Dekoratoren in Python: Grundlagen bis Parameter
1. Überblick
Dekoratoren sind eines der ‘magischsten’ Features von Python, können aber zu Missverständnissen führen, wenn du sie nur copy-pastest, ohne die Grundlagen zu verstehen. Dieser Artikel geht vom Konzept der Funktion als First-Class-Citizen über grundlegende Dekoratoren, Dekoratoren zur Beibehaltung von Metadaten mit functools.wraps, bis hin zu parametrisierten Dekoratoren und klassenbasierten Dekoratoren. Nach diesem Artikel wirst du in der Lage sein, deine eigenen Logging-, Timing- oder Retry-Dekoratoren für deine Projekte zu schreiben, ohne den ursprünglichen Funktionsnamen zu verlieren.
2. Funktionen als First-Class-Citizen
Bevor du Dekoratoren lernst, musst du diesen Punkt verstehen: In Python sind Funktionen auch Objekte. Du kannst Funktionen Variablen zuweisen, Funktionen als Parameter übergeben oder Funktionen von anderen Funktionen zurückgeben.
def greet(name: str) -> str:
return f"Hallo, {name}!"
say_hello = greet
print(say_hello("Alice"))
Hallo, Alice!
say_hello ist nur ein anderer Name, der auf das gleiche Funktionsobjekt greet zeigt. Dank dieser Eigenschaft ermöglicht Python das Schreiben von Funktionen, die Funktionen als Eingabe annehmen und Funktionen als Ausgabe zurückgeben — das ist die Grundlage von Dekoratoren.
3. Grundlegende Dekoratoren: Funktionen, die Funktionen umhüllen
Ein Dekorator ist einfach eine Funktion, die eine andere Funktion entgegennimmt und eine neue Funktion zurückgibt, die das Verhalten der ursprünglichen Funktion ‘umhüllt’ (wrap).
def log_call(func):
def wrapper(*args, **kwargs):
print(f"Rufe {func.__name__} auf mit args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} hat {result} zurückgegeben")
return result
return wrapper
def add(a: int, b: int) -> int:
return a + b
add = log_call(add)
print(add(2, 3))
Rufe add auf mit args=(2, 3), kwargs={}
add hat 5 zurückgegeben
5
log_callnimmtfunc(die ursprüngliche Funktionadd) entgegen und definiertwrapper— eine innere Funktion, diefuncaufruft, aber zusätzliches Logging vor/nach hinzufügt.log_call(add)gibtwrapperzurück und wir weisen es zurück anaddzu. Von jetzt an ist der Aufruf vonadd(...)tatsächlich der Aufruf vonwrapper(...).
3.1. Die @-Syntax — Syntactic Sugar
Das manuelle Schreiben von func = decorator(func) ist ziemlich umständlich. Python bietet die @decorator-Syntax, um genau das zu tun, nur kürzer:
@log_call
def multiply(a: int, b: int) -> int:
return a * b
print(multiply(4, 5))
Rufe multiply auf mit args=(4, 5), kwargs={}
multiply hat 20 zurückgegeben
20
@log_call platziert direkt über def multiply ist gleichbedeutend mit multiply = log_call(multiply). Das ist nur syntactic sugar — es steckt keine versteckte Magie dahinter.
4. Das Problem des verlorenen Metadaten und functools.wraps
Wenn der Dekorator die ursprüngliche Funktion durch wrapper ‘ersetzt’, verlierst du Attribute wie __name__ und __doc__ der ursprünglichen Funktion.
@log_call
def subtract(a: int, b: int) -> int:
"""Subtrahiert zwei Zahlen."""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
wrapper
None
Das ist ein häufiger Fehler beim Debuggen oder bei der Verwendung von Dokumentationsgeneratoren — du denkst, du rufst subtract auf, aber Python sagt, der Name ist wrapper. Behebe das mit functools.wraps:
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Rufe {func.__name__} auf")
return func(*args, **kwargs)
return wrapper
@log_call
def subtract(a: int, b: int) -> int:
"""Subtrahiert zwei Zahlen."""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
subtract
Subtrahiert zwei Zahlen.
functools.wraps(func) ist ein Dekorator-von-einem-Dekorator: er kopiert __name__, __doc__, __module__… von func zu wrapper.
Hinweis: Verwende
functools.wrapsimmer in echten Dekoratoren. Das Weglassen wird Debugging erschweren und Introspection beschädigen (z.B.help(), Frameworks, die sich auf Funktionsnamen verlassen).
5. Dekoratoren mit Parametern
Oft möchtest du einen Dekorator konfigurierbar machen, z.B. @retry(times=3). Dazu brauchst du eine zusätzliche Funktionsebene: eine äußere Funktion akzeptiert Parameter und gibt den echten Dekorator zurück, der dann func entgegennimmt.
import functools
import time
def retry(times: int = 3, delay: float = 1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exception = exc
print(f"Versuch {attempt} fehlgeschlagen: {exc}")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(times=2, delay=0)
def call_payment_api(order_id: int) -> str:
raise ConnectionError(f"Zeitüberschreitung für Bestellung {order_id}")
try:
call_payment_api(101)
except ConnectionError as exc:
print(f"Gebe auf: {exc}")
Versuch 1 fehlgeschlagen: Zeitüberschreitung für Bestellung 101
Versuch 2 fehlgeschlagen: Zeitüberschreitung für Bestellung 101
Gebe auf: Zeitüberschreitung für Bestellung 101
Die Struktur mit drei Ebenen zum Merken:
retry(times, delay)— eine Factory-Funktion, die Konfigurationsparameter entgegennimmt und einendecoratorzurückgibt.decorator(func)— der echte Dekorator, der die ursprüngliche Funktion entgegennimmt.wrapper(*args, **kwargs)— die Ausführungsfunktion, wenn die dekorierte Funktion aufgerufen wird.
@retry(times=2, delay=0) ruft tatsächlich retry(times=2, delay=0) zuerst auf (gibt decorator zurück), dann wird decorator auf call_payment_api angewendet.
5.1. Dekoratoren mit und ohne Parameter
Manchmal möchtest du, dass @retry sowohl mit als auch ohne Parameter funktioniert (@retry statt @retry()). Eine saubere Weise ist, functools.partial kombiniert mit einer callable-Überprüfung zu verwenden:
import functools
def retry(func=None, *, times: int = 3):
if func is None:
return functools.partial(retry, times=times)
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
print(f"Versuch {attempt} fehlgeschlagen: {exc}")
raise RuntimeError("Alle Versuche fehlgeschlagen")
return wrapper
@retry
def task_a():
return "ok"
@retry(times=5)
def task_b():
return "ok"
Wenn du @retry (ohne Funktionsaufruf) verwendest, nimmt func direkt die Funktion task_a entgegen, und der if func is None-Zweig wird nicht ausgeführt. Wenn du @retry(times=5) verwendest, ist func anfänglich None, und die Funktion gibt functools.partial(retry, times=5) zurück — eine Funktion, die darauf wartet, func im nächsten Schritt zu erhalten, nämlich task_b.
6. Dekoratoren als Klassen
Statt verschachtelte Funktionen zu verwenden, kannst du Dekoratoren als Klassen schreiben und __call__ implementieren. Dies ist nützlich, wenn der Dekorator komplexe Zustände beibehalten muss.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"{self.func.__name__} wurde {self.call_count} Mal aufgerufen")
return self.func(*args, **kwargs)
@CountCalls
def process_order(order_id: int) -> None:
print(f"Verarbeite Bestellung {order_id}")
process_order(1)
process_order(2)
print(process_order.call_count)
process_order wurde 1 Mal aufgerufen
Verarbeite Bestellung 1
process_order wurde 2 Mal aufgerufen
Verarbeite Bestellung 2
2
__init__speichert die ursprüngliche Funktion und initialisiertcall_count = 0.functools.update_wrapper(self, func)— äquivalent zufunctools.wraps, aber für Klasseninstanzen, kopiert Metadaten auf das Objektself.__call__ermöglicht es, dass die Instanzprocess_order(jetzt einCountCalls-Objekt, keine Funktion) wie eine normale Funktion aufgerufen werden kann.
Hinweis: Klassenbasierte Dekoratoren funktionieren nicht gut, wenn sie Methoden in anderen Klassen dekorieren (da die
selfder ursprünglichen Klasse und des Dekotators leicht kollidieren). Bei Methoden bevorzuge verschachtelte Funktionen oderfunctools.singledispatchmethod, wenn Dispatching erforderlich ist.
7. Mehrere Dekoratoren stapeln (Stacking)
Du kannst mehrere Dekoratoren auf einer Funktion stapeln. Die Reihenfolge der Anwendung ist von unten nach oben (der Dekorator nächst zur Funktion läuft zuerst).
@log_call
@retry(times=2, delay=0)
def charge_card(amount: float) -> str:
if amount <= 0:
raise ValueError("Betrag muss positiv sein")
return f"Habe {amount} berechnet"
print(charge_card(10))
Rufe wrapper auf
Habe 10 berechnet
Hier wird retry(times=2, delay=0) zuerst angewendet und umhüllt charge_card in eine Funktion mit Retry-Fähigkeit; dann umhüllt log_call es von außen. Wenn du functools.wraps nicht richtig bei retry verwendest, wirst du sehen, dass das Logging wrapper statt charge_card ausgibt — ein weiterer Beweis für die Bedeutung von Punkt 4.
8. Tests für Dekoratoren schreiben
Dekoratoren sind immer noch normale Python-Funktionen, daher kannst du sie mit pytest wie jede andere Logik testen. Installiere die Bibliothek, falls du sie noch nicht hast:
pip install pytest
Beispiele für Tests des retry-Dekotators:
import pytest
def test_retry_raises_after_exhausting_attempts():
call_log = []
@retry(times=3, delay=0)
def always_fail():
call_log.append("aufgerufen")
raise ValueError("boom")
with pytest.raises(ValueError):
always_fail()
assert len(call_log) == 3
def test_retry_succeeds_on_second_attempt():
attempts = {"count": 0}
@retry(times=3, delay=0)
def fail_once_then_succeed():
attempts["count"] += 1
if attempts["count"] < 2:
raise ValueError("boom")
return "ok"
result = fail_once_then_succeed()
assert result == "ok"
assert attempts["count"] == 2
Tests ausführen:
pytest test_retry.py -v
test_retry.py::test_retry_raises_after_exhausting_attempts PASSED
test_retry.py::test_retry_succeeds_on_second_attempt PASSED
call_log und attempts fungieren als Spies, um zu überprüfen, wie oft die ursprüngliche Funktion aufgerufen wird — entsprechend dem erwarteten Retry-Verhalten, ohne externe Bibliotheken zu mocken.
9. Häufige Fehler
- Vergessen von
functools.wraps: Funktion verliert Namen/Docstring, verursacht Debugging-Schwierigkeiten und Fehler bei Verwendung mit Frameworks, die sich auf__name__verlassen (z.B. Routing in Flask). - Mutable Standard-Argumente im Wrapper: ähnlich wie generelle Python-Fehler, vermeide
args=[]als Standard. - Dekorator mit starken Nebenwirkungen zur Importzeit: Wenn der Dekorator I/O durchführt (API aufrufen, Dateien lesen) direkt in
decorator(func)statt inwrapper, wird es jedes Mal ausgeführt, wenn das Modul importiert wird, nicht jedes Mal, wenn die Funktion aufgerufen wird. - Missbrauch von Dekoratoren für komplexe Logik: Wenn der Dekorator zu viele Verzweigungen enthält, ist der Code schwerer zu lesen als ein direkter Funktionsaufruf. Bei komplexer Logik solltest du Context Manager in Betracht ziehen oder Code in eine separate Klasse auslagern, statt es in den Dekorator zu stopfen.
Wann man Dekoratoren NICHT verwenden sollte: Wenn das “Umhüll”-Verhalten nur einmal verwendet wird, nicht wiederverwendbar ist, macht das Schreiben von Dekoratoren nur den Code schwerer nachzuverfolgbar als einen direkten Funktionsaufruf. Dekoratoren zeigen ihren Wert bei der Anwendung von Cross-Cutting Concerns wie Logging, Caching, Retry oder Authentifizierung auf mehrere verschiedene Funktionen.
10. Fazit
Dekoratoren in Python sind nichts Mysteriöses — sie sind einfach Funktionen, die Funktionen entgegennehmen und Funktionen zurückgeben, basierend darauf, dass Funktionen First-Class-Citizens sind. Vom grundlegenden Dekorator fügest du functools.wraps hinzu, um Metadaten zu bewahren, addierst eine weitere Funktionsebene, um Konfigurationsparameter entgegenzunehmen, oder wechselst zu einer klassenbasierten Implementierung, wenn du komplexe Zustände verwalten musst. Mit diesen Mustern beherrschst du die Fähigkeit, saubere und leicht testbare Logging-, Caching-, Retry- oder Validierungs-Dekoratoren für echte Projekte zu schreiben.