Python, de la zero Lecția 5 / 60

Decorators, demistificati

Functii care invelesc functii. Pattern-urile pe care le intalnesti zilnic, capcanele de stiut si cand sa-ti scrii propriul decorator.

Ai văzut @something deasupra unei definiții de funcție în Flask, FastAPI, pytest, Django, SQLAlchemy și practic în orice bibliotecă Python scrisă după 2010. Simbolul @ arată ca și cum ar face magie. Nu face. Sunt două linii de cod deghizate. La sfârșitul acestei lecții vei putea citi orice decorator din orice cod și vei ști exact ce face.

Definiția în două linii

Un decorator e o funcție care ia o funcție și returnează o funcție. Atât.

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)        # învelește-o
print(greet("narcis"))      # HELLO, NARCIS!

Sintaxa @ e doar prescurtare pentru ultima atribuire. Cele două secvențe de mai jos sunt exact echivalente:

@shout
def greet(name: str) -> str:
    return f"hello, {name}"

# vs.

def greet(name: str) -> str:
    return f"hello, {name}"

greet = shout(greet)

Citește-l așa, greet = shout(greet), și decorators încetează să mai fie misterioși. Restul e variațiuni pe această temă.

Un decorator real: timing

Iată exemplul pe care îl vei scrie în cariera ta undeva între cinci și cincizeci de ori. Un decorator care măsoară cât durează o funcție.

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

Câteva lucruri merită semnalate:

  • *args, **kwargs permite wrapper-ului să accepte orice signature. Ăsta e pattern-ul standard pentru un decorator căruia nu-i pasă cum arată funcția dedesubt.
  • ParamSpec și TypeVar (Python 3.10+) păstrează signature-ul de tipuri al funcției originale. Înainte de 3.10 trebuia să alegi între tipuri exacte și un decorator generic. Acum poți avea pe amândouă.
  • try/finally asigură că timing-ul e printat chiar dacă funcția învelită ridică o excepție.
  • @functools.wraps(fn) face cea mai importantă muncă din această secvență, și trebuie să discutăm despre el.

functools.wraps: linia pe care nu trebuie s-o omiți

Când învelești o funcție, wrapper-ul preia identitatea originalului. __name__, __doc__, __module__, __annotations__: toate aparțin acum wrapper-ului, nu funcției învelite. Asta strică uneltele de documentație, descoperirea testelor pytest, Sphinx, mesajele de traceback, orice face introspecție.

functools.wraps e un mic decorator care copiază acele atribute de la funcția învelită la 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.

Folosește functools.wraps. Întotdeauna. Fiecare decorator pe care îl scrii în cod de producție ar trebui să-l aibă. Costul e o linie. Costul de a-l uita e un coleg viitor care petrece două ore întrebându-se de ce toate stack trace-urile lui spun wrapper.

(Tangent: asistenții AI sunt extrem de buni la scris decorators, pentru că pattern-ul e atât de structurat: @functools.wraps, *args, **kwargs, interiorul def wrapper. Boilerplate-ul e identic în mii de exemple de antrenament. Ce ratează constant e thread-safety-ul. Dacă decoratorul tău cache-uiește rezultate într-un dict, sau mutează un counter, sau face append la o listă partajată, modelul va genera bucuros cod care intră în race condition. De fiecare dată când accepți cod de decorator scris de AI care atinge stare partajată, întreabă-te: ce se întâmplă dacă două thread-uri îl cheamă în același timp? De obicei răspunsul e „un RuntimeError peste șase luni în producție”.)

Stivuirea decoratorilor

Mai mulți decorators pe o singură funcție se compun de jos în sus. Decoratorul cel mai apropiat de funcție rulează primul.

@logged
@timed
def work(x: int) -> int:
    return x * 2

Asta e echivalent cu:

work = logged(timed(work))

timed învelește work. Apoi logged învelește versiunea timed. Când chemi work(3), wrapper-ul cel mai exterior (logged) rulează primul, cheamă spre interior și se desface spre exterior. Ordinea contează: dacă logged ar decide să facă short-circuit pe input prost și să nu cheme spre interior, timed n-ar rula niciodată. Ordinea de stivuire schimbă comportamentul.

Decoratori cu argumente: dansul de ordinul trei

Aici se împiedică oamenii de decorators. Uneori vrei să parametrizezi decoratorul în sine:

@retry(times=3)
def fetch(url: str) -> bytes:
    ...

retry(times=3) trebuie să se evalueze la un decorator, adică la o funcție care ia o funcție și returnează o funcție. Deci retry e o funcție care returnează un decorator. Trei niveluri de 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:
    ...

Trei def-uri, fiecare cu o singură treabă:

  1. retry(times, delay), cel mai exterior, captează configurația și returnează un decorator.
  2. decorator(fn), cel din mijloc, captează funcția decorată și returnează un wrapper.
  3. wrapper(*args, **kwargs), cel interior, captează fiecare apel și face munca propriu-zisă.

Dacă te uiți la el suficient de mult, încetează să mai pară ciudat. Dacă nu te uiți la el suficient de mult, vei continua să scrii @retry (fără paranteze) și să primești o eroare confuză. Parantezele sunt obligatorii când decoratorul ia argumente, chiar și un @retry() gol, pentru că sintaxa @ cere un decorator, nu o fabrică de decorators.

Decoratori în standard library

O jumătate de duzină de built-ins pe care îi vei folosi fără să te gândești:

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 face ca o metodă să arate ca un atribut. order.vat_amount se citește mai bine decât order.vat_amount().
  • @staticmethod și @classmethod schimbă cum se leagă self/cls. Utili, ocazional.
  • @functools.cache (3.9+) memoizează o funcție cu un cache nelimitat. La fel ca lru_cache(maxsize=None), dar mai clar.
  • @functools.lru_cache(maxsize=N) memoizează cu o politică LRU mărginită. Cheia de cache e tuplul de argumente; argumentele trebuie să fie hashable.

Cache-urile au footnote-ul de thread-safety pe care l-am menționat mai devreme. functools.lru_cache este thread-safe în CPython: operațiile dict-ului de dedesubt sunt protejate de GIL, dar un cache pe care-l scrii tu nu e, decât dacă pui un Lock în jurul read-modify-write-ului.

Decoratori în biblioteci populare

Pattern-uri pe care le vezi zilnic:

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

N-ai nevoie să înțelegi măruntaiele bibliotecii ca să-i folosești. Ai nevoie să știi că urmăresc același pattern decorator(fn) -> fn pe care l-ai văzut mai sus. Când ceva merge prost (@app.route aruncă, fixture-ul de test nu e injectat), ajută să-ți amintești că nu e magie: doar mecanismul „funcție-care-returnează-funcție” pe care l-ai putea scrie singur la nevoie.

Decoratori pe bază de clase

Poți scrie un decorator și ca o clasă cu __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

Util când decoratorul are nevoie de stare persistentă și vrei să o accesezi din afară. functools.update_wrapper e forma de funcție a lui @functools.wraps: aceeași treabă.

Când să NU scrii un decorator

Un decorator e o unealtă grozavă când:

  • Comportamentul învelește o funcție (înainte/după/în jur).
  • Vrei aceeași învelire în mai multe locuri.
  • Învelirea e ortogonală cu ce face funcția (timing, caching, retries, verificări de auth).

E unealta greșită când:

  • Comportamentul aparține în interiorul corpului funcției: scrie un helper.
  • Ai nevoie de semantică acquire/release în jurul unui bloc de cod, nu al unei funcții: folosește un context manager (lecția următoare).
  • Înveli o singură funcție, vreodată: scrie logica direct, inline.

Mirosul de urmărit: un decorator cu un obiect de configurare uriaș și un if context.something: do_a() else: do_b() condițional în interiorul wrapper-ului. Aia e o funcție mascată ca decorator. Refactorizeaz-o.

Asta a fost despre decorators. Lecția următoare: context managers, blocul with, trei moduri de a-ți scrie propriul context manager și pattern-urile care fac try/finally redundant.


Citations (consultat 2026-05-01):

Caută