Un decorador básico envuelve una función en otra. Pero hay patrones más complejos que aparecen en librerías reales: decoradores que aceptan parámetros, decoradores implementados como clases con __call__, y el apilado de varios decoradores sobre la misma función. Dominarlos permite escribir middleware, sistemas de retry, logging y validación de forma limpia.
Repaso: decorador simple con @wraps
from functools import wraps
import time
def cronometrar(func):
@wraps(func) # preserva __name__ y __doc__
def wrapper(*args, **kwargs):
inicio = time.perf_counter()
resultado = func(*args, **kwargs)
print(f"{func.__name__} tardó {time.perf_counter() - inicio:.4f}s")
return resultado
return wrapper
@cronometrar
def calcular(n: int) -> int:
return sum(range(n))
print(calcular(1_000_000)) # calcular tardó 0.0123s
print(calcular.__name__) # calcular (no wrapper, gracias a @wraps)
Decoradores con parámetros: tres niveles de anidamiento
Un decorador parametrizado requiere una función extra que recibe los parámetros y devuelve el decorador real. Son tres niveles: función de configuración ? decorador ? wrapper.
from functools import wraps
import time
def reintentar(max_intentos: int = 3, delay: float = 1.0, excepciones=(Exception,)):
"""Decorador que reintenta la función si lanza una excepción."""
def decorador(func):
@wraps(func)
def wrapper(*args, **kwargs):
for intento in range(1, max_intentos + 1):
try:
return func(*args, **kwargs)
except excepciones as e:
if intento == max_intentos:
raise
print(f"[{func.__name__}] Intento {intento} fallido: {e}. Reintentando en {delay}s...")
time.sleep(delay)
return wrapper
return decorador
@reintentar(max_intentos=3, delay=0.5, excepciones=(ConnectionError,))
def llamar_api(url: str) -> dict:
import random
if random.random() < 0.7:
raise ConnectionError(f"No se pudo conectar a {url}")
return {"status": "ok"}
from functools import wraps
def validar_tipos(**tipos_esperados):
"""Decorador que valida el tipo de los argumentos por nombre."""
def decorador(func):
@wraps(func)
def wrapper(*args, **kwargs):
import inspect
params = list(inspect.signature(func).parameters.keys())
todos = dict(zip(params, args))
todos.update(kwargs)
for nombre, tipo in tipos_esperados.items():
if nombre in todos and not isinstance(todos[nombre], tipo):
raise TypeError(f"'{nombre}' debe ser {tipo.__name__}, no {type(todos[nombre]).__name__}")
return func(*args, **kwargs)
return wrapper
return decorador
@validar_tipos(nombre=str, edad=int)
def crear_usuario(nombre: str, edad: int) -> dict:
return {"nombre": nombre, "edad": edad}
crear_usuario("Ana", 30) # OK
# crear_usuario("Ana", "30") # TypeError
Stacking: apilar varios decoradores
Cuando apilias decoradores, se aplican de abajo a arriba: el más cercano a la función se aplica primero.
from functools import wraps
def log_entrada(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"? Llamando a {func.__name__} con args={args}, kwargs={kwargs}")
return func(*args, **kwargs)
return wrapper
def log_salida(func):
@wraps(func)
def wrapper(*args, **kwargs):
resultado = func(*args, **kwargs)
print(f"? {func.__name__} devolvió: {resultado}")
return resultado
return wrapper
def cronometrar(func):
@wraps(func)
def wrapper(*args, **kwargs):
import time
inicio = time.perf_counter()
r = func(*args, **kwargs)
print(f"? {func.__name__}: {time.perf_counter() - inicio:.4f}s")
return r
return wrapper
@log_entrada # se aplica tercero (más exterior)
@log_salida # se aplica segundo
@cronometrar # se aplica primero (más cercano a la función)
def procesar(dato: int) -> int:
return dato * 2
procesar(5)
# ? Llamando a procesar con args=(5,), kwargs={}
# ? procesar: 0.0000s
# ? procesar devolvió: 10
Decorador como clase con __call__
Implementar un decorador como clase permite mantener estado entre llamadas, algo que la función anidada no puede hacer limpiamente.
import time
from functools import wraps
class LimitadorLlamadas:
"""Limita cuántas veces por segundo puede llamarse una función."""
def __init__(self, max_por_segundo: int):
self.max_por_segundo = max_por_segundo
self.intervalo = 1.0 / max_por_segundo
self._ultima_llamada = 0.0
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
ahora = time.perf_counter()
transcurrido = ahora - self._ultima_llamada
if transcurrido < self.intervalo:
time.sleep(self.intervalo - transcurrido)
self._ultima_llamada = time.perf_counter()
return func(*args, **kwargs)
return wrapper
@LimitadorLlamadas(max_por_segundo=2)
def llamar_api(endpoint: str) -> str:
return f"respuesta de {endpoint}"
# Máximo 2 llamadas por segundo
for i in range(4):
print(llamar_api(f"/endpoint/{i}"))
Decorador con estado usando clase como wrapper
class ContadorLlamadas:
"""Decorador que cuenta cuántas veces se ha llamado la función."""
def __init__(self, func):
wraps(func)(self) # copia metadatos
self._func = func
self.llamadas = 0
def __call__(self, *args, **kwargs):
self.llamadas += 1
return self._func(*args, **kwargs)
@ContadorLlamadas
def saludar(nombre: str) -> str:
return f"Hola, {nombre}"
saludar("Ana")
saludar("Luis")
print(saludar.llamadas) # 2
print(saludar.__name__) # saludar
La regla para elegir la forma: usa funciones anidadas para decoradores sin estado y sin parámetros; añade un nivel más de anidamiento cuando el decorador necesite configuración; usa clases cuando necesites mantener estado entre llamadas (contadores, cachés, rate limiters). Siempre incluye @wraps para que las herramientas de introspección y debuggers vean el nombre correcto.
