Zum Inhalt springen

cat posts/decorators-entmystifiziert.md

Decorators entmystifiziert

Was hinter dem @ steckt: Funktionen sind Objekte, Closures verpacken Verhalten, functools.wraps erhält Metadaten – und Decorators werden damit alltagstauglich.

In Teil 11 der Python-Serie sind Decorators kurz aufgetaucht: diese @-Zeilen über Funktionen und Klassen.

Sie wirken zunächst magisch:

@mit_protokoll
def speichern(name):
    ...

Oder:

@dataclass
class Gegenstand:
    ...

Oder in Webframeworks:

@app.get("/status")
def status():
    ...

Decorators sind aber keine Magie. Hinter ihnen stecken vor allem drei Ideen:

  1. Funktionen sind Objekte.
  2. Funktionen können andere Funktionen entgegennehmen und zurückgeben.
  3. @decorator ist Kurzform für eine Neuzuweisung.

Wenn Du diese drei Punkte verstanden hast, werden Decorators deutlich weniger mysteriös.

Funktionen sind Objekte

Eine Funktion ist in Python ein Objekt.

Du kannst sie einer Variablen zuweisen:

def begruesse():
    return "Hallo"


funktion = begruesse

print(funktion())

Ausgabe:

Hallo

Wichtig ist der Unterschied zwischen:

begruesse

und:

begruesse()

begruesse meint die Funktion selbst.

begruesse() ruft die Funktion auf und liefert ihr Ergebnis.

print(begruesse)
print(begruesse())

Ausgabe ungefähr:

<function begruesse at 0x...>
Hallo

Genau weil Funktionen Objekte sind, kannst Du sie weiterreichen.

Funktionen als Argumente übergeben

Eine Funktion kann eine andere Funktion als Argument bekommen:

def begruesse(name):
    return f"Hallo, {name}!"


def rufe_auf(funktion, wert):
    ergebnis = funktion(wert)
    print(ergebnis)


rufe_auf(begruesse, "Karl")

Ausgabe:

Hallo, Karl!

rufe_auf(...) weiß nicht, welche Funktion sie bekommt. Sie ruft einfach das übergebene Funktionsobjekt auf.

Das kennst Du indirekt schon von sorted(...):

namen = ["Karl", "Alexandra", "Mia"]

sortiert = sorted(namen, key=len)

print(sortiert)

Ausgabe:

['Mia', 'Karl', 'Alexandra']

len wird hier als Funktion übergeben. sorted(...) ruft sie später für die einzelnen Werte auf.

Funktionen können Funktionen zurückgeben

Eine Funktion kann auch eine neue Funktion erzeugen und zurückgeben.

def baue_begruessung():
    def begruesse(name):
        return f"Hallo, {name}!"

    return begruesse

Verwendung:

funktion = baue_begruessung()

print(funktion("Karl"))

Ausgabe:

Hallo, Karl!

Die innere Funktion begruesse(...) entsteht beim Aufruf von baue_begruessung(...) und wird anschließend zurückgegeben.

Damit sind wir schon sehr nah an Decorators.

Eine Funktion einwickeln

Ein Decorator ist im Kern eine Funktion, die eine andere Funktion entgegennimmt und eine neue Funktion zurückgibt.

def mit_protokoll(funktion):
    def wrapper():
        print(f"[log] {funktion.__name__} startet")
        ergebnis = funktion()
        print(f"[log] {funktion.__name__} fertig")
        return ergebnis

    return wrapper

Eine normale Funktion:

def speichern():
    print("Spielstand gespeichert.")

Jetzt wickeln wir sie von Hand ein:

speichern = mit_protokoll(speichern)

Danach:

speichern()

Ausgabe:

[log] speichern startet
Spielstand gespeichert.
[log] speichern fertig

Der Name speichern zeigt jetzt nicht mehr auf die ursprüngliche Funktion, sondern auf den wrapper.

Der wrapper ruft innen die ursprüngliche Funktion auf und ergänzt Verhalten davor und danach.

Das ist das Grundprinzip eines Decorators.

Das @ ist nur Kurzform

Diese Schreibweise:

@mit_protokoll
def speichern():
    print("Spielstand gespeichert.")

