Decoradores avanzados en Python: paramétricos, decoradores de clase y stacking

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.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP