Cosa fa davvero un decoratore alla tua funzione

Vuoi misurare il tempo di ogni funzione che chiama un’API esterna lenta. Puoi incollare chiamate a time.perf_counter() in ognuna, oppure scrivere la misurazione una sola volta e applicarla ovunque. Un decoratore è la seconda opzione: è una funzione che prende un’altra funzione, la avvolge in un comportamento aggiuntivo e restituisce la versione avvolta con lo stesso nome originale.

 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)  # al posto di una chiamata di rete
    return 142.50

fetch_price("AAPL")  # fetch_price took 0.201s

@timed sopra def fetch_price è l’abbreviazione di fetch_price = timed(fetch_price). Python chiama timed una sola volta, subito dopo la definizione di fetch_price, e ricollega il nome fetch_price a quello che timed restituisce. Tutto quello che fa la @ è questo.

Come funzionano i decoratori: funzioni come oggetti e closure

In Python le funzioni sono oggetti. Puoi passarne una come argomento e restituirne una da un’altra funzione, proprio come faresti con una lista. wrapper è anche una closure: cattura func dallo scope che la contiene e continua a usarla ogni volta che viene eseguita, anche molto dopo che timed ha terminato.

Questo è tutto il meccanismo. Decoratori con argomenti, decoratori basati su classi e built-in come @property sono varianti dello stesso principio.

Come functools.wraps conserva nome e docstring della funzione

L’esempio di timed ha un bug che non noti finchĂ© non ispezioni il risultato:

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

fetch_price ha perso nome e docstring, perché il nome restituito è wrapper, non fetch_price. Qualsiasi cosa legga i metadati della funzione vede wrapper al suo posto: help(), i debugger, i framework web che derivano nomi di route o endpoint dalla funzione. Si risolve con 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 copia __name__, __doc__, __module__ e __qualname__ (piĂą __annotations__) dall’originale al wrapper, e aggiunge un attributo __wrapped__ che rimanda alla funzione originale. Costa una riga. Metti @wraps(func) su ogni wrapper che scrivi.

Come scrivere un decoratore con argomenti

Un decoratore semplice prende un solo argomento: la funzione. Quando il decoratore ha bisogno di una configurazione propria, come quante volte riprovare e quanto aspettare, aggiungi un livello: una funzione che prende la configurazione e restituisce un decoratore.

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

Leggilo dall’esterno verso l’interno. retry(times=5, delay=2) viene eseguito per primo e restituisce decorator. decorator(fetch_price) viene eseguito dopo e restituisce wrapper. wrapper è quello che si esegue a ogni chiamata di fetch_price(...).

Tre livelli di annidamento sono piĂą difficili da leggere di uno solo. Se times e delay non cambiano mai nel tuo codice, fissali e torna alla forma con un solo argomento.

Come scrivere un decoratore basato su classe

Qualsiasi oggetto con un metodo __call__ può funzionare da decoratore. Passa a una classe quando il decoratore deve mantenere uno stato tra le chiamate, non solo una configurazione:

 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 sostituisce process_order con un’istanza di CountCalls, che mantiene calls tra una chiamata e l’altra. Una closure con un contatore nonlocal potrebbe fare lo stesso, ma la classe rende lo stato esplicito e ti dĂ  un posto dove mettere un metodo reset().

Due avvertenze. wraps non si applica direttamente qui: chiama functools.update_wrapper(self, func) in __init__ se vuoi i metadati. E questa versione si rompe sui metodi: l’istanza non ha __get__, quindi il self del metodo decorato non viene mai passato. Per decorare metodi, un decoratore basato su funzione è la scelta piĂą semplice.

Decoratori built-in: @staticmethod, @property e @lru_cache

I decoratori che Python fornisce di default usano lo stesso meccanismo, per questo smettono di sembrare oscuri una volta che ne hai scritto uno per conto tuo:

  • @staticmethod e @classmethod cambiano cosa riceve un metodo come primo argomento: niente di extra, oppure la classe invece dell’istanza.
  • @property trasforma una chiamata di metodo in accesso a un attributo, obj.value invece di obj.value(), continuando a eseguire codice sotto.
  • @functools.lru_cache(maxsize=128) restituisce un risultato dalla cache quando la funzione viene chiamata di nuovo con gli stessi argomenti. @functools.cache (Python 3.9+) fa lo stesso senza limite di dimensione. Usa cache quando l’insieme di input possibili è piccolo e limitato, lru_cache quando non lo è.
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)

Senza cache, fibonacci(35) ricalcola gli stessi risultati parziali milioni di volte. Con la cache, ogni n viene calcolato una sola volta.

In che ordine vengono eseguiti i decoratori sovrapposti?

I decoratori si applicano dal basso verso l’alto e vengono eseguiti dall’alto verso il basso:

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

timed avvolge fetch_price per primo, e retry avvolge il risultato. Al momento della chiamata, il wrapper di retry viene eseguito per primo e chiama la versione temporizzata a ogni tentativo. Così timed stampa un numero per ogni tentativo e non conta mai l’attesa tra un tentativo e l’altro. Scambia le due righe e timed finisce fuori da retry, riportando l’intero ciclo di retry, pause incluse, come un unico numero.

Quando un decoratore è lo strumento sbagliato?

I decoratori nascondono il flusso di controllo. Una funzione il cui corpo è una sola riga potrebbe aprire una transazione sul database e verificare un permesso prima che quella riga venga eseguita, e nulla nel punto di chiamata te lo dice. Questo compromesso ha senso quando riguarda responsabilità trasversali a molte funzioni, che devono comportarsi in modo identico ovunque: logging, timing, cache, autenticazione. Diventa un problema quando il comportamento appartiene a una sola funzione: in quel caso scrivilo in linea, dove chi legge dopo di te lo vedrà.

Anche gli stack trace diventano piĂą difficili da leggere. Tre decoratori sovrapposti aggiungono tre frame in piĂą tra chi chiama e il tuo codice. Se un decoratore assorbe eccezioni (come retry che intercetta ConnectionError) o cambia il valore restituito, dillo nella docstring della funzione decorata, che wraps mantiene visibile. Chi leggerĂ  il prossimo traceback ha bisogno di quell’indizio.

Quando estrarre un decoratore nel tuo codice?

Il segnale è la ripetizione: le stesse cinque righe di setup o teardown in testa a una terza funzione. Estraile in un decoratore prima di scrivere la quarta copia. Se stai sperimentando, fallo in un ambiente isolato, come descritto in Ambienti Virtuali Python: venv, pip e uv. Quando aggiungi i test per il decoratore, testalo prima contro una funzione fittizia minima: la stratificazione descritta in Test Unitari vs Test di Integrazione vs Test End-to-End si applica anche qui. Un decoratore è quasi il codice più facile da testare a livello di unità che esista.

Un caso in cui fermarti: se quello che vuoi iniettare è una dipendenza, come una connessione al database o un client HTTP configurato, non è un comportamento trasversale. Passala tramite il costruttore, come mostrato in Cos’è la Dependency Injection (con Esempi).

Articoli correlati