ist Kurzform für:

def speichern():
    print("Spielstand gespeichert.")


speichern = mit_protokoll(speichern)

Mehr steckt im einfachen Fall nicht hinter der @-Syntax.

Der Decorator wird direkt nach der Funktionsdefinition angewendet. Der Name der Funktion wird anschließend an das Ergebnis des Decorators gebunden.

Das bedeutet:

speichern()

ruft später nicht mehr direkt die ursprüngliche Funktion auf, sondern die eingewickelte Version.

Argumente weiterreichen mit *args und **kwargs

Unser erster Decorator funktioniert nur für Funktionen ohne Argumente.

Diese Funktion hätte ein Problem:

def speichern(name):
    print(f"{name} gespeichert.")

Der bisherige wrapper() nimmt keine Argumente entgegen.

Damit ein Decorator allgemein verwendbar ist, nutzt man *args und **kwargs:

def mit_protokoll(funktion):
    def wrapper(*args, **kwargs):
        print(f"[log] {funktion.__name__} startet")
        ergebnis = funktion(*args, **kwargs)
        print(f"[log] {funktion.__name__} fertig")
        return ergebnis

    return wrapper

Jetzt funktioniert:

@mit_protokoll
def speichern(name):
    print(f"{name} gespeichert.")


speichern("Karl")

Ausgabe:

[log] speichern startet
Karl gespeichert.
[log] speichern fertig

*args sammelt positionale Argumente.

**kwargs sammelt Keyword Arguments.

Der wrapper nimmt alles entgegen und reicht es unverändert an die ursprüngliche Funktion weiter.

Rückgabewerte nicht verlieren

Ein häufiger Fehler beim Schreiben eigener Decorators ist, den Rückgabewert der ursprünglichen Funktion zu vergessen.

Schlecht:

def mit_protokoll(funktion):
    def wrapper(*args, **kwargs):
        print("Starte")
        funktion(*args, **kwargs)
        print("Fertig")

    return wrapper

Wenn die dekorierte Funktion etwas zurückgibt, geht dieser Wert verloren.

@mit_protokoll
def addiere(a, b):
    return a + b


ergebnis = addiere(2, 3)

print(ergebnis)

Ausgabe:

Starte
Fertig
None

Besser:

def mit_protokoll(funktion):
    def wrapper(*args, **kwargs):
        print("Starte")
        ergebnis = funktion(*args, **kwargs)
        print("Fertig")
        return ergebnis

    return wrapper

Jetzt bleibt der Rückgabewert erhalten:

Starte
Fertig
5

Faustregel:

Wenn der Decorator den Rückgabewert nicht bewusst verändern soll, gib ihn unverändert zurück.

Closures: die Funktion erinnert sich

Der wrapper verwendet die Variable funktion:

def mit_protokoll(funktion):
    def wrapper(*args, **kwargs):
        return funktion(*args, **kwargs)

    return wrapper

Obwohl mit_protokoll(...) längst fertig ausgeführt ist, weiß wrapper später noch, welche Funktion gemeint war.

Das nennt man eine Closure.

Eine Closure ist eine innere Funktion, die sich Variablen aus ihrem umgebenden Scope merkt.

Kleines Beispiel:

def multiplizierer(faktor):
    def rechne(zahl):
        return zahl * faktor

    return rechne

Verwendung:

verdopple = multiplizierer(2)
verdreifache = multiplizierer(3)

print(verdopple(10))
print(verdreifache(10))

Ausgabe:

20
30

rechne(...) merkt sich jeweils den passenden faktor.

Decorators nutzen genau dieses Prinzip: Der wrapper merkt sich die ursprüngliche Funktion.

functools.wraps nicht vergessen

Unser Decorator funktioniert, hat aber ein Problem.

def mit_protokoll(funktion):
    def wrapper(*args, **kwargs):
        return funktion(*args, **kwargs)

    return wrapper


@mit_protokoll
def speichern(name):
    """Speichert den Spielstand."""
    print(f"{name} gespeichert.")

Von außen sieht die Funktion nun so aus:

print(speichern.__name__)
print(speichern.__doc__)

Ausgabe:

wrapper
None

