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.

Zum Beispiel:

@mit_protokoll
def speichern(name):
    ...

Oder:

@dataclass
class Gegenstand:
    ...

Oder bei Click:

@click.command()
def status():
    ...

Die Syntax wirkt zunächst nach einer besonderen Python-Magie. Dahinter steckt aber ein ziemlich normales Prinzip:

Ein Decorator bekommt ein Objekt und sein Rückgabewert tritt an dessen Stelle.

Bei Funktionen kannst Du Dir:

@decorator
def funktion():
    ...

zunächst ungefähr so vorstellen:

def funktion():
    ...


funktion = decorator(funktion)

Wenn dieser Zusammenhang sitzt, wird der Rest deutlich weniger mysteriös.

Funktionen sind Objekte

Eine Funktion ist in Python selbst ein Objekt.

def begruesse():
    return "Hallo"

Der Name:

begruesse

zeigt auf dieses Funktionsobjekt.

Die Klammern:

begruesse()

rufen es dagegen auf.

Das lässt sich direkt sehen:

print(begruesse)
print(begruesse())

Ausgabe ungefähr:

<function begruesse at 0x...>
Hallo

Weil Funktionen normale Objekte sind, kannst Du sie auch einer anderen Variablen zuweisen:

funktion = begruesse

print(
    funktion()
)

Ausgabe:

Hallo

funktion und begruesse zeigen hier auf dasselbe Funktionsobjekt.

Diese Eigenschaft ist die Grundlage von Decorators.

Funktionen weiterreichen

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, dass es speziell begruesse(...) bekommen hat. Es bekommt einfach ein aufrufbares Objekt und verwendet es später.

Das kennst Du längst aus der Standardbibliothek:

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

sortiert = sorted(
    namen,
    key=len,
)

print(sortiert)

Ausgabe:

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

Bei:

key=len

wird len nicht aufgerufen.

Wir übergeben die Funktion selbst. sorted(...) ruft sie später für die einzelnen Werte auf.

Funktionen können Funktionen zurückgeben

Auch die andere Richtung funktioniert:

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

    return begruesse

Aufruf:

funktion = baue_begruessung()

print(
    funktion("Karl")
)

Ausgabe:

Hallo, Karl!

baue_begruessung() erzeugt eine innere Funktion und gibt sie zurück.

Damit haben wir bereits fast alles, was für einen einfachen Decorator nötig ist:

Funktion hinein
    ↓
andere Funktion erzeugen
    ↓
Funktion zurückgeben

Eine Funktion einwickeln

Bauen wir einen ersten Decorator:

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

        ergebnis = funktion()

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

        return ergebnis

    return wrapper

Unsere eigentliche Funktion:

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

Nun 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 auf den wrapper.

Dieser ruft intern weiterhin die ursprüngliche Funktion auf.

Der Ablauf sieht also so aus:

speichern()
    ↓
wrapper()
    ↓
Text davor
    ↓
ursprüngliches speichern()
    ↓
Text danach

Das ist das klassische Wrapper-Muster hinter vielen Function Decorators.

@decorator ist die Kurzform

Statt:

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


speichern = mit_protokoll(
    speichern
)

schreiben wir:

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

Die Wirkung ist dieselbe.

Wichtig ist nur der Zeitpunkt: Der Decorator wird nicht erst ausgeführt, wenn Du später:

speichern()

aufrufst.

Die Decoration passiert bereits, wenn Python die Funktionsdefinition ausführt.

Bei Funktionen auf Modulebene ist das normalerweise während des Imports des Moduls.

Decorators laufen schon beim Definieren

Das lässt sich leicht beobachten:

def registriere(funktion):
    print(
        f"registriere {funktion.__name__}"
    )

    return funktion


@registriere
def speichern():
    print(
        "speichern läuft"
    )


print(
    "Modul ist fertig definiert"
)

Ausgabe:

registriere speichern
Modul ist fertig definiert

Wir haben:

speichern()

noch gar nicht aufgerufen.

Das:

registriere(speichern)

passiert bereits bei der Definition.

Erst ein späteres:

speichern()

führt den Body von speichern(...) aus.

Das ist bei Frameworks wichtig. Ein Decorator kann beispielsweise schon beim Import eine Route, einen CLI-Command oder einen Handler registrieren.

Deshalb sollte ein Decorator bei der Definition nicht unbedacht teure oder überraschende Arbeit ausführen.

Ein Decorator muss keine neue Funktion zurückgeben

Die vereinfachte Erklärung:

Ein Decorator nimmt eine Funktion und gibt eine neue Funktion zurück.

ist für Wrapper gut, aber technisch zu eng.

Ein Decorator muss zunächst aufrufbar sein. Sein Rückgabewert wird anschließend an den dekorierten Namen gebunden.

Er kann beispielsweise die ursprüngliche Funktion unverändert zurückgeben:

REGISTRY = {}


def registriere(funktion):
    REGISTRY[
        funktion.__name__
    ] = funktion

    return funktion

Dann:

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

Der Decorator hat hier hauptsächlich einen Seiteneffekt:

print(REGISTRY)

enthält anschließend einen Eintrag für speichern.

Andere Decorators geben wiederum ganz andere Objekte zurück.

@property erzeugt beispielsweise einen Descriptor. @click.command() macht aus einer Funktion ein Click-Command-Objekt.

