# Fortgeschrittenes Python: Decorators, Magic Methods und Generatoren

URL: https://www.mikebild.dev/de/blog/python-advanced-decorators-generatoren/

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.

```python
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:

```mermaid
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.

```python
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.

```python
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 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.

```python
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:

```python
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.

```python
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:

```python
# 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:

```python
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.

```python
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`:

```python
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.

```python
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` – Kapitel `13-decorators.md`, `14-magic-methods.md`, `15-iter.md` mit den zugehörigen Beispielen unter `examples/` (`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
- `functools` mit `wraps` und `update_wrapper`: https://docs.python.org/3.8/library/functools.html
- `contextlib.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/
