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.
Python-Code, den man Zeile für Zeile genauso in Java oder C# hätte schreiben können, ist selten falsch – er nutzt die Sprache nur nicht. Drei Mechanismen machen den Unterschied, und keiner davon ist Zauberei: der @-Decorator, die Dunder-Methode und yield. Alle drei sind im Sprachkern dokumentiert, jeder ist in wenigen Zeilen selbst nachbaubar, und jeder löst ein Problem, das man sonst mit Copy-Paste oder mit zu viel Arbeitsspeicher löst.
Der Stand hier ist Anfang 2020, also Python 3.8. Alle Beispiele laufen dort ohne Zusatzpakete – nur mit functools, time und der Standardbibliothek. Vorausgesetzt sind Python-Objekte, Funktionen und die üblichen eingebauten Datentypen.
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.
Der Ablauf beim Aufruf einer dekorierten Funktion, aufgezeichnet – 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 fehlt in selbstgeschriebenen Decorators mit schöner Regelmäßigkeit: 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 undekorierte Funktion herankommt. Für Debugging, Introspektion und Werkzeuge, die auf Metadaten schauen, ist das der Unterschied zwischen brauchbar und verwirrend. Die Zeile gehört deshalb in jeden Decorator, den ich schreibe.
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 greift tiefer in die Sprache hinein. 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 Falleval-fähig__str__–str()undprint(); fehlt sie, springt__repr__ein__eq__– der Vergleich mit==__len__– der Aufruflen(obj), der auch für die Wahrheitsprüfung herangezogen wird, wenn__bool__fehlt__iter__und__next__– die Iteration infor-Loops und beim Entpacken__enter__und__exit__– der Ein- und Ausstieg eineswith-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
So klar die drei Bausteine sind, so zuverlässig treten in der Praxis dieselben Stolperstellen auf. Diese drei kosten die meiste Zeit.
Der Klassiker sind veränderliche Default-Argumente. 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
Die zweite Stolperstelle ist das vergessene functools.wraps, der oben gezeigte Fall. Ein Decorator ohne diese Zeile 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.
Die dritte trifft Generatoren: Nach einem Durchlauf sind sie erschöpft, und 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
- Foliensatz zu diesem Thema: https://supabase.mikebild.dev/storage/v1/object/public/slides/introduction-python.html
- Repository
introduction-python– Kapitel13-decorators.md,14-magic-methods.md,15-iter.mdmit den zugehörigen Beispielen unterexamples/(decorators.py,classes.py,functional.py) - Python Data Model / Special Methods: https://docs.python.org/3.8/reference/datamodel.html
- Das
with-Statement und das Context-Manager-Protokoll: https://docs.python.org/3.8/reference/compound_stmts.html#the-with-statement functoolsmitwrapsundupdate_wrapper: https://docs.python.org/3.8/library/functools.htmlcontextlib.contextmanager: https://docs.python.org/3.8/library/contextlib.html- Yield-Ausdrücke und Generatoren: https://docs.python.org/3.8/reference/expressions.html#yield-expressions
- Tutorial zu Generators und Generator Expressions: https://docs.python.org/3.8/tutorial/classes.html#generators
- PEP 318 – Decorators for Functions and Methods: https://www.python.org/dev/peps/pep-0318/
Wie fandest du diesen Beitrag?
Kommentare