Was ein Decorator wirklich mit Ihrer Funktion macht
Sie wollen jede Funktion zeitlich messen, die eine langsame externe API aufruft. Sie können time.perf_counter()-Aufrufe in jede einzelne einfügen, oder die Messung einmal schreiben und überall anwenden. Ein Decorator ist die zweite Option: eine Funktion, die eine andere Funktion nimmt, sie in zusätzliches Verhalten einwickelt und die eingewickelte Version unter dem ursprünglichen Namen zurückgibt.
| |
@timed über def fetch_price ist die Kurzform von fetch_price = timed(fetch_price). Python ruft timed einmal auf, direkt nachdem fetch_price definiert wurde, und bindet den Namen fetch_price neu an das, was timed zurückgibt. Das ist alles, was das @ tut.
Wie Decorators funktionieren: Funktionen als Objekte und Closures
In Python sind Funktionen Objekte, genau wie eine Liste oder eine Zahl. Sie können eine als Argument übergeben und eine andere aus einer Funktion zurückgeben. wrapper ist außerdem eine Closure: Sie erfasst func aus dem umgebenden Scope und nutzt sie bei jeder Ausführung weiter, lange nachdem timed bereits zurückgekehrt ist.
Das ist der gesamte Mechanismus. Decorators mit Argumenten, klassenbasierte Decorators und eingebaute wie @property sind nur Varianten davon.
Wie functools.wraps Namen und Docstring der Funktion erhält
Das timed-Beispiel hat einen Fehler, den Sie erst sehen, wenn Sie das Ergebnis genauer untersuchen:
| |
fetch_price hat seinen Namen und seinen Docstring verloren, weil wrapper zurückgegeben wird. Alles, was die Metadaten der Funktion ausliest, sieht stattdessen wrapper: help(), Debugger, Webframeworks, die Routen- oder Endpunktnamen aus der Funktion ableiten. Beheben Sie das mit functools.wraps:
| |
wraps kopiert __name__, __doc__, __module__ und __qualname__ (sowie __annotations__) vom Original auf den Wrapper und fügt ein __wrapped__-Attribut hinzu, das darauf zurückverweist. Das kostet eine Zeile. Setzen Sie @wraps(func) auf jeden Wrapper, den Sie schreiben.
Wie Sie einen Decorator mit Argumenten schreiben
Ein einfacher Decorator nimmt ein Argument: die Funktion. Sobald der Decorator eine eigene Konfiguration braucht, etwa wie oft wiederholt und wie lange gewartet wird, fügen Sie eine Ebene hinzu: eine Funktion, die die Konfiguration entgegennimmt und einen Decorator zurückgibt.
| |
Lesen Sie das von außen nach innen. retry(times=5, delay=2) läuft zuerst und gibt decorator zurück. decorator(fetch_price) läuft als Nächstes und gibt wrapper zurück. wrapper ist das, was bei jedem Aufruf von fetch_price(...) ausgeführt wird.
Drei Verschachtelungsebenen sind schwerer zu lesen als eine. Wenn times und delay in Ihrer Codebasis nie wechseln, setzen Sie sie fest und kehren Sie zur einfachen Form mit einem Argument zurück.
Wie Sie einen klassenbasierten Decorator schreiben
Jedes Objekt mit einer __call__-Methode kann als Decorator fungieren. Verwenden Sie eine Klasse, wenn der Decorator Zustand über mehrere Aufrufe hinweg halten muss, nicht nur Konfiguration:
| |
@CountCalls ersetzt process_order durch eine Instanz von CountCalls, die calls zwischen den Aufrufen hält. Eine Closure mit einem nonlocal-Zähler könnte dasselbe leisten, aber die Klasse macht den Zustand explizit und bietet gleich einen Platz für eine reset()-Methode.
Zwei Fallstricke. wraps greift hier nicht direkt: Rufen Sie functools.update_wrapper(self, func) in __init__ auf, wenn Sie die Metadaten wollen. Und diese Version scheitert bei Methoden: Die Instanz hat kein __get__, also wird das self der dekorierten Methode nie übergeben. Für Methoden ist ein funktionsbasierter Decorator die einfachere Wahl.
Eingebaute Decorators: @staticmethod, @property und @lru_cache
Die von Python mitgelieferten Decorators nutzen denselben Mechanismus, deshalb wirken sie nicht mehr undurchsichtig, sobald Sie selbst einen geschrieben haben:
@staticmethodund@classmethodändern, was eine Methode als erstes Argument erhält: nichts Zusätzliches, oder die Klasse statt der Instanz.@propertymacht aus einem Methodenaufruf einen Attributzugriff,obj.valuestattobj.value(), führt darunter aber weiterhin Code aus.@functools.lru_cache(maxsize=128)gibt ein zwischengespeichertes Ergebnis zurück, wenn die Funktion erneut mit denselben Argumenten aufgerufen wird.@functools.cache(Python 3.9+) macht dasselbe ohne Größenlimit. Nutzen Siecache, wenn die Menge möglicher Eingaben klein und begrenzt ist, sonstlru_cache.
| |
Ohne Cache berechnet fibonacci(35) dieselben Teilergebnisse Millionen Mal neu. Mit Cache wird jedes n nur einmal berechnet.
In welcher Reihenfolge laufen gestapelte Decorators?
Decorators werden von unten nach oben angewendet und von oben nach unten ausgeführt:
| |
timed umschließt fetch_price zuerst, und retry umschließt das Ergebnis. Beim Aufruf läuft der Wrapper von retry zuerst und ruft bei jedem Versuch die zeitgemessene Version auf. So gibt timed eine Zahl pro Versuch aus und zählt die Wartezeit zwischen den Versuchen nie mit. Tauschen Sie die beiden Zeilen, und timed liegt außerhalb von retry und meldet den gesamten Retry-Zyklus, Wartezeiten inklusive, als eine einzige Zahl.
Wann ist ein Decorator das falsche Werkzeug?
Decorators verstecken den Kontrollfluss. Eine Funktion, deren Rumpf nur eine Zeile umfasst, könnte eine Datenbanktransaktion öffnen und eine Berechtigung prüfen, bevor diese Zeile ausgeführt wird, ohne dass das an der Aufrufstelle zu erkennen wäre. Dieser Kompromiss lohnt sich für Belange, die viele Funktionen betreffen und überall identisch funktionieren sollen: Logging, Zeitmessung, Caching, Authentifizierung. Er wird zum Problem, wenn das Verhalten nur zu einer einzigen Funktion gehört. Schreiben Sie es dann inline, dort, wo es der nächste Leser sieht.
Auch Stacktraces werden schwerer lesbar. Drei gestapelte Decorators fügen drei zusätzliche Frames zwischen Aufrufer und Ihrem Code ein. Wenn ein Decorator Exceptions abfängt (wie retry mit ConnectionError) oder den Rückgabewert ändert, vermerken Sie das in der Docstring der dekorierten Funktion, die wraps sichtbar hält. Wer den nächsten Traceback liest, braucht diesen Hinweis.
Wann sollten Sie einen Decorator in eigenem Code extrahieren?
Das Signal ist Wiederholung: dieselben fünf Zeilen Setup oder Teardown am Anfang einer dritten Funktion. Extrahieren Sie sie in einen Decorator, bevor Sie die vierte Kopie schreiben. Wenn Sie dabei experimentieren, tun Sie das in einer isolierten Umgebung, wie in Virtuelle Python-Umgebungen: venv, pip und uv beschrieben. Testen Sie den Decorator zuerst gegen eine winzige Dummy-Funktion: Die Schichtung aus Unit-Tests vs Integrationstests vs End-to-End-Tests gilt auch hier, und ein Decorator lässt sich kaum besser auf Unit-Ebene testen.
Ein Fall, in dem Sie stoppen sollten: Wenn das, was Sie injizieren wollen, eine Abhängigkeit ist, etwa eine Datenbankverbindung oder ein konfigurierter HTTP-Client, ist das kein Querschnittsverhalten. Übergeben Sie sie über den Konstruktor, wie in Was ist Dependency Injection? (Mit Beispielen) gezeigt.