En la lección anterior las firmas de almacen.py quedaron anotadas, y al escribirlas apareció una necesidad nueva: Ana quiere saber cuánto tarda cierre_de_caja() con un año de ventas, y quiere que cada llamada a vender() quede auditada en papyrus.log. Podrías abrir cada función y añadir dentro el cronómetro y el log... pero eso mezcla la lógica de negocio con la fontanería, y habría que repetirlo en cada función. Los decoradores resuelven esto: envuelven una función con comportamiento extra sin tocar su código. Ya los usas desde el módulo 5 (@property, @dataclass) sin saber cómo funcionan por dentro; hoy los desmontamos pieza a pieza.

Contenido

  1. Funciones como objetos de primera clase
  2. Closures: funciones que recuerdan
  3. Un decorador desde cero, sin azúcar sintáctico
  4. La sintaxis @
  5. *args y **kwargs: envolver cualquier firma
  6. functools.wraps: no perder la identidad
  7. Decoradores para Papyrus: @cronometrar y @registrar_llamada
  8. Decoradores con argumentos: @reintentar(veces=3)
  9. Los decoradores que ya conocías, con otra luz
  10. No sobredecorar

Funciones como objetos de primera clase

Todo lo que sigue se apoya en un hecho que ya rozaste en M3 con sorted(key=...) y las lambdas: en Python, una función es un objeto como cualquier otro. Se puede asignar, pasar como argumento y devolver:

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

atender = saludar          # asignar: SIN paréntesis — la función, no su resultado
print(atender("Omar"))     # Bienvenido a Papyrus, Omar

def aplicar(funcion, valor):
    return funcion(valor)  # pasar como argumento (como key= en sorted)

print(aplicar(saludar, "Júlia"))

La distinción crucial es saludar (el objeto función) frente a saludar() (el resultado de llamarla). Todo el mecanismo de los decoradores gira sobre esa diferencia.

Y también se pueden definir y devolver funciones dentro de funciones:

def crear_saludo(tienda: str):
    def saludo(nombre: str) -> str:      # función definida dentro
        return f"Bienvenido a {tienda}, {nombre}"
    return saludo                        # se devuelve el objeto función

saluda_papyrus = crear_saludo("Papyrus")
print(saluda_papyrus("Júlia"))           # Bienvenido a Papyrus, Júlia

Closures: funciones que recuerdan

Fíjate en algo sorprendente del ejemplo anterior: cuando crear_saludo("Papyrus") termina, su variable local tienda debería desaparecer... y sin embargo saluda_papyrus la sigue usando. Una función interna que captura variables de la función que la creó se llama closure (clausura). Paso a paso:

  1. Llamas a crear_saludo("Papyrus") → se crea tienda = "Papyrus".
  2. Se define saludo, que menciona tienda — Python anota que la necesita.
  3. crear_saludo devuelve saludo y termina, pero tienda no se destruye: viaja "mochila" adentro de la función devuelta.
  4. Cada llamada posterior a saluda_papyrus(...) encuentra tienda intacta.

Los closures son la memoria de los decoradores: así es como la función envoltorio "recuerda" a qué función envuelve.

Un decorador desde cero, sin azúcar

Un decorador es simplemente una función que recibe una función y devuelve otra que la envuelve. Sin @, a mano:

def anunciar(funcion):
    def envoltorio():
        print(f"→ Llamando a {funcion.__name__}...")
        resultado = funcion()             # la función original, recordada por el closure
        print(f"← {funcion.__name__} terminó.")
        return resultado
    return envoltorio

def abrir_tienda():
    print("Papyrus abre sus puertas.")

abrir_tienda = anunciar(abrir_tienda)     # ¡decorar = REASIGNAR!
abrir_tienda()
→ Llamando a abrir_tienda...
Papyrus abre sus puertas.
← abrir_tienda terminó.

La línea clave es abrir_tienda = anunciar(abrir_tienda): el nombre abrir_tienda deja de apuntar a la función original y pasa a apuntar al envoltorio, que guarda la original en su closure. Nadie tocó el cuerpo de abrir_tienda; solo se le puso una capa alrededor.

La sintaxis @

Como ese patrón de reasignación es tan común, Python le da azúcar sintáctico:

@anunciar
def abrir_tienda():
    print("Papyrus abre sus puertas.")

@anunciar justo encima del def significa exactamente abrir_tienda = anunciar(abrir_tienda) ejecutado tras definir la función. No hay más magia: si entiendes la reasignación, entiendes el @.

*args y **kwargs: envolver cualquier firma

