Co dekorator naprawdę robi z twoją funkcją
Chcesz zmierzyć czas każdej funkcji, która wywołuje wolne zewnętrzne API. Możesz wkleić wywołania time.perf_counter() do każdej z nich albo napisać pomiar raz i stosować go wszędzie. Dekorator to druga opcja: to funkcja, która bierze inną funkcję, owija ją dodatkowym zachowaniem i zwraca owiniętą wersję pod oryginalną nazwą.
| |
@timed nad def fetch_price to skrót od fetch_price = timed(fetch_price). Python wywołuje timed raz, zaraz po zdefiniowaniu fetch_price, i przypisuje nazwę fetch_price do tego, co zwraca timed. To jest cała robota, jaką wykonuje @.
Jak działają dekoratory: funkcje jako obiekty i domknięcia
W Pythonie funkcje są obiektami. Możesz przekazać jedną jako argument i zwrócić inną z funkcji, tak samo jak zrobiłbyś to z listą. wrapper jest też domknięciem (closure): przechwytuje func z otaczającego zakresu i korzysta z niej przy każdym wywołaniu, długo po tym, jak timed już zwróciło wynik.
To cały mechanizm. Dekoratory z argumentami, dekoratory oparte na klasach i wbudowane, jak @property, są tylko wariantami tego samego.
Jak functools.wraps zachowuje nazwę i docstring funkcji
Przykład z timed ma błąd, którego nie widać, dopóki nie sprawdzisz wyniku:
| |
fetch_price straciła nazwę i docstring, bo to wrapper jest zwracany. Wszystko, co odczytuje metadane funkcji, widzi w ich miejsce wrapper: help(), debugery, frameworki webowe, które wyprowadzają nazwy route czy endpointu z funkcji. Naprawiasz to za pomocą functools.wraps:
| |
wraps kopiuje __name__, __doc__, __module__ i __qualname__ (a także __annotations__) z oryginału na wrapper i dodaje atrybut __wrapped__, który wskazuje z powrotem na niego. Kosztuje jedną linię. Dodawaj @wraps(func) do każdego wrappera, który piszesz.
Jak napisać dekorator z argumentami
Prosty dekorator bierze jeden argument: funkcję. Gdy dekorator potrzebuje własnej konfiguracji, na przykład ile razy próbować ponownie i jak długo czekać, dodajesz jedną warstwę: funkcję, która bierze konfigurację i zwraca dekorator.
| |
Czytaj to od zewnątrz do wewnątrz. retry(times=5, delay=2) wykonuje się pierwsze i zwraca decorator. decorator(fetch_price) wykonuje się następnie i zwraca wrapper. wrapper to coś, co wykonuje się przy każdym wywołaniu fetch_price(...).
Trzy poziomy zagnieżdżenia są trudniejsze do przeczytania niż jeden. Jeśli times i delay nigdy się nie zmieniają w twoim kodzie, ustaw je na stałe i wróć do prostszej formy z jednym argumentem.
Jak napisać dekorator oparty na klasie
Każdy obiekt z metodą __call__ może działać jako dekorator. Sięgnij po klasę, gdy dekorator musi utrzymywać stan między wywołaniami, a nie tylko konfigurację:
| |
@CountCalls zamienia process_order na instancję CountCalls, która utrzymuje calls między wywołaniami. Domknięcie z licznikiem nonlocal mogłoby zrobić to samo, ale klasa trzyma ten stan jawnie i daje miejsce na metodę reset().
Dwie pułapki. wraps nie działa tu bezpośrednio: wywołaj functools.update_wrapper(self, func) w __init__, jeśli chcesz zachować metadane. A ta wersja nie działa z metodami: instancja nie ma __get__, więc self dekorowanej metody nigdy nie zostaje przekazany. Do dekorowania metod prostszym wyborem jest dekorator oparty na funkcji.
Wbudowane dekoratory: @staticmethod, @property i @lru_cache
Dekoratory, które Python ma wbudowane, działają na tym samym mechanizmie, dlatego przestają wydawać się niezrozumiałe, gdy sam napiszesz jeden:
@staticmethodi@classmethodzmieniają, co metoda otrzymuje jako pierwszy argument: nic dodatkowego albo klasę zamiast instancji.@propertyzmienia wywołanie metody w dostęp do atrybutu: piszeszobj.valuezamiastobj.value(), a pod spodem nadal wykonuje się kod.@functools.lru_cache(maxsize=128)zwraca wynik z pamięci podręcznej, gdy funkcja jest wywoływana ponownie z tymi samymi argumentami.@functools.cache(Python 3.9+) robi to samo bez limitu rozmiaru. Używajcache, gdy zbiór możliwych wejść jest mały i ograniczony,lru_cache, gdy nie jest.
| |
Bez cache fibonacci(35) przelicza te same wyniki częściowe miliony razy. Z cache każde n jest liczone tylko raz.
W jakiej kolejności wykonują się ułożone dekoratory?
Dekoratory stosują się od dołu do góry, a wykonują od góry do dołu:
| |
timed owija fetch_price najpierw, a retry owija wynik. Przy wywołaniu wrapper retry wykonuje się jako pierwszy i wywołuje zmierzoną wersję przy każdej próbie. Dlatego timed wypisuje jedną liczbę na próbę i nigdy nie liczy czekania między próbami. Zamień te dwie linie i timed znajdzie się poza retry, raportując cały cykl ponownych prób razem z oczekiwaniem jako jedną liczbę.
Kiedy dekorator jest niewłaściwym narzędziem?
Dekoratory skrywają przepływ kontroli. Funkcja, której treść to jedna linia, może otwierać transakcję w bazie danych i sprawdzać uprawnienie, zanim ta linia się wykona, i nic w miejscu wywołania nie mówi o tym wprost. Ten kompromis ma sens dla zachowań, które przecinają wiele funkcji i powinny działać identycznie wszędzie: logowanie, pomiar czasu, cache, autoryzacja. Staje się problemem, gdy zachowanie należy tylko do jednej funkcji. Wtedy napisz to wprost, w miejscu, gdzie zobaczy to kolejny czytelnik.
Stos wywołań też robi się trudniejszy do odczytania. Trzy ułożone dekoratory dodają trzy dodatkowe ramki między wywołującym a twoim kodem. Jeśli dekorator pochłania wyjątki (jak retry przechwytujący ConnectionError) albo zmienia zwracaną wartość, zapisz to w docstringu udekorowanej funkcji, który wraps utrzymuje widocznym. Każdy, kto czyta kolejny traceback, potrzebuje tej wskazówki.
Kiedy wydzielić dekorator we własnym kodzie?
Sygnałem jest powtarzanie: te same pięć linii przygotowania albo czyszczenia na początku trzeciej funkcji. Wydziel je do dekoratora, zanim napiszesz czwartą kopię. Jeśli eksperymentujesz, zrób to w izolowanym środowisku, jak opisano w Wirtualne Środowiska Python: venv, pip i uv, a kiedy dodasz testy dla dekoratora, przetestuj go najpierw na drobnej atrapie funkcji: podział opisany w Testy Jednostkowe vs Integracyjne vs End-to-End dotyczy też tego przypadku, a dekorator to jeden z najłatwiejszych elementów kodu do przetestowania na poziomie jednostkowym.
Jeden przypadek, w którym warto się zatrzymać: jeśli to, co chcesz wstrzyknąć, jest zależnością, na przykład połączeniem z bazą danych albo skonfigurowanym klientem HTTP, to nie jest zachowanie przekrojowe. Przekaż to przez konstruktor, jak pokazano w Czym Jest Dependency Injection? (Z Przykładami).