Das erklärt auch, warum ein dekorierter Name sich manchmal anders verhält als die ursprüngliche Funktion.

Argumente mit *args und **kwargs weiterreichen

Unser erster Wrapper funktioniert nur für Funktionen ohne Parameter:

def wrapper():
    ...

Diese Funktion:

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

könnte er nicht korrekt aufrufen.

Ein allgemeiner Wrapper nimmt deshalb beliebige Argumente entgegen:

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

Nun:

@mit_protokoll
def speichern(
    name,
    datei="spielstand.json",
):
    print(
        f"{name} -> {datei}"
    )


speichern(
    "Karl",
    datei="save.json",
)

Der Wrapper sammelt:

positionale Argumente -> args
Keyword Arguments     -> kwargs

und reicht beide wieder an die ursprüngliche Funktion weiter.

Den Rückgabewert nicht verlieren

Ein häufiger Decorator-Bug sieht so aus:

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

        funktion(
            *args,
            **kwargs,
        )

        print("Fertig")

    return wrapper

Dekorieren wir damit:

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

dann:

ergebnis = addiere(
    2,
    3,
)

print(ergebnis)

Ausgabe:

Starte
Fertig
None

Der ursprüngliche Rückgabewert wurde zwar erzeugt, aber vom Wrapper verworfen.

Richtig:

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

        ergebnis = funktion(
            *args,
            **kwargs,
        )

        print("Fertig")

        return ergebnis

    return wrapper

Nun bleibt das Verhalten der ursprünglichen Funktion erhalten.

Als brauchbare Default-Regel:

Wenn Dein Decorator den Rückgabewert nicht absichtlich verändert, reiche ihn unverändert weiter.

Closures: wie der Wrapper die ursprüngliche Funktion kennt

Im Wrapper verwenden wir:

funktion

obwohl mit_protokoll(...) längst beendet ist:

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

    return wrapper

Das funktioniert wegen einer Closure.

Die innere Funktion behält Zugriff auf Namen aus ihrem umgebenden Scope.

Ein kleineres Beispiel:

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

    return rechne

Nun:

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

print(
    verdopple(10)
)

print(
    verdreifache(10)
)

Ausgabe:

20
30

Beide erzeugten Funktionen besitzen ihre eigene Closure.

Die eine erinnert sich an:

faktor == 2

die andere an:

faktor == 3

Beim Decorator merkt sich der Wrapper auf dieselbe Weise die ursprüngliche Funktion.

functools.wraps gehört in Wrapper-Decorators

Unser Decorator hat noch ein Problem:

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

    return wrapper

Dekorieren wir:

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

und prüfen:

print(
    speichern.__name__
)

print(
    speichern.__doc__
)

bekommen wir:

wrapper
None

Von außen sieht die dekorierte Funktion nun wie der Wrapper aus.

Das ist schlecht für:

Dokumentation
Debugging
Introspection
Frameworks
Editor-Unterstützung

Dafür gibt es:

functools.wraps
from functools import wraps


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

    return wrapper

Nun:

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

ergibt:

print(
    speichern.__name__
)

print(
    speichern.__doc__
)
speichern
Speichert den Spielstand.

Was wraps tatsächlich erhält

@wraps(funktion) kopiert beziehungsweise aktualisiert mehrere Metadaten des Wrappers.

Dazu gehören unter aktuellen Python-Versionen (ab 3.14 __annotate__ statt __annotations__) unter anderem:

__module__
__name__
__qualname__
__annotations__
__type_params__
__doc__

Außerdem bekommt der Wrapper:

__wrapped__

mit einem Verweis auf die eingewickelte Funktion.

Das ist für Introspection nützlich:

print(
    speichern.__wrapped__
)

Werkzeuge wie inspect.signature(...) folgen diesem __wrapped__-Verweis standardmäßig ebenfalls.

wraps macht aus:

def wrapper(*args, **kwargs):
    ...

aber nicht magisch dieselbe Python-Funktion wie das Original. Der Wrapper bleibt eine eigene Funktion mit eigenem Code.

Auch die statische Typisierung musst Du separat korrekt beschreiben.

Der übliche Function Decorator

Damit ergibt sich für einen einfachen Wrapper dieses Grundmuster:

from functools import wraps


def decorator(funktion):
    @wraps(funktion)
    def wrapper(
        *args,
        **kwargs,
    ):
        # Verhalten davor

        ergebnis = funktion(
            *args,
            **kwargs,
        )

        # Verhalten danach

        return ergebnis

    return wrapper

Nicht jeder Decorator braucht exakt diese Form.

Wenn Du aber eine normale synchrone Funktion lediglich einwickeln willst, ist sie ein guter Ausgangspunkt.

Ein Logging-Decorator

Mit echtem Logging:

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__,
        )

        try:
            return funktion(
                *args,
                **kwargs,
            )
        finally:
            logger.info(
                "%s fertig",
                funktion.__name__,
            )

    return wrapper

Verwendung:

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

Durch finally wird die Abschlussmeldung auch dann geschrieben, wenn die Funktion mit einer Exception endet.

Ob Du das möchtest, hängt natürlich von der Bedeutung der Meldung ab. Ein Text wie:

speichern erfolgreich

wäre in finally falsch.

speichern beendet

passt dagegen.

