post

Fortgeschrittenes Python: Decorators, Magic Methods und Generatoren

Drei Bausteine, die pythonischen Code ausmachen: Decorators umschließen Funktionen, Magic Methods binden eigene Objekte an Pythons Protokolle, Generatoren erzeugen lazy Sequenzen.

In meinen Python-Schulungen gibt es einen wiederkehrenden Moment. Die Teilnehmenden beherrschen Funktionen, Klassen, Listen und Dictionaries – und dann fällt der Satz: „Das könnte ich in jeder Sprache so schreiben." Genau da setze ich an. Denn Python hat drei Bausteine, die den Unterschied zwischen „läuft" und „liest sich pythonisch" ausmachen: Decorators, Magic Methods und Generatoren. Sie sind kein Selbstzweck und keine Zauberei. Sie sind drei sauber definierte Mechanismen, mit denen man Verhalten wiederverwendet, eigene Objekte an die Sprache anschließt und große Datenmengen verarbeitet, ohne sie komplett in den Speicher zu holen.

Ich arbeite hier auf dem Stand von Anfang 2020, also mit Python 3.8. Alles, was ich zeige, läuft dort ohne Zusatzpakete – nur mit functools, time und der Standardbibliothek. Wer die Grundlagen dahinter auffrischen will, findet in Python für Entwickler, die Systeme verstehen wollen den passenden Unterbau.

Decorators: Funktionen, die Funktionen umschließen

Der Einstieg in Decorators ist einfacher, als der @-Zucker vermuten lässt. In Python sind Funktionen ganz normale Objekte. Man kann sie einer Variablen zuweisen, als Argument übergeben und aus einer anderen Funktion zurückgeben. Ein Decorator ist nichts weiter als eine Funktion höherer Ordnung: Sie nimmt eine Funktion entgegen und gibt eine – meist umschließende – Funktion zurück.

import functools
import time


def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper


@timer
def slow_sum(n):
    return sum(range(n))

Die Schreibweise @timer über def slow_sum ist exakt Syntaxzucker für slow_sum = timer(slow_sum). Nichts anderes passiert. Nach dem Dekorieren zeigt der Name slow_sum nicht mehr auf die ursprüngliche Funktion, sondern auf wrapper. Bei jedem Aufruf startet zuerst die Zeitmessung, dann läuft die echte Funktion, danach wird die verstrichene Zeit gedruckt. Das *args, **kwargs sorgt dafür, dass der Wrapper beliebige Signaturen durchreicht, ohne selbst etwas über die Argumente wissen zu müssen.

Den Ablauf beim Aufruf einer dekorierten Funktion zeige ich in der Schulung gern als Diagramm – der Wrapper klammert die eigentliche Arbeit ein:

flowchart LR
    Call["call slow_sum()"] --> W["wrapper()"]
    W -->|"before:<br/>start timer"| F["original func()"]
    F -->|"return value"| W
    W -->|"after:<br/>print elapsed"| Ret["return result"]

functools.wraps ist keine Kür

Ein Detail, das in vielen selbstgeschriebenen Decorators fehlt und das ich deshalb immer explizit mache: die Zeile @functools.wraps(func). Ohne sie funktioniert der Decorator zwar, aber der Wrapper übernimmt nicht die Identität der ursprünglichen Funktion. slow_sum.__name__ liefert dann "wrapper" statt "slow_sum", slow_sum.__doc__ ist der Docstring des Wrappers, und help(slow_sum) zeigt die falsche Signatur.

