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ą.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import time

def timed(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper

@timed
def fetch_price(ticker):
    time.sleep(0.2)  # zastępuje wywołanie sieciowe
    return 142.50

fetch_price("AAPL")  # fetch_price took 0.201s

@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:

1
2
print(fetch_price.__name__)  # wrapper
print(fetch_price.__doc__)   # None

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from functools import wraps

def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import time
from functools import wraps

def retry(times=3, delay=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except ConnectionError as e:
                    last_error = e
                    time.sleep(delay)
            raise last_error
        return wrapper
    return decorator

@retry(times=5, delay=2)
def fetch_price(ticker):
    ...

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ę:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class CountCalls:
    def __init__(self, func):
        self.func = func
        self.calls = 0

    def __call__(self, *args, **kwargs):
        self.calls += 1
        return self.func(*args, **kwargs)

@CountCalls
def process_order(order_id):
    ...

process_order(101)
process_order(102)
print(process_order.calls)  # 2

@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:

  • @staticmethod i @classmethod zmieniają, co metoda otrzymuje jako pierwszy argument: nic dodatkowego albo klasę zamiast instancji.
  • @property zmienia wywołanie metody w dostęp do atrybutu: piszesz obj.value zamiast obj.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żywaj cache, gdy zbiór możliwych wejść jest mały i ograniczony, lru_cache, gdy nie jest.
1
2
3
4
5
6
7
from functools import lru_cache

@lru_cache(maxsize=256)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

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:

1
2
3
4
@retry(times=3)
@timed
def fetch_price(ticker):
    ...

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

Powiązane artykuły