Exceptions protokollieren und weiterwerfen

Ein Decorator kann Fehler zentral 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

Entscheidend ist:

raise

Die ursprüngliche Exception wird nach dem Logging weitergegeben.

Ein allgemeiner Decorator wie:

except Exception:
    logger.exception(
        "Fehler"
    )

    return None

würde dagegen die Semantik jeder dekorierten Funktion verändern.

Das kann absichtlich sinnvoll sein, sollte aber niemals aus Versehen passieren.

Laufzeit messen

Ein weiterer typischer Wrapper:

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:
            dauer = (
                perf_counter()
                - start
            )

            print(
                f"{funktion.__name__} "
                f"dauerte {dauer:.3f}s"
            )

    return wrapper

Verwendung:

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

perf_counter() ist für das Messen einer verstrichenen Dauer gedacht.

Für ernsthafte Performance-Analysen ersetzt so ein Decorator natürlich keinen Profiler. Für gezielte Messungen kann er trotzdem nützlich sein.

Decorators mit eigenen Argumenten

Bisher sah unser Decorator so aus:

@mit_protokoll
def speichern():
    ...

Manchmal soll er selbst konfiguriert werden:

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

Nun passiert bei der Definition zuerst:

wiederhole(3)

Das Ergebnis davon muss der eigentliche Decorator sein.

Wir brauchen deshalb eine weitere Ebene:

from functools import wraps


def wiederhole(mal):
    if mal < 1:
        raise ValueError(
            "mal muss mindestens 1 sein"
        )

    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

Nun:

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


klopf()

Ausgabe:

klopf
klopf
klopf

Die Langform einer Decorator Factory

Diese Syntax:

@wiederhole(3)
def klopf():
    ...

kannst Du gedanklich zerlegen:

def klopf():
    ...

Dann:

decorator = wiederhole(3)

und schließlich:

klopf = decorator(
    klopf
)

Zusammen:

klopf = wiederhole(3)(
    klopf
)

Damit sind die drei Funktionsebenen leichter auseinanderzuhalten:

wiederhole(3)
    ↓
decorator(funktion)
    ↓
wrapper(*args, **kwargs)

Der Begriff Decorator Factory ist dafür hilfreich:

Eine Decorator Factory ist eine Funktion, die einen Decorator erzeugt.

Retry als Decorator

Ein etwas realistischeres Beispiel:

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

            assert (
                letzter_fehler
                is not None
            )

            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 wiederholt bewusst nur bei:

OSError

und nicht pauschal bei jedem Exception.

Eine TypeError wegen eines Programmierfehlers dreimal hintereinander auszuführen würde das Problem schließlich nicht lösen.

In echten Netzwerk- oder API-Clients gehören zu Retry-Logik oft noch Backoff, Jitter und eine klare Entscheidung darüber, welche Fehler überhaupt wiederholbar sind. Das Grundmuster bleibt aber dasselbe.

Mehrere Decorators

Python erlaubt mehrere Decorators:

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

Die Anwendung ist verschachtelt.

Gedanklich:

funktion = decorator_a(
    decorator_b(
        funktion
    )
)

Der Decorator direkt über der Funktion bekommt sie also zuerst.

Ein sichtbares 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 wickelt zuerst hallo ein.

Danach wickelt a das Ergebnis von b ein.

Das ist relevant bei Decorators für:

Authentifizierung
Caching
Transaktionen
Retries
Logging
Framework-Registrierung

Die Reihenfolge kann das Verhalten verändern.

Decorator-Ausdrücke werden bei der Definition ausgewertet

Auch bei:

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

passiert die Vorbereitung der Decorators während der Funktionsdefinition.

Gerade Decorator Factories sollten deshalb keine überraschenden Laufzeitoperationen durchführen, die eigentlich erst beim späteren Funktionsaufruf erwartet werden.

Ein:

@lade_konfiguration_aus_dem_netz()
def funktion():
    ...

könnte beispielsweise schon beim Import einen Netzwerkzugriff auslösen.

Technisch ist das möglich. Als API-Design ist es meistens keine gute Überraschung.

State in einer Closure

Closures können veränderlichen State behalten:

from functools import wraps