def timer_broken(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


@timer_broken
def greet(name):
    """Say hello to someone."""
    return f"Hello, {name}"


print(greet.__name__)   # -> "wrapper"
print(greet.__doc__)    # -> None

functools.wraps ist ein Convenience-Wrapper um functools.update_wrapper. Er kopiert __module__, __name__, __qualname__, __annotations__ und __doc__ von der Originalfunktion auf den Wrapper und setzt zusätzlich __wrapped__, sodass man im Zweifel wieder an die ungeschützte Funktion herankommt. Für Debugging, Introspektion und Werkzeuge, die auf Metadaten schauen, ist das der Unterschied zwischen brauchbar und verwirrend. Ich behandle die Zeile deshalb nicht als optional, sondern als festen Bestandteil jedes Decorators.

Decorators mit Argumenten

Häufig will man einen Decorator konfigurieren, etwa: „Führe die Funktion dreimal aus." Dafür braucht man eine Ebene mehr. Ein Decorator mit Argumenten ist eine Funktion, die einen Decorator zurückgibt, der wiederum den Wrapper zurückgibt – drei ineinander geschachtelte Funktionen.

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator


@repeat(times=3)
def ping():
    print("ping")

@repeat(times=3) wird zuerst ausgewertet: Der Aufruf repeat(times=3) liefert decorator zurück, und dieses decorator wird dann auf ping angewandt. Wer den Aufbau einmal von innen nach außen gelesen hat, dem erschließt sich das Muster – und es taucht in vielen Bibliotheken genau so auf, etwa bei Routing-Decorators in Web-Frameworks.

Magic Methods: eigene Objekte, die sich wie eingebaute verhalten

Der zweite Baustein ist der, bei dem in der Schulung am meisten Aha-Momente entstehen. Magic Methods – auch Dunder-Methoden genannt, wegen der doppelten Unterstriche – sind der Weg, über den eigene Klassen an Pythons Protokolle andocken. Man ruft diese Methoden nie direkt auf. Stattdessen ruft der Interpreter sie auf, wenn man ein Objekt in einen bekannten Kontext stellt: len(obj) löst __len__ aus, == löst __eq__ aus, ein for-Loop löst __iter__ aus, ein with-Block löst __enter__ und __exit__ aus.

Ein paar der gängigsten Dunder-Methoden und was sie auslösen:

  • __init__ – die Konstruktion beim Erzeugen einer Instanz
  • __repr__repr() und die Anzeige im REPL, idealerweise eindeutig und im besten Fall eval-fähig
  • __str__str() und print(); fehlt sie, springt __repr__ ein
  • __eq__ – der Vergleich mit ==
  • __len__ – der Aufruf len(obj), der auch für die Wahrheitsprüfung herangezogen wird, wenn __bool__ fehlt
  • __iter__ und __next__ – die Iteration in for-Loops und beim Entpacken
  • __enter__ und __exit__ – der Ein- und Ausstieg eines with-Blocks
  • __call__ – der Aufruf einer Instanz wie eine Funktion, obj()

Damit das konkret wird, baue ich eine Klasse, die Zeilen aus einer Datei bereitstellt. Sie soll ein ordentliches __repr__ haben, sich mit == vergleichen lassen, als Context Manager funktionieren und iterierbar sein.

class LineStore:
    def __init__(self, path):
        self.path = path

    def __repr__(self):
        return f"LineStore({self.path!r})"

    def __eq__(self, other):
        return isinstance(other, LineStore) and self.path == other.path

    def __enter__(self):
        self._file = open(self.path, encoding="utf-8")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        self._file.close()
        return False   # do not swallow exceptions

    def __iter__(self):
        return (line.rstrip("\n") for line in self._file)

Ein LineStore verhält sich jetzt wie ein eingebautes Objekt. Im REPL zeigt er LineStore('access.log') statt einer nichtssagenden Speicheradresse. Zwei Instanzen mit gleichem Pfad sind gleich. Und der eigentliche Clou steckt im with-Block:

with LineStore("access.log") as store:
    for line in store:
        if "ERROR" in line:
            print(line)

Das with ruft __enter__ auf, bindet den Rückgabewert an store, führt den Body aus und ruft danach – garantiert, auch bei einer Exception – __exit__ auf. Dort wird die Datei geschlossen. Genau das ist der Sinn eines Context Managers: Aufräumen, das man nicht vergessen kann. Der Rückgabewert von __exit__ entscheidet über den Umgang mit Fehlern. Gibt die Methode True zurück, wird eine im Block aufgetretene Exception unterdrückt; ein falsy Wert wie False reicht sie weiter. Ich gebe hier bewusst False zurück, weil ein Fehler beim Lesen der Datei nicht stillschweigend verschwinden soll.

Für einfache Fälle muss man das Protokoll übrigens nicht von Hand implementieren. contextlib.contextmanager verwandelt eine Generatorfunktion in einen Context Manager, wobei das yield Setup und Teardown trennt. Das führt uns direkt zum dritten Baustein.

Generatoren: lazy Sequenzen mit yield

Der LineStore schleppt eine Eigenheit mit sich: Sein __iter__ gibt bereits eine Generator-Expression zurück. Generatoren sind der dritte Pfeiler, und für mich der praktisch wichtigste, sobald Daten größer werden als der Arbeitsspeicher.

Sobald in einer Funktion das Schlüsselwort yield auftaucht, ist sie kein normales Callable mehr, sondern eine Generatorfunktion. Ihr Aufruf führt den Rumpf nicht aus, sondern gibt sofort ein Generator-Objekt zurück. Der Code läuft erst, wenn man über das Objekt iteriert – und er hält an jedem yield an, merkt sich seinen Zustand und macht beim nächsten Schritt genau dort weiter.

def stream_lines(path):
    with open(path, encoding="utf-8") as fh:
        for line in fh:          # a file object is itself an iterator
            yield line.rstrip("\n")


for line in stream_lines("big.log"):
    if line.startswith("2020"):
        print(line)

Hier steht immer nur eine Zeile gleichzeitig im Speicher. Egal, ob die Datei zehn Kilobyte oder zehn Gigabyte groß ist – der Speicherbedarf bleibt konstant. Genau das ist der Unterschied zu einer Liste. Würde ich fh.readlines() schreiben, hielte Python alle Zeilen auf einmal. Der Generator erzeugt sie einzeln, auf Abruf.

Dasselbe Prinzip gibt es kompakt als Generator-Expression – syntaktisch wie eine List Comprehension, nur mit runden statt eckigen Klammern:

# list comprehension: builds the whole list in memory
lengths_list = [len(line) for line in stream_lines("big.log")]

# generator expression: yields one length at a time
total = sum(len(line) for line in stream_lines("big.log"))

Die zweite Variante summiert die Zeilenlängen, ohne je eine vollständige Liste anzulegen. Für Aggregationen über große Datenmengen ist das der Standardweg. Und wenn man Generatoren verketten will, hilft yield from, um an einen anderen iterierbaren Wert zu delegieren, ohne selbst eine Schleife zu schreiben:

def chained(*paths):
    for path in paths:
        yield from stream_lines(path)

Drei Fallstricke, die ich immer zeige

So klar die drei Bausteine sind, so zuverlässig treten in Schulungen dieselben Stolperstellen auf. Drei davon gehören für mich in jede Einführung.

Veränderliche Default-Argumente. Der Klassiker. Wer eine leere Liste als Default schreibt, bekommt nicht bei jedem Aufruf eine frische Liste – sondern immer dieselbe.

def append_item(item, acc=[]):   # trap: shared across calls
    acc.append(item)
    return acc


print(append_item(1))   # [1]
print(append_item(2))   # [1, 2]  – surprise

Der Default wird genau einmal ausgewertet, nämlich beim def. Danach teilen sich alle Aufrufe dasselbe Objekt. Die Lösung ist ein Wächter über None:

def append_item(item, acc=None):
    if acc is None:
        acc = []
    acc.append(item)
    return acc

functools.wraps vergessen. Der oben gezeigte Fall gehört hierher. Ein Decorator ohne @functools.wraps funktioniert, aber er verschluckt Name, Docstring und Signatur der dekorierten Funktion. In kleinen Skripten fällt das nicht auf; sobald jemand die Funktion introspektiert oder ein Framework auf die Metadaten schaut, wird es zum Rätsel.

Ein Generator ist nach einem Durchlauf erschöpft. Das ist die Kehrseite der Lazy-Auswertung. Ein Generator merkt sich seinen Fortschritt und setzt ihn nicht zurück. Wer ihn zweimal durchläuft, bekommt beim zweiten Mal nichts.

gen = stream_lines("big.log")
first = list(gen)    # reads all lines
second = list(gen)   # empty – the generator is exhausted

Eine Liste kann man beliebig oft durchlaufen, einen Generator nur einmal. Wer die Daten mehrfach braucht, materialisiert sie bewusst in eine Liste – oder ruft die Generatorfunktion erneut auf, um ein frisches Objekt zu erhalten. Verwandt dazu: len() funktioniert nicht auf einem Generator, weil dessen Länge ohne vollständigen Durchlauf gar nicht bekannt ist.

Fazit

Decorators, Magic Methods und Generatoren wirken beim ersten Kontakt wie fortgeschrittene Kür, sind aber im Alltag Grundwerkzeug. Decorators trennen wiederkehrendes Verhalten – Zeitmessung, Logging, Wiederholung – sauber von der eigentlichen Logik, solange man functools.wraps nicht vergisst. Magic Methods schließen eigene Objekte an die Sprache an, sodass len(), ==, for und with einfach funktionieren, ohne dass der aufrufende Code etwas über die Interna wissen muss. Und Generatoren machen aus einer Funktion eine lazy Sequenz, die große Datenmengen Element für Element liefert, statt alles gleichzeitig zu halten.

Was diese drei Bausteine verbindet, ist ein gemeinsamer Gedanke: Python stellt klar definierte Protokolle bereit, und der eigene Code klinkt sich dort ein. Man schreibt nicht gegen die Sprache, sondern mit ihr. Wer das einmal verinnerlicht hat, erkennt die Muster überall in der Standardbibliothek wieder – und schreibt Code, der sich nicht nur ausführen, sondern lesen lässt.

Weiterführende Quellen

Kommentare

Kommentar schreiben