Die dekorierte Funktion heißt scheinbar wrapper. Ihr Docstring ist verloren.

Das ist schlecht für:

  • Debugging,
  • Tracebacks,
  • Dokumentation,
  • CLI-Frameworks,
  • Tests,
  • Introspection,
  • Type Checker und Editor-Hilfe.

Darum verwendet man functools.wraps:

from functools import wraps


def mit_protokoll(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        return funktion(*args, **kwargs)

    return wrapper

Nun bleiben wichtige Metadaten erhalten:

@mit_protokoll
def speichern(name):
    """Speichert den Spielstand."""
    print(f"{name} gespeichert.")


print(speichern.__name__)
print(speichern.__doc__)

Ausgabe:

speichern
Speichert den Spielstand.

Faustregel:

In jeden selbst geschriebenen Function Decorator gehört @functools.wraps an den wrapper.

Ein vollständiger Logging-Decorator

Ein alltagstauglicher Decorator sieht also eher so aus:

from functools import wraps


def mit_protokoll(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        print(f"[log] {funktion.__name__} startet")

        ergebnis = funktion(*args, **kwargs)

        print(f"[log] {funktion.__name__} fertig")

        return ergebnis

    return wrapper

Verwendung:

@mit_protokoll
def speichern(name):
    print(f"{name} gespeichert.")
    return True


erfolg = speichern("Karl")

print(erfolg)

Ausgabe:

[log] speichern startet
Karl gespeichert.
[log] speichern fertig
True

Für echtes Logging würdest Du statt print(...) natürlich das logging-Modul verwenden.

import logging
from functools import wraps

logger = logging.getLogger(__name__)


def mit_protokoll(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        logger.info("%s startet", funktion.__name__)

        ergebnis = funktion(*args, **kwargs)

        logger.info("%s fertig", funktion.__name__)

        return ergebnis

    return wrapper

Exceptions im Decorator

Ein Decorator kann auch Fehler protokollieren.

import logging
from functools import wraps

logger = logging.getLogger(__name__)


def logge_fehler(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        try:
            return funktion(*args, **kwargs)
        except Exception:
            logger.exception("%s ist fehlgeschlagen", funktion.__name__)
            raise

    return wrapper

Wichtig ist das abschließende:

raise

Damit wird die ursprüngliche Exception erneut ausgelöst.

Der Decorator loggt also den Fehler, verschluckt ihn aber nicht.

Schlecht wäre:

except Exception:
    logger.exception("Fehler")
    return None

Das kann Bugs verstecken. Plötzlich sieht der Aufrufer nur None, obwohl eigentlich etwas kaputtgegangen ist.

Wenn ein Decorator Exceptions behandelt, sollte das sehr bewusst passieren.

Laufzeit messen

Ein praktischer Decorator misst die Laufzeit einer Funktion:

from functools import wraps
from time import perf_counter


def misst_laufzeit(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        start = perf_counter()

        try:
            return funktion(*args, **kwargs)
        finally:
            ende = perf_counter()
            dauer = ende - start
            print(f"{funktion.__name__} dauerte {dauer:.3f}s")

    return wrapper

Verwendung:

@misst_laufzeit
def lade_raeume():
    return ["halle", "bibliothek", "krypta"]


raeume = lade_raeume()

finally sorgt dafür, dass die Zeitmessung auch dann ausgegeben wird, wenn die Funktion eine Exception auslöst.

Das kann beim Debugging nützlich sein. Für Produktionscode wäre wieder Logging statt print(...) sinnvoller.

Decorators mit Argumenten

Manchmal soll der Decorator selbst konfigurierbar sein:

@wiederhole(3)
def klopf():
    print("klopf")

Das sieht so aus, als würde wiederhole(3) direkt die Funktion dekorieren.

Tatsächlich brauchen wir eine zusätzliche Ebene.

from functools import wraps


def wiederhole(mal):
    def decorator(funktion):
        @wraps(funktion)
        def wrapper(*args, **kwargs):
            ergebnis = None

            for _ in range(mal):
                ergebnis = funktion(*args, **kwargs)

            return ergebnis

        return wrapper

    return decorator

Verwendung:

@wiederhole(3)
def klopf():
    print("klopf")


klopf()

Ausgabe:

klopf
klopf
klopf

Die Ebenen sind:

  1. wiederhole(3) wird aufgerufen.
  2. Es entsteht ein Decorator.
  3. Dieser Decorator bekommt die Funktion.
  4. Er gibt den wrapper zurück.

Die Langform wäre:

def klopf():
    print("klopf")


decorator = wiederhole(3)
klopf = decorator(klopf)

Oder in einer Zeile:

klopf = wiederhole(3)(klopf)

Ein Decorator mit Optionen

Ein realistischeres Beispiel: ein Decorator, der eine Funktion bei Fehlern erneut aufruft.

from functools import wraps


def wiederholen_bei_fehler(versuche):
    if versuche < 1:
        raise ValueError("versuche muss mindestens 1 sein")

    def decorator(funktion):
        @wraps(funktion)
        def wrapper(*args, **kwargs):
            letzter_fehler = None

            for _ in range(versuche):
                try:
                    return funktion(*args, **kwargs)
                except OSError as fehler:
                    letzter_fehler = fehler

            raise letzter_fehler

        return wrapper

    return decorator

Verwendung:

@wiederholen_bei_fehler(3)
def lade_datei(pfad):
    with open(pfad, encoding="utf-8") as datei:
        return datei.read()

Der Decorator versucht die Funktion bis zu dreimal, aber nur bei OSError.

Das ist bewusst eingeschränkt. Ein Decorator, der einfach jede Exception schluckt und blind wiederholt, kann echte Programmierfehler verdecken.

Mehrere Decorators übereinander

Du kannst mehrere Decorators auf dieselbe Funktion setzen:

@decorator_a
@decorator_b
def funktion():
    ...

Die Anwendung erfolgt von unten nach oben.

Das entspricht:

def funktion():
    ...


funktion = decorator_a(decorator_b(funktion))

Der untere Decorator wird also zuerst auf die ursprüngliche Funktion angewendet. Der obere Decorator bekommt das Ergebnis des unteren.

Ein Beispiel:

from functools import wraps


def a(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        print("a davor")
        ergebnis = funktion(*args, **kwargs)
        print("a danach")
        return ergebnis

    return wrapper


def b(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        print("b davor")
        ergebnis = funktion(*args, **kwargs)
        print("b danach")
        return ergebnis

    return wrapper


@a
@b
def hallo():
    print("hallo")


hallo()

Ausgabe:

a davor
b davor
hallo
b danach
a danach

b umschließt zuerst die ursprüngliche Funktion. Danach umschließt a das Ergebnis.

Die Reihenfolge kann wichtig sein, zum Beispiel bei Authentifizierung, Caching, Logging oder Transaktionen.

State im Decorator

Closures können State speichern.

Beispiel: Zählen, wie oft eine Funktion aufgerufen wurde.

from functools import wraps


def zaehle_aufrufe(funktion):
    anzahl = 0

    @wraps(funktion)
    def wrapper(*args, **kwargs):
        nonlocal anzahl

        anzahl += 1

        print(f"{funktion.__name__} wurde {anzahl}x aufgerufen")

        return funktion(*args, **kwargs)

    return wrapper

Verwendung:

@zaehle_aufrufe
def begruesse(name):
    print(f"Hallo, {name}!")


begruesse("Karl")
begruesse("Lara")

Ausgabe:

begruesse wurde 1x aufgerufen
Hallo, Karl!
begruesse wurde 2x aufgerufen
Hallo, Lara!

Dieser State gehört zu genau dieser dekorierten Funktion.

Dekorierst Du eine zweite Funktion, bekommt sie ihren eigenen Closure-State.

Das kann nützlich sein. Es kann aber auch überraschen, wenn der Decorator mutable Daten speichert und mehrere Aufrufe beeinflusst.

Für einfache Logging- oder Timing-Decorators brauchst Du normalerweise keinen veränderlichen State.

Caching mit functools.cache

Ein sehr praktischer Decorator aus der Standardbibliothek ist functools.cache.

from functools import cache


@cache
def fibonacci(n):
    if n < 2:
        return n

    return fibonacci(n - 1) + fibonacci(n - 2)

Ohne Cache berechnet eine naive rekursive Fibonacci-Funktion dieselben Werte ständig neu.

Mit @cache merkt sich Python bereits berechnete Ergebnisse.

print(fibonacci(40))

Das ist deutlich schneller als die ungebremste rekursive Variante.

Wichtig: Die Argumente müssen hashable sein, weil der Cache sie als Schlüssel verwendet.

Gut:

fibonacci(40)

Problematisch:

@cache
def verarbeite_liste(werte):
    return sum(werte)


verarbeite_liste([1, 2, 3])

Eine Liste ist nicht hashable. Dafür wäre ein Tupel geeignet:

verarbeite_liste((1, 2, 3))

lru_cache: begrenzter Cache

functools.lru_cache ist die ältere und flexiblere Variante.

from functools import lru_cache


@lru_cache(maxsize=128)
def berechne(zahl):
    return zahl * zahl

maxsize=128 bedeutet: Es werden bis zu 128 Ergebnisse gespeichert.

cache ist im Wesentlichen die einfache unbeschränkte Variante.

Orientierung:

  • @cache: simpel, unbeschränkt, gut für kleine Argumenträume.
  • @lru_cache(maxsize=...): begrenzt, besser kontrollierbar.

Bei langlebigen Programmen solltest Du unbeschränkte Caches vorsichtig einsetzen. Sie können wachsen, wenn immer neue Argumente verwendet werden.

@property: Methoden wie Attribute verwenden

Ein weiterer häufiger Decorator ist @property.

class Spieler:
    def __init__(self, name, hp, max_hp):
        self.name = name
        self.hp = hp
        self.max_hp = max_hp

    @property
    def ist_besiegt(self):
        return self.hp <= 0

Verwendung:

spieler = Spieler("Karl", hp=0, max_hp=100)

print(spieler.ist_besiegt)

Ausgabe:

True

Ohne @property müsstest Du schreiben:

spieler.ist_besiegt()

Mit @property wirkt die Methode wie ein Attribut.

Das passt gut für berechnete Eigenschaften, die keine sichtbaren Nebenwirkungen haben.

Weniger gut:

@property
def lade_datei(self):
    ...

Eine Property sollte nicht überraschend teure oder verändernde Aktionen ausführen.

Setter für Properties

Properties können auch Setter haben.

class Spieler:
    def __init__(self, name, hp=100):
        self.name = name
        self._hp = 0
        self.hp = hp

    @property
    def hp(self):
        return self._hp

    @hp.setter
    def hp(self, wert):
        if wert < 0:
            wert = 0

        self._hp = wert

Verwendung:

spieler = Spieler("Karl")

spieler.hp = -50

print(spieler.hp)

Ausgabe:

0

Der Setter fängt negative Werte ab.

Das kann sinnvoll sein. Trotzdem sollte man Properties nicht überstrapazieren. Wenn eine Operation fachlich etwas tut, ist eine Methode oft klarer:

spieler.erleide_schaden(50)

ist aussagekräftiger als eine versteckte Berechnung in einem Setter.

@staticmethod und @classmethod

Auch staticmethod und classmethod sind Decorators.

@staticmethod

Eine Static Method lebt im Namensraum der Klasse, bekommt aber weder self noch cls.

class Wuerfel:
    @staticmethod
    def ist_gueltige_seitenzahl(seiten):
        return seiten >= 2

Aufruf:

print(Wuerfel.ist_gueltige_seitenzahl(6))

Ausgabe:

True

Das ist im Grunde eine normale Funktion, die logisch zur Klasse gehört.

Wenn sie weder Objekt noch Klasse braucht, kann sie aber oft auch einfach eine Modulfunktion sein.

@classmethod

Eine Class Method bekommt die Klasse als erstes Argument, üblicherweise cls.

class Spieler:
    def __init__(self, name, hp):
        self.name = name
        self.hp = hp

    @classmethod
    def aus_dict(cls, daten):
        return cls(
            name=daten["name"],
            hp=daten["hp"],
        )

Verwendung:

daten = {
    "name": "Karl",
    "hp": 100,
}

spieler = Spieler.aus_dict(daten)

print(spieler.name)

Ausgabe:

Karl

@classmethod ist besonders nützlich für alternative Konstruktoren.

@dataclass: ein Class Decorator

Decorators können nicht nur Funktionen dekorieren, sondern auch Klassen.

Ein sehr bekanntes Beispiel ist @dataclass:

from dataclasses import dataclass


@dataclass
class Gegenstand:
    name: str
    beschreibung: str = ""

Der Decorator ergänzt Methoden wie __init__, __repr__ und __eq__ anhand der annotierten Fields.

Das ist keine Magie. @dataclass nimmt die Klasse entgegen, bearbeitet sie und gibt sie zurück.

Vereinfacht entspricht:

@dataclass
class Gegenstand:
    ...

der Idee:

class Gegenstand:
    ...


Gegenstand = dataclass(Gegenstand)

Natürlich macht dataclass(...) intern deutlich mehr, aber das Decorator-Prinzip ist dasselbe.

Decorators in Frameworks

Viele Frameworks verwenden Decorators, um Funktionen zu registrieren.

Ein typisches Muster in Webframeworks:

@app.get("/status")
def status():
    return {"ok": True}

Oder bei Click:

@click.command()
@click.option("--name")
def begruesse(name):
    click.echo(f"Hallo, {name}")

Oder bei pytest:

@pytest.fixture
def spieler():
    return Spieler("Test")

Der Decorator verändert hier nicht immer nur den Funktionsaufruf.

Manchmal registriert er die Funktion auch irgendwo:

  • als Route,
  • als CLI-Kommando,
  • als Test-Fixture,
  • als Signal-Handler,
  • als Task,
  • als Plugin.

Das Prinzip bleibt:

Eine Funktion oder Klasse wird an einen Decorator übergeben. Der Decorator gibt etwas zurück oder registriert nebenbei zusätzliche Informationen.

Decorator oder normale Funktion?

Nur weil Decorators möglich sind, sind sie nicht immer die beste Lösung.

Ein Decorator ist gut, wenn dasselbe Verhalten viele Funktionen umgeben soll:

  • Logging,
  • Timing,
  • Caching,
  • Berechtigungsprüfung,
  • Transaktionen,
  • Retry-Logik,
  • Registrierung in Frameworks.

Beispiel:

@misst_laufzeit
def lade_spielstand():
    ...

Weniger sinnvoll ist ein Decorator, wenn das Verhalten nur an einer Stelle gebraucht wird oder der Ablauf dadurch schwerer lesbar wird.

Statt:

@irgendwie_vorbereiten
def speichern():
    ...

ist manchmal klarer:

def speichern():
    bereite_vor()
    ...

Decorators verstecken bewusst einen Teil des Ablaufs. Das ist nützlich, wenn der versteckte Teil allgemein und wiederverwendbar ist. Es ist schlecht, wenn dadurch fachliche Logik unsichtbar wird.

Decorators typisieren

Decorators mit Type Hints sauber zu typisieren, ist ein fortgeschritteneres Thema.

Für einfache Fälle kannst Du zunächst untypisiert bleiben.

Wenn Du die Signatur der dekorierten Funktion erhalten möchtest, helfen ParamSpec und TypeVar:

from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def mit_protokoll(funktion: Callable[P, R]) -> Callable[P, R]:
    @wraps(funktion)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"{funktion.__name__} startet")
        return funktion(*args, **kwargs)

    return wrapper

Das bedeutet grob:

  • P steht für die Parameter der ursprünglichen Funktion.
  • R steht für ihren Rückgabetyp.
  • Der Decorator gibt wieder eine Funktion mit denselben Parametern und demselben Rückgabetyp zurück.

Für den Einstieg musst Du das nicht auswendig können. Wichtig ist nur: Decorators können typisiert werden, aber es ist anspruchsvoller als bei normalen Funktionen.

Testen von Decorators

Einen Decorator testest Du am einfachsten mit einer kleinen Testfunktion.

Beispiel für mit_protokoll(...):

def test_mit_protokoll_gibt_ergebnis_zurueck(capsys):
    @mit_protokoll
    def addiere(a, b):
        return a + b

    ergebnis = addiere(2, 3)

    ausgabe = capsys.readouterr()

    assert ergebnis == 5
    assert "addiere startet" in ausgabe.out
    assert "addiere fertig" in ausgabe.out

Für wraps kannst Du prüfen:

def test_mit_protokoll_erhaelt_name():
    @mit_protokoll
    def addiere(a, b):
        """Addiert zwei Zahlen."""
        return a + b

    assert addiere.__name__ == "addiere"
    assert addiere.__doc__ == "Addiert zwei Zahlen."

Wenn ein Decorator mit State arbeitet, sollten Tests besonders darauf achten, dass sich Aufrufe nicht unerwartet gegenseitig beeinflussen.

Wann Decorators schwer werden

Decorators werden schwieriger, wenn sie:

  • Argumente haben,
  • Methoden und Funktionen gleichzeitig unterstützen sollen,
  • asynchrone Funktionen unterstützen sollen,
  • Generatorfunktionen unterstützen sollen,
  • Descriptor-Verhalten verändern,
  • Signaturen sichtbar erhalten sollen,
  • komplexen State verwalten,
  • mit anderen Decorators kombiniert werden.

Ein einfacher Logging-Decorator ist schnell geschrieben.

Ein Decorator, der perfekt mit normalen Funktionen, Methoden, async def, Generatoren, Type Hints und Introspection harmoniert, ist deutlich anspruchsvoller.

Das ist normal.

Fange klein an und verwende für Standardfälle lieber bewährte Decorators aus der Standardbibliothek oder aus etablierten Frameworks.

Stolperfallen

  • @wraps vergessen: Ohne functools.wraps heißen dekorierte Funktionen oft nur noch wrapper, und Docstrings sowie andere Metadaten gehen verloren.

  • Rückgabewert verlieren: Ein Decorator sollte den Rückgabewert der ursprünglichen Funktion zurückgeben, wenn er ihn nicht bewusst verändern soll.

  • Exceptions verschlucken: Fehler im Decorator nicht still in None oder False verwandeln, wenn das nicht ausdrücklich gewollt ist.

  • *args und **kwargs vergessen: Dann funktioniert der Decorator nur für Funktionen mit genau der Signatur des Wrappers.

  • State im Decorator unterschätzen: Veränderlicher Closure-State bleibt über Aufrufe hinweg erhalten.

  • Reihenfolge mehrerer Decorators verwechseln: Decorators werden von unten nach oben angewendet.

  • Decorator mit Argumenten falsch verschachteln: @wiederhole(3) braucht eine Funktion, die einen Decorator zurückgibt.

  • Zu viel fachliche Logik verstecken: Decorators eignen sich gut für Querschnittsverhalten, aber schlecht für überraschende Geschäftslogik.

  • @property für teure Aktionen verwenden: Eine Property sollte sich wie ein Attribut anfühlen, nicht wie ein versteckter Datenbank- oder Dateizugriff.

  • @staticmethod verwenden, obwohl eine Modulfunktion klarer wäre: Nicht jede Hilfsfunktion muss in eine Klasse.

  • Caching ohne Grenzen einsetzen: @cache ist praktisch, kann in langlebigen Programmen aber wachsen, wenn ständig neue Argumente auftauchen.

  • Mutable oder unhashable Argumente cachen wollen: functools.cache und lru_cache brauchen hashbare Argumente.

  • Decorators als Magie behandeln: @decorator ist eine Funktions- oder Klassenumwandlung. Wenn es unklar wird, schreibe die Langform gedanklich aus.

Kompakte Übersicht

Ziel Werkzeug
Funktion einwickeln Decorator-Funktion
beliebige Argumente weiterreichen *args, **kwargs
Rückgabewert erhalten return funktion(...)
Metadaten erhalten @functools.wraps(funktion)
Decorator anwenden @decorator
Langform funktion = decorator(funktion)
Decorator konfigurieren Decorator Factory, z. B. @wiederhole(3)
Ergebnisse cachen @functools.cache, @functools.lru_cache
berechnetes Attribut @property
alternative Konstruktoren @classmethod
Hilfsfunktion im Klassennamensraum @staticmethod
Klassen automatisch ergänzen @dataclass
mehrere Decorators Anwendung von unten nach oben
Decorator typisieren ParamSpec, TypeVar, Callable

Übungen

1. Einen einfachen Decorator schreiben

Schreibe einen Decorator mit_log, der vor dem Funktionsaufruf den Namen der Funktion ausgibt.

Lösung
from functools import wraps


def mit_log(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        print(f"{funktion.__name__} wird aufgerufen")
        return funktion(*args, **kwargs)

    return wrapper


@mit_log
def begruesse(name):
    print(f"Hallo, {name}!")


begruesse("Karl")
Ausgabe:
begruesse wird aufgerufen
Hallo, Karl!

2. Rückgabewert erhalten

Schreibe einen Decorator mit_log, der den Rückgabewert nicht verliert. Teste ihn mit einer Funktion addiere(a, b).

Lösung
from functools import wraps


def mit_log(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        print(f"{funktion.__name__} startet")
        ergebnis = funktion(*args, **kwargs)
        print(f"{funktion.__name__} fertig")
        return ergebnis

    return wrapper


@mit_log
def addiere(a, b):
    return a + b


print(addiere(2, 3))
Ausgabe:
addiere startet
addiere fertig
5

3. Einen Decorator mit Argument schreiben

Schreibe @wiederhole(3), sodass eine Funktion dreimal ausgeführt wird.

Lösung
from functools import wraps


def wiederhole(mal):
    def decorator(funktion):
        @wraps(funktion)
        def wrapper(*args, **kwargs):
            ergebnis = None

            for _ in range(mal):
                ergebnis = funktion(*args, **kwargs)

            return ergebnis

        return wrapper

    return decorator


@wiederhole(3)
def klopf():
    print("klopf")


klopf()
Ausgabe:
klopf
klopf
klopf

4. Decorator-Reihenfolge beobachten

Erzeuge zwei Decorators a und b, die jeweils vor und nach dem Aufruf etwas ausgeben. Dekoriere eine Funktion mit beiden.

Lösung
from functools import wraps


def a(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        print("a davor")
        ergebnis = funktion(*args, **kwargs)
        print("a danach")
        return ergebnis

    return wrapper


def b(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        print("b davor")
        ergebnis = funktion(*args, **kwargs)
        print("b danach")
        return ergebnis

    return wrapper


@a
@b
def hallo():
    print("hallo")


hallo()
Ausgabe:
a davor
b davor
hallo
b danach
a danach

5. functools.cache verwenden

Schreibe eine rekursive Fibonacci-Funktion mit @cache.

Lösung
from functools import cache


@cache
def fibonacci(n):
    if n < 2:
        return n

    return fibonacci(n - 1) + fibonacci(n - 2)


print(fibonacci(40))

6. Eine Property bauen

Schreibe eine Klasse Spieler mit hp und einer Property ist_besiegt.

Lösung
class Spieler:
    def __init__(self, hp):
        self.hp = hp

    @property
    def ist_besiegt(self):
        return self.hp <= 0


spieler = Spieler(0)

print(spieler.ist_besiegt)
Ausgabe:
True

Vertiefung

Fazit

Decorators sind weniger geheimnisvoll, als sie aussehen.

Diese Schreibweise:

@decorator
def funktion():
    ...

bedeutet im Kern:

def funktion():
    ...


funktion = decorator(funktion)

Ein Decorator nimmt also eine Funktion entgegen und gibt eine neue oder veränderte Funktion zurück.

Mit *args, **kwargs und functools.wraps werden Decorators alltagstauglich. Mit einer zusätzlichen Ebene entstehen Decorators mit Argumenten wie @wiederhole(3). Und mit Standard-Decorators wie @property, @classmethod, @staticmethod, @dataclass, @cache und @lru_cache nutzt Du dieses Prinzip ständig, auch wenn Du keine eigenen Decorators schreibst.

Der wichtigste Gedanke:

Decorators sind gut für wiederverwendbares Verhalten rund um Funktionen oder Klassen. Sie sollten Code klarer machen, nicht verstecken.

Wenn Dir ein Decorator magisch vorkommt, schreibe ihn gedanklich in die Langform um. Dann bleibt meistens nur normale Python-Mechanik übrig.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!