Hai visto @qualcosa sopra una definizione di funzione in Flask, FastAPI, pytest, Django, SQLAlchemy, e praticamente ogni libreria Python scritta dopo il 2010. Il simbolo @ sembra fare magia. Non la fa. Sono due righe di codice travestite. Alla fine di questa lezione sarai in grado di leggere qualunque decorator in qualunque codebase e di sapere esattamente cosa sta facendo.
La definizione in due righe
Un decorator è una funzione che prende una funzione e restituisce una funzione. Tutto qui.
from typing import Callable
def shout(fn: Callable[[str], str]) -> Callable[[str], str]:
def wrapped(name: str) -> str:
return fn(name).upper() + "!"
return wrapped
def greet(name: str) -> str:
return f"hello, {name}"
greet = shout(greet) # lo avvolgiamo
print(greet("narcis")) # HELLO, NARCIS!
La sintassi @ è solo un’abbreviazione di quell’ultima assegnazione. Questi due snippet sono esattamente equivalenti:
@shout
def greet(name: str) -> str:
return f"hello, {name}"
# vs.
def greet(name: str) -> str:
return f"hello, {name}"
greet = shout(greet)
Leggilo così, greet = shout(greet), e i decorator smettono di essere misteriosi. Tutto il resto è una variazione su questo tema.
Un decorator vero: il timing
Ecco l’esempio che scriverai nella tua carriera tra le cinque e le cinquanta volte. Un decorator che misura quanto ci mette una funzione.
from __future__ import annotations
import time
import functools
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def timed(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
print(f"{fn.__name__} took {elapsed_ms:.2f} ms")
return wrapper
@timed
def slow_add(a: int, b: int) -> int:
time.sleep(0.1)
return a + b
print(slow_add(2, 3))
# slow_add took 100.43 ms
# 5
Qualche cosa che vale la pena sottolineare:
*args, **kwargspermette al wrapper di accettare qualunque firma. È il pattern standard per un decorator a cui non interessa che faccia abbia la funzione sottostante.ParamSpeceTypeVar(Python 3.10+) preservano la firma di tipo della funzione originale. Prima della 3.10 dovevi scegliere tra tipi accurati e un decorator generico. Adesso puoi avere entrambi.try/finallygarantisce che il timing venga stampato anche se la funzione avvolta solleva un’eccezione.@functools.wraps(fn)sta facendo il lavoro più importante in questo snippet, e dobbiamo parlarne.
functools.wraps: la riga che non puoi saltare
Quando avvolgi una funzione, il wrapper prende il posto dell’identità dell’originale. __name__, __doc__, __module__, __annotations__: ora appartengono tutti al wrapper, non alla funzione avvolta. Questo rompe gli strumenti di documentazione, la test discovery di pytest, Sphinx, i messaggi di traceback, qualunque cosa faccia introspezione.
functools.wraps è un piccolo decorator che copia quegli attributi dalla funzione avvolta al wrapper.
import functools
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def bare(fn: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return wrapper
def proper(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return wrapper
@bare
def f1() -> None:
"""Original docstring."""
@proper
def f2() -> None:
"""Original docstring."""
print(f1.__name__, f1.__doc__) # wrapper None
print(f2.__name__, f2.__doc__) # f2 Original docstring.
Usa functools.wraps. Sempre. Ogni decorator che scrivi in codice di produzione dovrebbe averlo. Il costo è una riga. Il costo di dimenticarselo è un futuro collega che passa due ore a chiedersi perché tutti i suoi stack trace dicono wrapper.
(Tangente: gli AI assistant sono estremamente bravi a scrivere decorator, perché il pattern è così strutturato: @functools.wraps, *args, **kwargs, l’inner def wrapper. Il boilerplate è identico in migliaia di esempi di training. Quello che mancano sistematicamente è la thread-safety. Se il tuo decorator mette in cache i risultati in un dict, o muta un contatore, o appende a una lista condivisa, il modello genera felicemente codice che ha race condition. Ogni volta che accetti codice di un decorator scritto dall’AI che tocca stato condiviso, chiediti: cosa succede se due thread chiamano questa cosa nello stesso momento? Di solito la risposta è “un RuntimeError fra sei mesi in produzione.”)
Stackare i decorator
Più decorator su una stessa funzione si compongono dal basso verso l’alto. Il decorator più vicino alla funzione gira per primo.
@logged
@timed
def work(x: int) -> int:
return x * 2
Equivale a:
work = logged(timed(work))
timed avvolge work. Poi logged avvolge la versione “timed”. Quando chiami work(3), il wrapper più esterno (logged) parte per primo, chiama verso l’interno, e si srotola verso l’esterno. L’ordine conta: se logged decidesse di fare short-circuit su un input non valido e di non chiamare verso l’interno, timed non girerebbe mai. L’ordine di stacking cambia il comportamento.
Decorator con argomenti: il balletto di terzo ordine
È qui che i decorator fanno inciampare le persone. A volte vuoi parametrizzare il decorator stesso:
@retry(times=3)
def fetch(url: str) -> bytes:
...
retry(times=3) deve valutare a un decorator, cioè a una funzione che prende una funzione e restituisce una funzione. Quindi retry è una funzione che restituisce un decorator. Tre livelli di def:
import functools
import time
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def retry(times: int = 3, delay: float = 0.5) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last_exc: BaseException | None = None
for attempt in range(1, times + 1):
try:
return fn(*args, **kwargs)
except Exception as exc:
last_exc = exc
if attempt < times:
time.sleep(delay)
assert last_exc is not None
raise last_exc
return wrapper
return decorator
@retry(times=3, delay=0.1)
def flaky() -> str:
...
Tre def, ciascuno con un compito:
retry(times, delay), il più esterno, cattura la configurazione e restituisce un decorator.decorator(fn), quello in mezzo, cattura la funzione che si sta decorando e restituisce un wrapper.wrapper(*args, **kwargs), quello interno, cattura ogni chiamata e fa il lavoro vero.
Se lo fissi abbastanza a lungo smette di sembrare strano. Se non lo fissi abbastanza a lungo, continuerai a scrivere @retry (senza parentesi) e a ottenere un errore confuso. Le parentesi sono obbligatorie quando il decorator prende argomenti, anche un @retry() vuoto, perché la sintassi @ richiede un decorator, non una decorator factory.
Decorator nella libreria standard
Una mezza dozzina di built-in che userai senza pensarci:
import functools
class Order:
def __init__(self, total: float, vat_rate: float) -> None:
self._total = total
self._vat_rate = vat_rate
@property
def vat_amount(self) -> float:
return self._total * self._vat_rate
@staticmethod
def supported_currencies() -> list[str]:
return ["EUR", "USD", "GBP"]
@classmethod
def empty(cls) -> "Order":
return cls(total=0.0, vat_rate=0.0)
@functools.cache
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
@functools.lru_cache(maxsize=128)
def expensive_lookup(key: str) -> dict[str, int]:
...
@propertyfa sembrare un metodo un attributo.order.vat_amountsi legge meglio diorder.vat_amount().@staticmethode@classmethodcambiano comeself/clsviene legato. Utili, ogni tanto.@functools.cache(3.9+) memoizza una funzione con una cache senza limiti. Equivale alru_cache(maxsize=None)ma più chiaro.@functools.lru_cache(maxsize=N)memoizza con una policy LRU limitata. La chiave della cache è la tupla degli argomenti; gli argomenti devono essere hashable.
Le cache hanno la nota a piè di pagina sulla thread-safety di cui parlavo prima. functools.lru_cache è thread-safe in CPython (le operazioni sul dict sottostante sono protette dalla GIL), ma una cache che ti scrivi tu non lo è, a meno che tu non metta un Lock attorno al read-modify-write.
Decorator in librerie popolari
Pattern che vedi tutti i giorni:
# Flask
@app.route("/orders/<int:order_id>", methods=["GET"])
def get_order(order_id: int) -> dict[str, object]:
...
# pytest
@pytest.fixture
def db_connection() -> Iterator[Connection]:
conn = connect()
yield conn
conn.close()
# tenacity
@retry(stop=stop_after_attempt(3), wait=wait_exponential())
def call_external_api() -> bytes:
...
# Click
@click.command()
@click.option("--country", default="IT")
def export(country: str) -> None:
...
Non hai bisogno di capire le interiora delle librerie per usarle. Ti serve sapere che stanno seguendo lo stesso pattern decorator(fn) -> fn che hai visto sopra. Quando qualcosa va storto (@app.route esplode, la fixture di test non viene iniettata), aiuta ricordarsi che non c’è alcuna magia: solo macchinario di funzione-che-restituisce-funzione che, alla peggio, potresti scrivere da solo.
Decorator basati su classi
Puoi scrivere un decorator anche come classe con __call__:
import functools
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
class CallCounter:
def __init__(self, fn: Callable[P, R]) -> None:
functools.update_wrapper(self, fn)
self.fn = fn
self.calls = 0
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
self.calls += 1
return self.fn(*args, **kwargs)
@CallCounter
def ping() -> str:
return "pong"
ping(); ping(); ping()
print(ping.calls) # 3
Utile quando il decorator ha bisogno di stato persistente e vuoi che sia accessibile dall’esterno. functools.update_wrapper è la versione funzionale di @functools.wraps: stesso lavoro.
Quando NON scrivere un decorator
Un decorator è uno strumento eccellente quando:
- Il comportamento avvolge una funzione (prima/dopo/intorno).
- Vuoi lo stesso wrapping in molti posti.
- Il wrapping è ortogonale a quello che fa la funzione (timing, caching, retry, controlli di auth).
È lo strumento sbagliato quando:
- Il comportamento appartiene dentro il corpo della funzione: scrivi un helper.
- Hai bisogno di semantica di acquire/release attorno a un blocco di codice, non a una funzione: usa un context manager (prossima lezione).
- Avvolgi una sola funzione in tutta la tua vita: inlinea la logica e basta.
Il puzzo a cui fare attenzione: un decorator con un oggetto di configurazione gigante e un if context.qualcosa: do_a() else: do_b() condizionale dentro il wrapper. Quella è una funzione travestita da decorator. Refactor.
Questi sono i decorator. Prossima lezione: i context manager. Il blocco with, tre modi per scriverne uno tuo, e i pattern che rendono try/finally ridondante.
Citations (retrieved 2026-05-01):
- PEP 318, “Decorators for Functions and Methods” — https://peps.python.org/pep-0318/
- PEP 612, “Parameter Specification Variables” (
ParamSpec) — https://peps.python.org/pep-0612/ functoolsmodule documentation — https://docs.python.org/3/library/functools.html- Python Language Reference, “Function definitions” — https://docs.python.org/3/reference/compound_stmts.html#function-definitions