A la lliçó anterior les signatures de magatzem.py van quedar anotades, i en escriure-les va aparèixer una necessitat nova: l'Anna vol saber quant triga tancament_de_caixa() amb un any de vendes, i vol que cada crida a vendre() quedi auditada a papyrus.log. Podries obrir cada funció i afegir-hi dins el cronòmetre i el log... però això barreja la lògica de negoci amb la fontaneria, i caldria repetir-ho a cada funció. Els decoradors resolen això: emboliquen una funció amb comportament extra sense tocar-ne el codi. Ja els fas servir des del mòdul 5 (@property, @dataclass) sense saber com funcionen per dins; avui els desmuntem peça a peça.

Contingut

  1. Funcions com a objectes de primera classe
  2. Closures: funcions que recorden
  3. Un decorador des de zero, sense sucre sintàctic
  4. La sintaxi @
  5. *args i **kwargs: embolicar qualsevol signatura
  6. functools.wraps: no perdre la identitat
  7. Decoradors per a Papyrus: @cronometrar i @registrar_crida
  8. Decoradors amb arguments: @reintentar(cops=3)
  9. Els decoradors que ja coneixies, amb una altra llum
  10. No sobredecorar

Funcions com a objectes de primera classe

Tot el que ve a continuació es recolza en un fet que ja vas fregar a M3 amb sorted(key=...) i les lambdes: en Python, una funció és un objecte com qualsevol altre. Es pot assignar, passar com a argument i retornar:

def saludar(nom: str) -> str:
    return f"Benvingut a Papyrus, {nom}"

atendre = saludar          # assignar: SENSE parentesis — la funcio, no el seu resultat
print(atendre("Omar"))     # Benvingut a Papyrus, Omar

def aplicar(funcio, valor):
    return funcio(valor)   # passar com a argument (com key= a sorted)

print(aplicar(saludar, "Julia"))

