Cuando el estado de un decorador crece, un closure empieza a sentirse forzado. Python te permite usar cualquier callable como decorador, y una clase con __call__ es exactamente eso: un callable cuyo estado vive de forma natural en atributos de instancia, no en variables capturadas por una función anidada.
La mecánica es directa. Cuando escribes @MiDecorador encima de una función, Python ejecuta MiDecorador(fn) y reemplaza el nombre de la función con el resultado. Si MiDecorador es una clase, ese resultado es una instancia de la clase. Cada vez que llamas a la función decorada, Python invoca instancia.__call__(...). El estado del decorador —contadores, cachés, timestamps— vive en self con toda la claridad de un atributo de instancia.
¿Cuándo merece la pena el cambio? Cuando tienes dos o más variables de estado que interactúan entre sí, cuando necesitas resetear o inspeccionar ese estado desde fuera, o cuando el closure empieza a parecerse a una clase disfrazada de función. Un rate limiter, un contador de llamadas con umbral, una caché con política de expiración: todos ellos son casos donde la clase gana en legibilidad.
Lo que rompe si te equivocas: olvidar @functools.wraps dentro de __call__ (o su equivalente manual) hace que la función decorada pierda su __name__, __doc__ y __wrapped__, lo que rompe herramientas como help(), pytest y cualquier introspección que espere la identidad original.
import functools
import time
from collections import deque
class RateLimiter:
"""Permite como máximo `max_calls` llamadas en una ventana de `period` segundos."""
def __init__(self, max_calls: int, period: float):
self.max_calls = max_calls
self.period = period
self._timestamps: deque[float] = deque()
self._fn = None # se asigna en __call__ durante la decoración
def __call__(self, *args, **kwargs):
# Primera vez que se llama: recibimos la función a decorar
if self._fn is None and len(args) == 1 and callable(args[0]):
fn = args[0]
self._fn = fn
# Preservamos la identidad de la función original
functools.update_wrapper(self, fn)
return self
# Llamadas posteriores: aplicamos la lógica de rate limiting
now = time.monotonic()
# Descartamos timestamps fuera de la ventana
while self._timestamps and now - self._timestamps[0] > self.period:
self._timestamps.popleft()
if len(self._timestamps) >= self.max_calls:
oldest = self._timestamps[0]
wait = self.period - (now - oldest)
raise RuntimeError(
f"Límite alcanzado: {self.max_calls} llamadas en {self.period}s. "
f"Reintenta en {wait:.2f}s."
)
self._timestamps.append(now)
return self._fn(*args, **kwargs)
def reset(self):
"""Permite resetear el estado desde el exterior — algo imposible con un closure."""
self._timestamps.clear()
@RateLimiter(max_calls=3, period=10.0)
def fetch_data(url: str) -> str:
return f"datos de {url}"
# --- Decorador de clase que modifica una CLASE, no una función ---
def add_repr(cls):
"""
Decorador de clase: añade un __repr__ genérico basado en __dict__
si la clase no define uno propio.
"""
if "__repr__" not in cls.__dict__: # __dict__ evita heredar el de object
def __repr__(self):
attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{cls.__name__}({attrs})"
cls.__repr__ = __repr__
return cls
@add_repr
class Config:
def __init__(self, host: str, port: int):
self.host = host
self.port = port
if __name__ == "__main__":
print(fetch_data("https://api.ejemplo.com/datos"))
print(fetch_data("https://api.ejemplo.com/datos"))
print(fetch_data("https://api.ejemplo.com/datos"))
try:
fetch_data("https://api.ejemplo.com/datos") # cuarta llamada: explota
except RuntimeError as e:
print(e)
fetch_data.reset() # reseteamos el estado — llamada 4 ahora es válida
print(fetch_data("https://api.ejemplo.com/datos"))
cfg = Config("localhost", 8080)
print(cfg) # Config(host='localhost', port=8080)
Qué está pasando realmente
RateLimiter tiene un truco de inicialización que vale la pena entender. La clase usa un patrón de doble invocación: @RateLimiter(max_calls=3, period=10.0) primero crea una instancia con la configuración (vía __init__), y luego Python llama a esa instancia con la función como argumento. El __call__ detecta ese primer contacto comprobando self._fn is None. A partir de ese momento, el mismo __call__ sirve como wrapper de cada llamada real.
El estado reside en self._timestamps, una deque que actúa como ventana deslizante: en cada invocación se purgan los timestamps más antiguos que period segundos antes de contar. Esto es O(n) en el peor caso pero n está acotado a max_calls, así que en la práctica es O(1).
El método reset() ilustra la ventaja semántica sobre un closure. Con un closure necesitarías exponer una función auxiliar o usar una lista mutable como hack. Con una clase, reset es un método de primera clase que cualquier consumidor puede llamar con fetch_data.reset(), porque fetch_data es la instancia del decorador.
functools.update_wrapper(self, fn) copia __name__, __qualname__, __doc__, __dict__ y __wrapped__ desde fn hacia la instancia del decorador. Es el equivalente de @functools.wraps pero aplicado a instancias en lugar de funciones internas.
El decorador add_repr funciona de forma completamente distinta: recibe una clase, la modifica directamente y devuelve la misma clase. Fíjate en cls.__dict__ en lugar de hasattr: hasattr encontraría el __repr__ heredado de object, mientras que cls.__dict__ solo mira lo que la clase define explícitamente. Es un detalle pequeño con un impacto real: si una subclase ya define su propio __repr__, no lo pisamos.
Errores que debes conocer
Error: usar @RateLimiter sin paréntesis cuando el __init__ espera argumentos hace que Python pase la función como max_calls, lo que provoca un TypeError críptico en tiempo de decoración.
# ❌ Wrong
@RateLimiter # Python llama RateLimiter(fetch_data) → max_calls recibe una función
def fetch_data(url):
...
# ✅ Right
@RateLimiter(max_calls=3, period=10.0)
def fetch_data(url):
...
Los paréntesis crean la instancia primero; sin ellos la clase recibe la función directamente en __init__.
Error: olvidar functools.update_wrapper (o no llamarlo antes de devolver self) hace que fetch_data.__name__ sea "RateLimiter" y que herramientas de introspección fallen de formas no obvias.
# ❌ Wrong — __call__ devuelve self sin actualizar los metadatos
def __call__(self, *args, **kwargs):
if self._fn is None and callable(args[0]):
self._fn = args[0]
return self # los metadatos de la función original se pierden
# ✅ Right
def __call__(self, *args, **kwargs):
if self._fn is None and callable(args[0]):
self._fn = args[0]
functools.update_wrapper(self, args[0]) # copia __name__, __doc__, __wrapped__
return self
update_wrapper necesita ejecutarse antes de que la instancia reemplace a la función en el namespace del módulo.
Error: compartir estado mutable entre instancias al definir atributos de clase en lugar de atributos de instancia.
# ❌ Wrong — todos los decoradores comparten el mismo deque
class RateLimiter:
_timestamps = deque() # atributo de clase: compartido entre todas las instancias
# ✅ Right
class RateLimiter:
def __init__(self, max_calls, period):
self._timestamps = deque() # atributo de instancia: aislado por decorador
Cada función decorada necesita su propia ventana de tiempo; si el deque es de clase, decorar dos funciones distintas las hace competir por el mismo límite.
N° 119