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
- Funciones como objetos de primera clase
- Closures: funciones que recuerdan
- Un decorador desde cero, sin azúcar sintáctico
- La sintaxis
@ *argsy**kwargs: envolver cualquier firmafunctools.wraps: no perder la identidad- Decoradores para Papyrus:
@cronometrary@registrar_llamada - Decoradores con argumentos:
@reintentar(veces=3) - Los decoradores que ya conocías, con otra luz
- 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úliaClosures: 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:
- Llamas a
crear_saludo("Papyrus")→ se creatienda = "Papyrus". - Se define
saludo, que mencionatienda— Python anota que la necesita. crear_saludodevuelvesaludoy termina, perotiendano se destruye: viaja "mochila" adentro de la función devuelta.- Cada llamada posterior a
saluda_papyrus(...)encuentratiendaintacta.
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()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 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 envoltorioCon 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 envoltorioSin @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:
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 decorador → decorador(guardar_catalogo) → devuelve envoltorio → guardar_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 sicronometrares 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 devolverNonesilenciosamente. Sivenderdecorada deja de devolver el importe, revisa el envoltorio antes que la función. - Olvidar
@functools.wraps: los logs de 07-05 empiezan a decirenvoltorioen vez devendery la depuración se vuelve un laberinto. - Tragarse excepciones en el decorador: un
except Exception: passen un envoltorio es el "silencio" que 07-04 prohibió, ahora camuflado. Observa, registra conlogger.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 usandotyping.Callable; guárdalo como lectura opcional, no lo necesitas todavía.
Ejercicios
- 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 lanzarSocioInvalidoError(codigo)sin llamar a la función; si es válido, la llama normalmente. No olvidesfunctools.wraps. - Sin usar la sintaxis
@, decoracierre_de_cajacon elcronometrarde la lección mediante reasignación explícita, y explica en un comentario por quécierre_de_caja.__name__sigue siendo"cierre_de_caja". - Convierte
@cronometraren una fábrica@cronometrar(umbral=1.0)que solo escriba en el log si la función tardó más deumbralsegundos (para no llenarpapyrus.logcon cierres rápidos).
Soluciones
-
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 envoltorioEl envoltorio nombra
codigoexplícitamente (lo necesita para validar) y reenvía el resto con*args, **kwargs. -
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. -
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): ...umbralviaja en el closure a través de los tres niveles. Error común aquí: dejar@cronometrarsin 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
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