def zaehle_aufrufe(funktion):
    anzahl = 0

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

        anzahl += 1

        print(
            f"{funktion.__name__} "
            f"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 zum erzeugten Wrapper.

Dekorierst Du eine zweite Funktion:

@zaehle_aufrufe
def speichern():
    ...

bekommt sie ihren eigenen Zähler.

Closure-State ist nicht automatisch threadsicher

Sobald mehrere Threads denselben dekorierten Callable gleichzeitig verwenden, wird mutable State interessanter.

Dieser Code:

anzahl += 1

sollte nicht einfach deshalb als sicher betrachtet werden, weil er innerhalb einer Closure steckt.

Wenn korrekte Synchronisation wichtig ist, brauchst Du dieselben Überlegungen wie bei anderem gemeinsam verändertem State.

Für Logging-Decorators ohne eigenen veränderlichen State spielt das meistens keine Rolle.

Für Counter, Rate Limiter oder eigene Caches dagegen schon.

Ein Wrapper passt nicht automatisch zu async def

Ein Decorator für normale Funktionen:

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

        try:
            return funktion(
                *args,
                **kwargs,
            )
        finally:
            ...

funktioniert syntaktisch auch auf:

async def lade_daten():
    ...

misst dann aber nicht das, was Du vermutlich erwartest.

Der Aufruf einer Coroutine Function führt ihren eigentlichen Body noch nicht aus. Er erzeugt zunächst ein Coroutine-Objekt.

Der synchrone Wrapper wäre deshalb schon „fertig“, bevor das await der eigentlichen Operation stattgefunden hat.

Für Async-Code braucht der Wrapper selbst async def:

from functools import wraps
from time import perf_counter


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

        try:
            return await funktion(
                *args,
                **kwargs,
            )
        finally:
            dauer = (
                perf_counter()
                - start
            )

            print(
                f"{funktion.__name__} "
                f"dauerte {dauer:.3f}s"
            )

    return wrapper

Nun umfasst die Messung tatsächlich die asynchrone Ausführung.

Dasselbe Problem gibt es bei Generatorfunktionen

Eine Generatorfunktion:

def zahlen():
    yield 1
    yield 2

führt ihren Body beim Aufruf ebenfalls noch nicht aus.

Sie erzeugt zunächst einen Generator.

Ein normaler Logging-Wrapper könnte deshalb:

startet
fertig

loggen, obwohl noch kein einziger Wert des Generators verarbeitet wurde.

Soll der Decorator die tatsächliche Iteration umschließen, muss auch der Wrapper ein Generator sein:

from functools import wraps


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

        try:
            return (
                yield from funktion(
                    *args,
                    **kwargs,
                )
            )
        finally:
            print(
                f"{funktion.__name__} fertig"
            )

    return wrapper

Mehr zu diesem Verhalten findest Du in Generatoren und yield wirklich verstehen.

Das zeigt eine wichtige Grenze der simplen Formel:

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

Sie ist universell aufrufbar, erhält aber nicht automatisch die Semantik jeder Art von Callable.

Decorators für Methoden

Ein normaler Function Decorator mit einem Function Wrapper funktioniert auch für Methoden:

from functools import wraps


def mit_log(funktion):
    @wraps(funktion)
    def wrapper(
        *args,
        **kwargs,
    ):
        print(
            funktion.__name__
        )

        return funktion(
            *args,
            **kwargs,
        )

    return wrapper

Dann:

class Spieler:
    @mit_log
    def heile(
        self,
        menge,
    ):
        self.hp += menge

self steckt einfach im ersten positional Argument des Wrappers.

Interessanter wird es, wenn ein Decorator statt einer normalen Funktion ein eigenes Callable-Objekt zurückgibt. Dann spielt Pythons Descriptor-Protokoll eine Rolle, damit Methoden weiterhin korrekt an Instanzen gebunden werden.

Für normale Wrapper-Funktionen musst Du Dich damit zunächst nicht beschäftigen.

@property: ein Decorator, der keine Funktion zurückgibt

Ein bekanntes Beispiel aus Python selbst:

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

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

Verwendung:

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

print(
    spieler.ist_besiegt
)

Ausgabe:

True

Ohne @property wäre:

spieler.ist_besiegt()

nötig.

property(...) nimmt die Funktion entgegen und erzeugt ein Property-Objekt. Dieses Objekt implementiert das Descriptor-Protokoll und steuert den späteren Attributzugriff.

Das ist ein gutes Beispiel dafür, warum ein Decorator nicht zwangsläufig eine Wrapper-Funktion zurückgibt.

Properties sollten wie Attribute wirken

Eine Property passt gut für eine berechnete Eigenschaft:

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

Weniger passend:

@property
def spielstand(self):
    return lade_grosse_datei_aus_dem_netz()

Ein Zugriff wie:

spieler.spielstand

sieht billig und nebenwirkungsarm aus.

Wenn dahinter plötzlich ein langsamer Netzwerkrequest steckt, überrascht das den Leser.

Eine Property sollte sich nach Möglichkeit wie ein normales Attribut anfühlen.

Property Setter

Properties können einen Setter besitzen:

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

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

    @hp.setter
    def hp(
        self,
        wert,
    ):
        self._hp = max(
            wert,
            0,
        )

Nun:

spieler = Spieler()

spieler.hp = -50

print(
    spieler.hp
)

Ausgabe:

0

Für reine Invarianten kann das sinnvoll sein.

Fachliche Aktionen sind als Methoden oft klarer:

spieler.erleide_schaden(
    50
)

transportiert mehr Bedeutung als eine überraschend komplexe Zuweisung an spieler.hp.

cached_property

Für teuer berechnete Eigenschaften gibt es außerdem:

functools.cached_property
from functools import cached_property


class Raum:
    def __init__(
        self,
        daten,
    ):
        self.daten = daten

    @cached_property
    def statistik(self):
        print(
            "berechne Statistik"
        )

        return berechne_statistik(
            self.daten
        )

Beim ersten:

raum.statistik

wird der Wert berechnet und anschließend im Objekt gespeichert.

Weitere Zugriffe verwenden den gespeicherten Wert.

Löschen kannst Du ihn mit:

del raum.statistik

Dann wird er beim nächsten Zugriff neu berechnet.

cached_property benötigt normalerweise ein beschreibbares __dict__ der Instanz. Eine reine __slots__-Klasse ohne __dict__ kann es deshalb nicht einfach verwenden.

Außerdem garantiert cached_property nicht, dass der Getter bei parallelem Zugriff mehrerer Threads exakt einmal ausgeführt wird.

Wenn genau das notwendig ist, brauchst Du zusätzliche Synchronisation.

@staticmethod

Auch staticmethod ist ein Decorator:

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

Aufruf:

print(
    Wuerfel.ist_gueltige_seitenzahl(
        6
    )
)

Ausgabe:

True

Eine Static Method bekommt beim Zugriff weder:

self

noch:

cls

automatisch.

Sie liegt lediglich logisch im Namensraum der Klasse.

Das ist manchmal passend. Wenn die Funktion mit der Klasse kaum etwas zu tun hat, kann eine normale Modulfunktion trotzdem klarer sein.

@classmethod

Eine Class Method bekommt die konkrete Klasse als ersten Parameter:

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
)