El envoltorio() de arriba no acepta argumentos, así que no puede envolver vender(catalogo, titulo, unidades). La solución es el dúo de M3 *args/**kwargs, que aquí encuentra su uso estrella — recoger lo que sea y pasarlo tal cual:

def anunciar(funcion):
    def envoltorio(*args, **kwargs):
        print(f"→ {funcion.__name__}")
        return funcion(*args, **kwargs)   # reenvía los argumentos intactos
    return envoltorio

Con esto el decorador es universal: sirve para funciones con cero, tres o veinte parámetros, posicionales o con nombre. Es la firma estándar de todo envoltorio.

functools.wraps: no perder la identidad

Hay un efecto secundario sutil. Tras decorar, vender.__name__ ya no es "vender" sino "envoltorio", y su docstring — el contrato que tanto cuidamos en 08-01 — desaparece. Para el logger.exception() de M7, que registra nombres de funciones, esto es un problema real. functools.wraps copia la identidad de la original al envoltorio:

import functools

def anunciar(funcion):
    @functools.wraps(funcion)             # ← un decorador... dentro de tu decorador
    def envoltorio(*args, **kwargs):
        return funcion(*args, **kwargs)
    return envoltorio
Sin @functools.wraps Con @functools.wraps
vender.__name__"envoltorio" vender.__name__"vender"
help(vender) → docstring del envoltorio (o nada) help(vender) → docstring original
Trazas y logs confusos Trazas y logs correctos

Regla sin excepciones: todo decorador lleva @functools.wraps.

Decoradores para Papyrus

@cronometrar: medir cierre_de_caja

import functools
import logging
import time

logger = logging.getLogger(__name__)      # el patrón por módulo de 07-05

def cronometrar(funcion):
    """Registra en el log cuánto tarda cada llamada."""
    @functools.wraps(funcion)
    def envoltorio(*args, **kwargs):
        inicio = time.perf_counter()
        try:
            return funcion(*args, **kwargs)
        finally:                          # se mide incluso si la función lanza (07-02)
            duracion = time.perf_counter() - inicio
            logger.info("%s tardó %.3f s", funcion.__name__, duracion)
    return envoltorio

@cronometrar
def cierre_de_caja(ruta_ventas):
    ...

Tras ejecutar el cierre, en datos/papyrus.log aparece con el formato de M7:

2026-07-13 20:05:12,481 INFO     almacen: cierre_de_caja tardó 0.042 s

El try/finally garantiza la medición aunque cierre_de_caja lance: la misma disciplina de limpieza de 07-02.

@registrar_llamada: auditoría de ventas

Ana quiere poder reconstruir qué se vendió y qué falló. En vez de meter logs dentro de vender(), se envuelve:

def registrar_llamada(funcion):
    """Deja en el log cada llamada, su resultado o su excepción."""
    @functools.wraps(funcion)
    def envoltorio(*args, **kwargs):
        logger.info("llamada: %s args=%r kwargs=%r", funcion.__name__, args, kwargs)
        try:
            resultado = funcion(*args, **kwargs)
            logger.info("ok: %s → %r", funcion.__name__, resultado)
            return resultado
        except Exception:
            logger.exception("fallo en %s", funcion.__name__)   # con traza, como en 07-05
            raise                                               # re-lanzar: el decorador no decide
    return envoltorio

@registrar_llamada
def vender(catalogo, titulo, unidades):
    ...

Una venta de Hamlet x2 deja ok: vender → 20.7; un intento de vender 9 Odiseas con 4 en stock deja la traza completa de StockInsuficienteError. Y atención al raise desnudo: el decorador observa y re-lanza. Atrapar y silenciar aquí violaría la regla de las tres anillas de 07-04 — la decisión sobre la excepción pertenece al mostrador o a la frontera, no al envoltorio.

Decoradores con argumentos: @reintentar(veces=3)

¿Y si guardar el catálogo falla esporádicamente (disco de red, antivirus) y quieres reintentar? Querríamos escribir @reintentar(veces=3). Pero eso significa que reintentar(veces=3) se llama primero y debe devolver un decorador: es una fábrica de decoradores. Tres niveles:

def reintentar(veces: int = 3, pausa: float = 0.5):
    """Fábrica: devuelve un decorador que reintenta hasta `veces` veces."""
    def decorador(funcion):                       # nivel 2: el decorador real
        @functools.wraps(funcion)
        def envoltorio(*args, **kwargs):          # nivel 3: el envoltorio
            for intento in range(1, veces + 1):
                try:
                    return funcion(*args, **kwargs)
                except OSError:                   # solo errores transitorios, no bugs
                    logger.warning("intento %d/%d de %s falló",
                                   intento, veces, funcion.__name__)
                    if intento == veces:
                        raise                     # agotados los intentos, propaga
                    time.sleep(pausa)
        return envoltorio
    return decorador

@reintentar(veces=3)
def guardar_catalogo(catalogo, ruta):
    ...

Lectura en cadena: @reintentar(veces=3) → se ejecuta reintentar(veces=3) → devuelve decoradordecorador(guardar_catalogo) → devuelve envoltorioguardar_catalogo = envoltorio. Cada nivel es un closure: envoltorio recuerda a la vez funcion, veces y pausa. Nota la elección de except OSError: se reintenta lo transitorio; un TypeError es un bug y debe explotar a la primera (07-04).

Los decoradores que ya conocías, con otra luz

Decorador Dónde lo viste Qué hace, ahora que sabes cómo
@property M5 (encapsulación) Envuelve el método para que se acceda como atributo
@dataclass 05-06 Decora la clase: lee sus anotaciones (08-01) y le genera __init__, __repr__, __eq__
@staticmethod / @classmethod M5 Cambian cómo se pasa (o no) self/cls al método
@functools.wraps Esta lección Un decorador que arregla decoradores

Ninguno era magia: todos son "función (o clase) entra, versión envuelta o transformada sale".

No sobredecorar

Una advertencia antes de que decores toda la tienda: cada decorador añade una capa que atravesar al depurar. Si vender lleva @cronometrar, @registrar_llamada y @reintentar, una traza de error muestra tres envoltorios antes de llegar al código real, y el orden de apilamiento empieza a importar (se aplican de abajo arriba). Criterio práctico: decora cuando el comportamiento sea transversal y repetido (logging, medición, reintentos); no decores para lógica de negocio de una sola función — eso va dentro de la función, donde se ve.

Errores Comunes y Consejos

  • Decorar con paréntesis de más o de menos: @cronometrar() falla si cronometrar es un decorador simple, y @reintentar (sin paréntesis) falla si es una fábrica. Regla: fábrica → siempre con paréntesis, aunque uses los valores por defecto (@reintentar()).
  • Olvidar return funcion(...) en el envoltorio: la función decorada pasa a devolver None silenciosamente. Si vender decorada deja de devolver el importe, revisa el envoltorio antes que la función.
  • Olvidar @functools.wraps: los logs de 07-05 empiezan a decir envoltorio en vez de vender y la depuración se vuelve un laberinto.
  • Tragarse excepciones en el decorador: un except Exception: pass en un envoltorio es el "silencio" que 07-04 prohibió, ahora camuflado. Observa, registra con logger.exception, y re-lanza.
  • Consejo: escribe primero el decorador sin @, con la reasignación explícita, hasta que el flujo te resulte obvio. El @ llega solo.
  • Consejo: los envoltorios se benefician de los type hints de 08-01 — def envoltorio(*args, **kwargs) -> ... puede anotarse con precisión usando typing.Callable; guárdalo como lectura opcional, no lo necesitas todavía.

Ejercicios

  1. Escribe @solo_socios, un decorador para funciones cuyo primer argumento es un código de socio ("LUIS-001", "MARTA-002", "PAU-003"). Si el código no está en el conjunto de socios válidos, debe lanzar SocioInvalidoError(codigo) sin llamar a la función; si es válido, la llama normalmente. No olvides functools.wraps.
  2. Sin usar la sintaxis @, decora cierre_de_caja con el cronometrar de la lección mediante reasignación explícita, y explica en un comentario por qué cierre_de_caja.__name__ sigue siendo "cierre_de_caja".
  3. Convierte @cronometrar en una fábrica @cronometrar(umbral=1.0) que solo escriba en el log si la función tardó más de umbral segundos (para no llenar papyrus.log con cierres rápidos).

Soluciones

  1. import functools
    from errores import SocioInvalidoError
    
    SOCIOS_VALIDOS: set[str] = {"LUIS-001", "MARTA-002", "PAU-003"}
    
    def solo_socios(funcion):
        @functools.wraps(funcion)
        def envoltorio(codigo, *args, **kwargs):
            if codigo not in SOCIOS_VALIDOS:
                raise SocioInvalidoError(codigo)   # guard clause de 07-03, ahora reutilizable
            return funcion(codigo, *args, **kwargs)
        return envoltorio
    

    El envoltorio nombra codigo explícitamente (lo necesita para validar) y reenvía el resto con *args, **kwargs.

  2. cierre_de_caja = cronometrar(cierre_de_caja)
    # __name__ sigue siendo "cierre_de_caja" porque el envoltorio lleva
    # @functools.wraps(funcion), que copia __name__ y la docstring de la
    # función original al envoltorio antes de devolverlo.
    
  3. def cronometrar(umbral: float = 0.0):
        def decorador(funcion):
            @functools.wraps(funcion)
            def envoltorio(*args, **kwargs):
                inicio = time.perf_counter()
                try:
                    return funcion(*args, **kwargs)
                finally:
                    duracion = time.perf_counter() - inicio
                    if duracion > umbral:
                        logger.info("%s tardó %.3f s", funcion.__name__, duracion)
            return envoltorio
        return decorador
    
    @cronometrar(umbral=1.0)
    def cierre_de_caja(ruta_ventas): ...
    

    umbral viaja en el closure a través de los tres niveles. Error común aquí: dejar @cronometrar sin paréntesis — ahora es fábrica y hay que llamarla siempre.

Conclusión

Un decorador no tiene nada de mágico: funciones que son objetos, closures que recuerdan, y una reasignación que el @ abrevia. Con @cronometrar, @registrar_llamada y @reintentar(veces=3), Papyrus añade medición, auditoría y resiliencia sin ensuciar vender() ni cierre_de_caja() — y de paso @property y @dataclass dejaron de ser conjuros. Precisamente al cronometrar el cierre aparece la siguiente pregunta: cierre_de_caja() lee ventas.csv entero para sumarlo, y el archivo crece cada día. ¿Hace falta cargar en memoria un año de ventas para sumar las de hoy? No: se pueden producir y consumir las filas bajo demanda, una a una. Ese es el terreno de los generadores y del yield, la próxima lección.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados