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_call nimmt func (die ursprüngliche Funktion add) entgegen und definiert wrapper — eine innere Funktion, die func aufruft, aber zusätzliches Logging vor/nach hinzufügt.
  • log_call(add) gibt wrapper zurück und wir weisen es zurück an add zu. Von jetzt an ist der Aufruf von add(...) tatsächlich der Aufruf von wrapper(...).

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.wraps immer 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:

  1. retry(times, delay) — eine Factory-Funktion, die Konfigurationsparameter entgegennimmt und einen decorator zurückgibt.
  2. decorator(func) — der echte Dekorator, der die ursprüngliche Funktion entgegennimmt.
  3. 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 initialisiert call_count = 0.
  • functools.update_wrapper(self, func) — äquivalent zu functools.wraps, aber für Klasseninstanzen, kopiert Metadaten auf das Objekt self.
  • __call__ ermöglicht es, dass die Instanz process_order (jetzt ein CountCalls-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 self der ursprünglichen Klasse und des Dekotators leicht kollidieren). Bei Methoden bevorzuge verschachtelte Funktionen oder functools.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 in wrapper, 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.

pythondekoratorclosurefunktionale-programmierungsauberer-code