@classmethod ist besonders praktisch für alternative Konstruktoren.

Die Verwendung von:

cls(...)

statt:

Spieler(...)

hat außerdem den Vorteil, dass Unterklassen grundsätzlich die passende Klasse weiterreichen können.

@dataclass: Decorators funktionieren auch auf Klassen

Nicht nur Funktionen lassen sich dekorieren.

from dataclasses import dataclass


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

Gedanklich entspricht die Syntax wieder ungefähr:

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


Gegenstand = dataclass(
    Gegenstand
)

dataclass(...) untersucht die Klasse und erzeugt abhängig von der Konfiguration unter anderem Methoden wie:

__init__
__repr__
__eq__

Normalerweise arbeitet dataclass mit der übergebenen Klasse.

Eine interessante Ausnahme ist:

@dataclass(
    slots=True,
)

Dabei wird eine neue Klasse zurückgegeben, weil die Slots nicht einfach nachträglich in die bereits erzeugte Klasse eingebaut werden können.

Mehr dazu findest Du in Dataclasses: Klassen ohne Boilerplate.

Framework-Decorators tun oft mehr als Wrapping

Bei einem Webframework kann ein Decorator so aussehen:

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

Bei Click:

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

Bei pytest:

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

Hier sollte man nicht automatisch annehmen:

Decorator = vor Funktion etwas tun, danach Funktion aufrufen.

Frameworks können Decorators verwenden, um:

Funktionen zu registrieren
Metadaten zu speichern
andere Objekte zu erzeugen
Descriptor-Verhalten einzubauen
Callbacks in einen Command- oder Route-Baum einzuhängen

Das gemeinsame Sprachmerkmal bleibt:

Der Decorator erhält das gerade definierte Objekt und sein Ergebnis wird an dessen Namen gebunden.

Was der Decorator intern daraus macht, entscheidet seine Implementierung.

Caching mit functools.cache

Ein sehr praktischer Standard-Decorator 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 die naive rekursive Variante dieselben Teilprobleme immer wieder.

Mit:

print(
    fibonacci(40)
)

werden bereits berechnete Ergebnisse wiederverwendet.

@cache ist dabei die unbeschränkte Variante eines Memoization Cache.

Cache-Argumente müssen hashbar sein

Die Argumente bilden die Cache Keys.

Darum funktioniert:

fibonacci(40)

problemlos.

Eine Liste als Argument dagegen nicht:

from functools import cache


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


summe(
    [
        1,
        2,
        3,
    ]
)

Listen sind nicht hashbar.

Ein Tuple wäre geeignet:

summe(
    (
        1,
        2,
        3,
    )
)

Das gilt auch für lru_cache.

lru_cache begrenzt den Cache

Mit:

from functools import lru_cache


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

werden höchstens die 128 zuletzt verwendeten Cache-Einträge gehalten.

Mit:

maxsize=None

ist der Cache unbeschränkt.

@cache ist die einfache optimierte Form dieses unbeschränkten Falls.

Für langlebige Programme ist das ein wichtiger Unterschied.

Ein Cache, dessen mögliche Keys ständig wachsen:

Benutzer-ID
Request-ID
Timestamp
zufälliger Token

kann mit @cache immer mehr Speicher halten.

Caches halten Objekte fest

Ein Cache speichert nicht nur das Ergebnis abstrakt irgendwo.

Er hält Referenzen auf die verwendeten Argumente und Rückgabewerte, solange der Eintrag im Cache bleibt.

Auch deshalb sollte ein unbeschränkter Cache bewusst eingesetzt werden.

Bei Bedarf:

funktion.cache_clear()

löscht die gespeicherten Ergebnisse.

Statistiken bekommst Du mit:

funktion.cache_info()

zum Beispiel über Hits und Misses.

Threadsafe heißt nicht „wird garantiert einmal berechnet“

cache und lru_cache schützen ihre internen Datenstrukturen auch bei parallelem Zugriff mehrerer Threads.

Trotzdem kann die eingewickelte Funktion für denselben Key mehr als einmal gleichzeitig ausgeführt werden, wenn mehrere Threads den noch nicht vorhandenen Wert nahezu gleichzeitig anfordern.

Der Cache bleibt dabei konsistent.

Die Funktion selbst bekommt aber keine Exactly-once-Garantie.

Das ist relevant, wenn die gecachte Funktion Nebenwirkungen hat.

Ohnehin sind Funktionen mit Nebenwirkungen meist schlechte Kandidaten für Memoization.

