Python, dalle fondamenta Lezione 5 / 60

Decorator, demistificati

Funzioni che avvolgono funzioni. I pattern che incontri tutti i giorni, le trappole da conoscere, e quando vale la pena scriverne uno tuo.

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, **kwargs permette al wrapper di accettare qualunque firma. È il pattern standard per un decorator a cui non interessa che faccia abbia la funzione sottostante.
  • ParamSpec e TypeVar (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/finally garantisce 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:

  1. retry(times, delay), il più esterno, cattura la configurazione e restituisce un decorator.
  2. decorator(fn), quello in mezzo, cattura la funzione che si sta decorando e restituisce un wrapper.
  3. 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]:
    ...
  • @property fa sembrare un metodo un attributo. order.vat_amount si legge meglio di order.vat_amount().
  • @staticmethod e @classmethod cambiano come self/cls viene legato. Utili, ogni tanto.
  • @functools.cache (3.9+) memoizza una funzione con una cache senza limiti. Equivale a lru_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):

Cerca