La distinció crucial és saludar (l'objecte funció) davant de saludar() (el resultat de cridar-la). Tot el mecanisme dels decoradors gira sobre aquesta diferència.

I també es poden definir i retornar funcions dins de funcions:

def crear_salutacio(botiga: str):
    def salutacio(nom: str) -> str:      # funcio definida a dins
        return f"Benvingut a {botiga}, {nom}"
    return salutacio                     # es retorna l'objecte funcio

saluda_papyrus = crear_salutacio("Papyrus")
print(saluda_papyrus("Julia"))           # Benvingut a Papyrus, Julia

Closures: funcions que recorden

Fixa't en una cosa sorprenent de l'exemple anterior: quan crear_salutacio("Papyrus") acaba, la seva variable local botiga hauria de desaparèixer... i tanmateix saluda_papyrus la continua fent servir. Una funció interna que captura variables de la funció que la va crear s'anomena closure (clausura). Pas a pas:

  1. Crides crear_salutacio("Papyrus") → es crea botiga = "Papyrus".
  2. Es defineix salutacio, que esmenta botiga — Python pren nota que la necessita.
  3. crear_salutacio retorna salutacio i acaba, però botiga no es destrueix: viatja de "motxilla" dins de la funció retornada.
  4. Cada crida posterior a saluda_papyrus(...) troba botiga intacta.

Els closures són la memòria dels decoradors: així és com la funció embolcall "recorda" quina funció embolica.

Un decorador des de zero, sense sucre

Un decorador és simplement una funció que rep una funció i en retorna una altra que l'embolica. Sense @, a mà:

def anunciar(funcio):
    def embolcall():
        print(f"→ Cridant {funcio.__name__}...")
        resultat = funcio()               # la funcio original, recordada pel closure
        print(f"← {funcio.__name__} ha acabat.")
        return resultat
    return embolcall

def obrir_botiga():
    print("Papyrus obre les portes.")

obrir_botiga = anunciar(obrir_botiga)     # decorar = REASSIGNAR!
obrir_botiga()
→ Cridant obrir_botiga...
Papyrus obre les portes.
← obrir_botiga ha acabat.

La línia clau és obrir_botiga = anunciar(obrir_botiga): el nom obrir_botiga deixa d'apuntar a la funció original i passa a apuntar a l'embolcall, que desa l'original al seu closure. Ningú no ha tocat el cos d'obrir_botiga; només se li ha posat una capa al voltant.

La sintaxi @

Com que aquest patró de reassignació és tan comú, Python li dona sucre sintàctic:

@anunciar
def obrir_botiga():
    print("Papyrus obre les portes.")

@anunciar just a sobre del def significa exactament obrir_botiga = anunciar(obrir_botiga) executat després de definir la funció. No hi ha més màgia: si entens la reassignació, entens el @.

*args i **kwargs: embolicar qualsevol signatura

L'embolcall() de dalt no accepta arguments, així que no pot embolicar vendre(cataleg, titol, unitats). La solució és el duo de M3 *args/**kwargs, que aquí troba el seu ús estrella — recollir el que sigui i passar-ho tal qual:

def anunciar(funcio):
    def embolcall(*args, **kwargs):
        print(f"→ {funcio.__name__}")
        return funcio(*args, **kwargs)    # reenvia els arguments intactes
    return embolcall

Amb això el decorador és universal: serveix per a funcions amb zero, tres o vint paràmetres, posicionals o amb nom. És la signatura estàndard de tot embolcall.

functools.wraps: no perdre la identitat

Hi ha un efecte secundari subtil. Un cop decorada, vendre.__name__ ja no és "vendre" sinó "embolcall", i la seva docstring — el contracte que tant hem cuidat a 08-01 — desapareix. Per al logger.exception() de M7, que registra noms de funcions, això és un problema real. functools.wraps copia la identitat de l'original a l'embolcall:

import functools

def anunciar(funcio):
    @functools.wraps(funcio)              # ← un decorador... dins del teu decorador
    def embolcall(*args, **kwargs):
        return funcio(*args, **kwargs)
    return embolcall
Sense @functools.wraps Amb @functools.wraps
vendre.__name__"embolcall" vendre.__name__"vendre"
help(vendre) → docstring de l'embolcall (o res) help(vendre) → docstring original
Traces i logs confusos Traces i logs correctes

Regla sense excepcions: tot decorador porta @functools.wraps.

Decoradors per a Papyrus

@cronometrar: mesurar tancament_de_caixa

import functools
import logging
import time

logger = logging.getLogger(__name__)      # el patro per modul de 07-05

def cronometrar(funcio):
    """Registra al log quant triga cada crida."""
    @functools.wraps(funcio)
    def embolcall(*args, **kwargs):
        inici = time.perf_counter()
        try:
            return funcio(*args, **kwargs)
        finally:                          # es mesura fins i tot si la funcio llanca (07-02)
            durada = time.perf_counter() - inici
            logger.info("%s ha trigat %.3f s", funcio.__name__, durada)
    return embolcall

@cronometrar
def tancament_de_caixa(ruta_vendes):
    ...

Després d'executar el tancament, a dades/papyrus.log apareix amb el format de M7:

2026-07-13 20:05:12,481 INFO     magatzem: tancament_de_caixa ha trigat 0.042 s

El try/finally garanteix la mesura encara que tancament_de_caixa llanci: la mateixa disciplina de neteja de 07-02.

@registrar_crida: auditoria de vendes

L'Anna vol poder reconstruir què s'ha venut i què ha fallat. En comptes de ficar logs dins de vendre(), s'embolica:

def registrar_crida(funcio):
    """Deixa al log cada crida, el seu resultat o la seva excepcio."""
    @functools.wraps(funcio)
    def embolcall(*args, **kwargs):
        logger.info("crida: %s args=%r kwargs=%r", funcio.__name__, args, kwargs)
        try:
            resultat = funcio(*args, **kwargs)
            logger.info("ok: %s → %r", funcio.__name__, resultat)
            return resultat
        except Exception:
            logger.exception("fallada a %s", funcio.__name__)   # amb traca, com a 07-05
            raise                                               # rellançar: el decorador no decideix
    return embolcall

@registrar_crida
def vendre(cataleg, titol, unitats):
    ...

Una venda de Hamlet x2 deixa ok: vendre → 20.7; un intent de vendre 9 exemplars de L'Odissea amb 4 en estoc deixa la traça completa d'EstocInsuficientError. I atenció al raise nu: el decorador observa i rellança. Atrapar i silenciar aquí violaria la regla de les tres anelles de 07-04 — la decisió sobre l'excepció pertany al taulell o a la frontera, no a l'embolcall.

Decoradors amb arguments: @reintentar(cops=3)

I si desar el catàleg falla esporàdicament (disc de xarxa, antivirus) i vols reintentar? Voldríem escriure @reintentar(cops=3). Però això vol dir que reintentar(cops=3) es crida primer i ha de retornar un decorador: és una fàbrica de decoradors. Tres nivells:

def reintentar(cops: int = 3, pausa: float = 0.5):
    """Fabrica: retorna un decorador que reintenta fins a `cops` vegades."""
    def decorador(funcio):                        # nivell 2: el decorador real
        @functools.wraps(funcio)
        def embolcall(*args, **kwargs):           # nivell 3: l'embolcall
            for intent in range(1, cops + 1):
                try:
                    return funcio(*args, **kwargs)
                except OSError:                   # nomes errors transitoris, no bugs
                    logger.warning("intent %d/%d de %s ha fallat",
                                   intent, cops, funcio.__name__)
                    if intent == cops:
                        raise                     # esgotats els intents, propaga
                    time.sleep(pausa)
        return embolcall
    return decorador

@reintentar(cops=3)
def desar_cataleg(cataleg, ruta):
    ...

Lectura en cadena: @reintentar(cops=3) → s'executa reintentar(cops=3) → retorna decoradordecorador(desar_cataleg) → retorna embolcalldesar_cataleg = embolcall. Cada nivell és un closure: embolcall recorda alhora funcio, cops i pausa. Fixa't en la tria d'except OSError: es reintenta allò transitori; un TypeError és un bug i ha de petar a la primera (07-04).

Els decoradors que ja coneixies, amb una altra llum

Decorador On el vas veure Què fa, ara que saps com
@property M5 (encapsulació) Embolica el mètode perquè s'hi accedeixi com a atribut
@dataclass 05-06 Decora la classe: en llegeix les anotacions (08-01) i li genera __init__, __repr__, __eq__
@staticmethod / @classmethod M5 Canvien com es passa (o no) self/cls al mètode
@functools.wraps Aquesta lliçó Un decorador que arregla decoradors

Cap no era màgia: tots són "funció (o classe) entra, versió embolicada o transformada surt".

No sobredecorar

Un advertiment abans que decoris tota la botiga: cada decorador afegeix una capa a travessar quan depures. Si vendre porta @cronometrar, @registrar_crida i @reintentar, una traça d'error mostra tres embolcalls abans d'arribar al codi real, i l'ordre d'apilament comença a importar (s'apliquen de baix a dalt). Criteri pràctic: decora quan el comportament sigui transversal i repetit (logging, mesura, reintents); no decoris per a lògica de negoci d'una sola funció — això va dins de la funció, on es veu.

Errors Comuns i Consells

  • Decorar amb parèntesis de més o de menys: @cronometrar() falla si cronometrar és un decorador simple, i @reintentar (sense parèntesis) falla si és una fàbrica. Regla: fàbrica → sempre amb parèntesis, encara que facis servir els valors per defecte (@reintentar()).
  • Oblidar return funcio(...) a l'embolcall: la funció decorada passa a retornar None silenciosament. Si vendre decorada deixa de retornar la quantia, revisa l'embolcall abans que la funció.
  • Oblidar @functools.wraps: els logs de 07-05 comencen a dir embolcall en comptes de vendre i la depuració es torna un laberint.
  • Empassar-se excepcions al decorador: un except Exception: pass en un embolcall és el "silenci" que 07-04 va prohibir, ara camuflat. Observa, registra amb logger.exception, i rellança.
  • Consell: escriu primer el decorador sense @, amb la reassignació explícita, fins que el flux et resulti obvi. El @ arriba sol.
  • Consell: els embolcalls es beneficien dels type hints de 08-01 — def embolcall(*args, **kwargs) -> ... es pot anotar amb precisió fent servir typing.Callable; guarda-t'ho com a lectura opcional, encara no ho necessites.

Exercicis

  1. Escriu @nomes_socis, un decorador per a funcions el primer argument de les quals és un codi de soci ("LLUIS-001", "MARTA-002", "PAU-003"). Si el codi no és al conjunt de socis vàlids, ha de llançar SociInvalidError(codi) sense cridar la funció; si és vàlid, la crida normalment. No oblidis functools.wraps.
  2. Sense fer servir la sintaxi @, decora tancament_de_caixa amb el cronometrar de la lliçó mitjançant reassignació explícita, i explica en un comentari per què tancament_de_caixa.__name__ continua sent "tancament_de_caixa".
  3. Converteix @cronometrar en una fàbrica @cronometrar(llindar=1.0) que només escrigui al log si la funció ha trigat més de llindar segons (per no omplir papyrus.log amb tancaments ràpids).

Solucions

  1. import functools
    from errors import SociInvalidError
    
    SOCIS_VALIDS: set[str] = {"LLUIS-001", "MARTA-002", "PAU-003"}
    
    def nomes_socis(funcio):
        @functools.wraps(funcio)
        def embolcall(codi, *args, **kwargs):
            if codi not in SOCIS_VALIDS:
                raise SociInvalidError(codi)   # guard clause de 07-03, ara reutilitzable
            return funcio(codi, *args, **kwargs)
        return embolcall
    

    L'embolcall anomena codi explícitament (el necessita per validar) i reenvia la resta amb *args, **kwargs.

  2. tancament_de_caixa = cronometrar(tancament_de_caixa)
    # __name__ segueix sent "tancament_de_caixa" perque l'embolcall porta
    # @functools.wraps(funcio), que copia __name__ i la docstring de la
    # funcio original a l'embolcall abans de retornar-lo.
    
  3. def cronometrar(llindar: float = 0.0):
        def decorador(funcio):
            @functools.wraps(funcio)
            def embolcall(*args, **kwargs):
                inici = time.perf_counter()
                try:
                    return funcio(*args, **kwargs)
                finally:
                    durada = time.perf_counter() - inici
                    if durada > llindar:
                        logger.info("%s ha trigat %.3f s", funcio.__name__, durada)
            return embolcall
        return decorador
    
    @cronometrar(llindar=1.0)
    def tancament_de_caixa(ruta_vendes): ...
    

    llindar viatja al closure a través dels tres nivells. Error comú aquí: deixar @cronometrar sense parèntesis — ara és fàbrica i cal cridar-la sempre.

Conclusió

Un decorador no té res de màgic: funcions que són objectes, closures que recorden, i una reassignació que el @ abreuja. Amb @cronometrar, @registrar_crida i @reintentar(cops=3), Papyrus afegeix mesura, auditoria i resiliència sense embrutar vendre() ni tancament_de_caixa() — i de passada @property i @dataclass han deixat de ser encanteris. Precisament en cronometrar el tancament apareix la pregunta següent: tancament_de_caixa() llegeix vendes.csv sencer per sumar-lo, i el fitxer creix cada dia. Cal carregar en memòria un any de vendes per sumar les d'avui? No: es poden produir i consumir les files sota demanda, d'una en una. Aquest és el terreny dels generadors i del yield, la propera lliçó.

Curs de Programació en Python

Mòdul 1: Introducció a Python

Mòdul 2: Estructures de Control

Mòdul 3: Funcions i Mòduls

Mòdul 4: Estructures de Dades

Mòdul 5: Programació Orientada a Objectes

Mòdul 6: Gestió de Fitxers

Mòdul 7: Gestió d'Errors i Excepcions

Mòdul 8: Temes Avançats

Mòdul 9: Proves i Depuració

Mòdul 10: Desenvolupament Web amb Python

Mòdul 11: Ciència de Dades amb Python

Mòdul 12: Projecte Final

© Copyright 2026. Tots els drets reservats