Decorator-Reihenfolge bei Standard-Decorators

Auch Standard-Decorators folgen der normalen Verschachtelung.

Bei Kombinationen kann die Reihenfolge deshalb Teil des API sein.

Ein Beispiel aus der Standardbibliothek ist singledispatchmethod, das bei Kombinationen mit Decorators wie @classmethod außen liegen muss, damit sein:

.register

weiter verfügbar bleibt.

Du musst solche Sonderregeln nicht auswendig lernen.

Wenn ein Framework oder Standard-Decorator mit einem anderen kombiniert wird, lohnt sich aber immer ein Blick in dessen Dokumentation.

singledispatch: Dispatch per Typ

Ein weiterer interessanter Standard-Decorator ist:

functools.singledispatch

Er erlaubt unterschiedliche Implementierungen abhängig vom Typ des ersten Arguments:

from functools import singledispatch


@singledispatch
def beschreibe(wert):
    return repr(wert)


@beschreibe.register
def _(wert: int):
    return (
        f"Zahl: {wert}"
    )


@beschreibe.register
def _(wert: str):
    return (
        f"Text: {wert}"
    )

Nun:

print(
    beschreibe(42)
)

print(
    beschreibe("Fackel")
)

Ausgabe:

Zahl: 42
Text: Fackel

Hier dient der Decorator nicht primär dazu, Code vor und nach einer Funktion auszuführen.

Er baut eine Generic Function mit einem Registry-basierten Dispatch.

Auch das gehört zum Decorator-Prinzip.

Decorators sauber typisieren

Bei einem untypisierten Wrapper:

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

    return wrapper

weiß ein statischer Type Checker nur wenig über den Zusammenhang zwischen ursprünglicher und zurückgegebener Funktion.

Für einen Decorator, der die Signatur erhält, sind ParamSpec und ein Type Parameter für den Rückgabewert gedacht.

Mit der modernen Syntax ab Python 3.12:

from collections.abc import Callable
from functools import wraps


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

        return funktion(
            *args,
            **kwargs,
        )

    return wrapper

P steht für die komplette Parameterliste.

T steht für den Rückgabewert.

Damit kann der Checker verstehen:

Decorator bekommt Callable mit Signatur P -> T
Decorator liefert wieder Callable P -> T

Ältere Syntax mit ParamSpec und TypeVar

Wenn Dein Projekt Python 3.11 oder älter unterstützt:

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


P = ParamSpec("P")
T = TypeVar("T")


def mit_protokoll(
    funktion: Callable[P, T],
) -> Callable[P, T]:
    @wraps(funktion)
    def wrapper(
        *args: P.args,
        **kwargs: P.kwargs,
    ) -> T:
        return funktion(
            *args,
            **kwargs,
        )

    return wrapper

Das beschreibt denselben Zusammenhang.

Mehr zu Callable und Type Hints allgemein findest Du in Type Hints in der Praxis; ParamSpec selbst beschreibt PEP 612.

wraps und Type Hints lösen unterschiedliche Probleme

Die beiden Dinge werden gerne vermischt.

@wraps(funktion)

hilft zur Laufzeit bei Metadaten und Introspection.

Callable[P, T]

hilft dem statischen Type Checker.

Ein Decorator kann also @wraps korrekt verwenden und trotzdem schlecht typisiert sein.

Oder statisch korrekt typisiert sein, während __name__ und __doc__ versehentlich auf den Wrapper zeigen.

Für einen guten allgemeinen Wrapper sind oft beide Ebenen sinnvoll.

Wenn ein Decorator die Signatur absichtlich verändert

Nicht jeder Decorator erhält dieselben Parameter.

Angenommen, ein Decorator fügt vor der ursprünglichen Signatur einen Context hinzu oder entfernt für den äußeren Aufrufer einen Parameter.

Dann reicht:

Callable[P, T]
    ->
Callable[P, T]

nicht mehr.

Für solche Fälle gibt es unter anderem:

typing.Concatenate

Das ist fortgeschritteneres Typing.

Wichtig für den Einstieg ist nur:

ParamSpec ist nicht bloß dazu da, *args und **kwargs hübscher hinzuschreiben. Es beschreibt die Beziehung zwischen zwei Callable-Signaturen.

Decorators testen

Ein Decorator lässt sich mit kleinen Funktionen gut isoliert testen.

Für unseren print-basierten Wrapper mit_protokoll aus dem Abschnitt „Argumente mit *args und **kwargs weiterreichen“:

def test_mit_protokoll(
    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
    )

Falls der Decorator auch eine Abschlussmeldung erzeugt:

assert (
    "addiere fertig"
    in ausgabe.out
)

Metadaten testen

Wenn wraps wichtig für das API ist:

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

    assert (
        addiere.__name__
        == "addiere"
    )

    assert (
        addiere.__doc__
        == "Addiert zwei Zahlen."
    )

Auch:

addiere.__wrapped__

kann interessant sein, wenn andere Tools auf die Originalfunktion zugreifen müssen.

Ausnahmen ebenfalls testen

Ein Decorator, der Exceptions nur protokollieren soll, darf sie nicht verschlucken:

import pytest


def test_fehler_wird_weitergegeben():
    @logge_fehler
    def kaputt():
        raise ValueError(
            "kaputt"
        )

    with pytest.raises(
        ValueError,
        match="kaputt",
    ):
        kaputt()

Gerade bei Decorators lohnt sich dieser Test, weil Wrapper unbeabsichtigt leicht Rückgabewerte oder Exceptions verändern.

Decorator oder normale Funktion?

Decorators sind angenehm für Querschnittsverhalten.

Zum Beispiel:

Logging
Timing
Caching
Retry
Berechtigungen
Transaktionen
Registrierung
Tracing

Das Verhalten betrifft viele Funktionen auf dieselbe Art.

Dann ist:

@misst_laufzeit
def lade_spielstand():
    ...

oft klarer als dieselbe Messlogik in jede Funktion zu kopieren.

Weniger passend ist ein Decorator, wenn er wichtige fachliche Schritte versteckt.

Statt:

@bezahle_gold
def kaufe_schwert():
    ...

kann:

def kaufe_schwert(
    spieler,
):
    spieler.bezahle(
        50
    )

    ...

für einen Leser wesentlich klarer sein.

Decorators machen einen Teil des Kontrollflusses bewusst unsichtbar.

Das ist ihr Vorteil und gleichzeitig ihre Gefahr.

Wann ein eigener Decorator zu kompliziert wird

Ein einfacher Wrapper ist schnell geschrieben.

Schwieriger wird es, wenn derselbe Decorator gleichzeitig perfekt unterstützen soll:

normale Funktionen
Methoden
async def
Generatorfunktionen
eigene Descriptoren
Signaturveränderungen
statische Typisierung
mehrere verschachtelte Decorators
mutablem State
Threading

Dann baust Du schnell mehr Infrastruktur als eigentliche Anwendung.

Für Standardfälle ist deshalb ein vorhandener Decorator aus der Standardbibliothek oder einem etablierten Framework häufig die bessere Wahl.

Eigene Decorators sollten einen klaren, kleinen Vertrag haben.

Häufige Stolperfallen

  • @decorator für Aufrufsyntax halten: Der Decorator wird bei der Definition angewendet, nicht erst beim späteren Funktionsaufruf.

  • Decorator und Wrapper verwechseln: Der Decorator bekommt die Funktion. Der Wrapper ist häufig das Objekt, das später stattdessen aufgerufen wird.

  • Glauben, ein Decorator müsse eine Funktion zurückgeben: Der Rückgabewert wird an den dekorierten Namen gebunden und kann auch ein anderes Objekt sein.

  • @wraps vergessen: Dann zeigen Namen, Docstrings, Annotationen und Introspection schnell auf den Wrapper statt auf das ursprüngliche API.

  • @wraps mit perfekter Typisierung verwechseln: Runtime-Metadaten und statische Callable-Typen sind zwei verschiedene Ebenen.

  • Den Rückgabewert verlieren: Wenn der Decorator ihn nicht absichtlich verändert, sollte der Wrapper ihn weiterreichen.

  • Exceptions verschlucken: Ein Logging-Decorator sollte Fehler nicht unbemerkt in None oder False verwandeln.

  • *args und **kwargs vergessen: Ein allgemeiner Wrapper muss die Parameter der ursprünglichen Funktion weiterreichen können.

  • Decorator Factory und Decorator vermischen: Bei @wiederhole(3) erzeugt wiederhole(3) erst den eigentlichen Decorator.

  • Die Reihenfolge mehrerer Decorators verwechseln: Bei @a @b entsteht gedanklich a(b(funktion)).

  • Teure Arbeit während der Decoration ausführen: Decorators auf Modulebene laufen normalerweise schon beim Import.

  • Mutable Closure-State unterschätzen: Er bleibt zwischen Aufrufen erhalten und ist nicht automatisch threadsicher.

  • Einen synchronen Wrapper auf async def legen und echte Laufzeitmessung erwarten: Ohne await misst Du im Wesentlichen nur die Erzeugung des Coroutine-Objekts.

  • Generatorfunktionen wie normale Funktionen behandeln: Der eigentliche Generator-Body läuft erst beim Iterieren.

  • Properties für überraschend teure oder verändernde Arbeit verwenden: Ein Property-Zugriff sollte sich möglichst wie ein Attribut verhalten.

  • cached_property mit einem normalen property gleichsetzen: Der Wert wird nach dem ersten Zugriff in der Instanz gespeichert und benötigt normalerweise ein __dict__.

  • @staticmethod nur verwenden, weil eine Funktion irgendwie zur Datei mit der Klasse gehört: Eine normale Modulfunktion kann klarer sein.

  • @classmethod mit einer normalen Factory außerhalb der Klasse verwechseln: Für alternative Konstruktoren ist cls(...) gerade wegen Vererbung praktisch.

  • Unbeschränkte Caches sorglos einsetzen: @cache kann in langlebigen Prozessen wachsen und hält Referenzen auf Argumente und Ergebnisse.

  • Unhashable Argumente cachen wollen: cache und lru_cache benötigen hashbare Function Arguments.

  • Threadsafe Cache mit Exactly-once-Ausführung verwechseln: Zwei Threads können denselben noch nicht gecachten Aufruf gleichzeitig berechnen.

  • Framework-Decorators auf Wrapper reduzieren: Viele registrieren Funktionen oder ersetzen sie durch Framework-Objekte.

  • Zu viel Fachlogik hinter Decorators verstecken: Querschnittsverhalten passt gut; zentrale Business-Logik wird dadurch oft schwerer nachvollziehbar.

