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.
| |
@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:
| |
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:
| |
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.
| |
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:
| |
@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:
@staticmethode@classmethodcambiano cosa riceve un metodo come primo argomento: niente di extra, oppure la classe invece dell’istanza.@propertytrasforma una chiamata di metodo in accesso a un attributo,obj.valueinvece diobj.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. Usacachequando l’insieme di input possibili è piccolo e limitato,lru_cachequando non lo è.
| |
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:
| |
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).