Kompakte Übersicht

Ziel Werkzeug
Funktion als Objekt weiterreichen Funktionsname ohne ()
einfachen Decorator anwenden @decorator
gedankliche Langform funktion = decorator(funktion)
allgemeinen Wrapper bauen *args, **kwargs
Rückgabewert erhalten return funktion(...)
Metadaten erhalten @functools.wraps(funktion)
Originalfunktion erreichen funktion.__wrapped__
konfigurierbaren Decorator bauen Decorator Factory
mehrere Decorators verstehen a(b(funktion))
State im Wrapper halten Closure + nonlocal
Laufzeit messen time.perf_counter()
Ergebnisse cachen @cache, @lru_cache(...)
berechnetes Attribut @property
einmal berechnetes Attribut @cached_property
alternativer Konstruktor @classmethod
Funktion im Klassennamensraum @staticmethod
Klasse automatisch ergänzen @dataclass
nach Typ dispatchen @singledispatch
Decorator typisieren ParamSpec + Return-Type-Parameter
Async Function einwickeln async def wrapper(...)
Generator einwickeln Generator-Wrapper + yield from

Übungen

1. Einen einfachen Decorator schreiben

Schreibe einen Decorator mit_log, der vor jedem Aufruf den Funktionsnamen 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
Verwendung:
@mit_log
def begruesse(name):
    print(
        f"Hallo, {name}!"
    )


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

2. Die Langform ausschreiben

Was ist die Langform dieses Codes?

@mit_log
def speichern():
    print("gespeichert")
Lösung
def speichern():
    print(
        "gespeichert"
    )


speichern = mit_log(
    speichern
)
Die Decorator-Syntax nimmt Dir genau diese Neuzuweisung ab.

3. Rückgabewerte erhalten

Schreibe einen Decorator, der vor und nach einem Aufruf etwas ausgibt, ohne den Rückgabewert zu verlieren.

Teste ihn mit:

addiere(2, 3)
Lösung
from functools import wraps


def mit_log(funktion):
    @wraps(funktion)
    def wrapper(
        *args,
        **kwargs,
    ):
        print("startet")

        ergebnis = funktion(
            *args,
            **kwargs,
        )

        print("fertig")

        return ergebnis

    return wrapper


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


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

4. Eine Decorator Factory bauen

Schreibe:

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

so, dass klopf dreimal ausgegeben 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
Verwendung:
@wiederhole(3)
def klopf():
    print("klopf")


klopf()
Ausgabe:
klopf
klopf
klopf

5. Die Reihenfolge vorhersagen

Was gibt dieser Code aus?

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

wenn a und b jeweils vor und nach ihrem inneren Aufruf ihren Buchstaben ausgeben?

Lösung Die Decoration entspricht:
hallo = a(
    b(
        hallo
    )
)
Beim Aufruf entsteht deshalb:
a davor
b davor
hallo
b danach
a danach

6. Einen Aufrufzähler bauen

Schreibe einen Decorator, der mitzählt, wie häufig eine Funktion aufgerufen wurde.

Lösung
from functools import wraps


def zaehle_aufrufe(funktion):
    anzahl = 0

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

        anzahl += 1

        print(
            f"Aufruf {anzahl}"
        )

        return funktion(
            *args,
            **kwargs,
        )

    return wrapper
Jede dekorierte Funktion erhält ihren eigenen Closure-State.

7. Einen 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)
)

8. Eine Property bauen

Schreibe eine Klasse Spieler mit hp und der berechneten Property ist_besiegt.

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

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

print(
    spieler.ist_besiegt
)
Ausgabe:
True

9. Einen typisierten Decorator schreiben

Typisiere unter Python 3.12 oder neuer einen Decorator, der Parameter und Rückgabewert der dekorierten Funktion erhält.

Lösung
from collections.abc import Callable
from functools import wraps


def mit_log[
    T,
    **P,
](
    funktion: Callable[P, T],
) -> Callable[P, T]:
    @wraps(funktion)
    def wrapper(
        *args: P.args,
        **kwargs: P.kwargs,
    ) -> T:
        print(
            funktion.__name__
        )

        return funktion(
            *args,
            **kwargs,
        )

    return wrapper
`P` beschreibt die Parameterliste, `T` den Rückgabetyp.

10. Einen Async-Decorator korrigieren

Warum ist dieser Wrapper für eine Coroutine Function ungeeignet?

def mit_log(funktion):
    def wrapper(
        *args,
        **kwargs,
    ):
        print("start")
        ergebnis = funktion(
            *args,
            **kwargs,
        )
        print("fertig")
        return ergebnis

    return wrapper
Lösung Der Aufruf einer Coroutine Function erzeugt zunächst nur ein Coroutine-Objekt. Der eigentliche Body läuft erst beim `await`. Der Wrapper muss deshalb selbst asynchron sein:
from functools import wraps


def mit_log(funktion):
    @wraps(funktion)
    async def wrapper(
        *args,
        **kwargs,
    ):
        print("start")

        try:
            return await funktion(
                *args,
                **kwargs,
            )
        finally:
            print("fertig")

    return wrapper

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!