Qué le hace realmente un decorador a tu función
Quieres medir el tiempo de cada función que llama a una API externa lenta. Puedes pegar llamadas a time.perf_counter() en cada una, o escribir la medición una sola vez y aplicarla en todas partes. Un decorador es la segunda opción: es una función que toma otra función, la envuelve en un comportamiento adicional y devuelve la versión envuelta con el mismo nombre original.
| |
@timed encima de def fetch_price es la forma corta de fetch_price = timed(fetch_price). Python llama a timed una sola vez, justo después de definir fetch_price, y reasigna el nombre fetch_price a lo que timed devuelve. Eso es todo lo que hace la @.
Cómo funcionan los decoradores: funciones como objetos y clausuras
En Python las funciones son objetos. Puedes pasar una como argumento y devolver otra desde una función, igual que harías con una lista. wrapper es además una clausura: captura func del ámbito que la contiene y sigue usándola cada vez que se ejecuta, mucho después de que timed haya terminado.
Ese es todo el mecanismo. Los decoradores con argumentos, los decoradores basados en clases y los integrados como @property son variaciones de lo mismo.
Cómo functools.wraps conserva el nombre y el docstring de la función
El ejemplo de timed tiene un bug que no se ve hasta que inspeccionas el resultado:
| |
fetch_price perdió su nombre y su docstring, porque lo que se devuelve es wrapper. Cualquier cosa que lea los metadatos de la función ve wrapper en su lugar: help(), los depuradores, los frameworks web que derivan nombres de ruta o de endpoint de la función. Se corrige con functools.wraps:
| |
wraps copia __name__, __doc__, __module__ y __qualname__ (además de __annotations__) del original al wrapper, y añade un atributo __wrapped__ que apunta de vuelta a él. Cuesta una línea. Pon @wraps(func) en cada wrapper que escribas.
Cómo escribir un decorador con argumentos
Un decorador simple toma un solo argumento: la función. En cuanto el decorador necesita su propia configuración, como cuántas veces reintentar y cuánto esperar, añades una capa: una función que toma la configuración y devuelve un decorador.
| |
Léelo de afuera hacia adentro. retry(times=5, delay=2) se ejecuta primero y devuelve decorator. decorator(fetch_price) se ejecuta después y devuelve wrapper. wrapper es lo que se ejecuta en cada llamada a fetch_price(...).
Tres niveles de anidamiento son más difíciles de leer que uno. Si times y delay nunca cambian en tu código, fíjalos y vuelve a la forma de un solo argumento.
Cómo escribir un decorador basado en clase
Cualquier objeto con un método __call__ puede actuar como decorador. Recurre a una clase cuando el decorador tiene que mantener estado entre llamadas, no solo configuración:
| |
@CountCalls sustituye process_order por una instancia de CountCalls, que mantiene calls entre invocaciones. Una clausura con un contador nonlocal podría hacer lo mismo, pero la clase deja el estado explícito y te da un lugar donde poner un método reset().
Dos cosas importan aquí. wraps no se aplica directamente aquí: llama a functools.update_wrapper(self, func) en __init__ si quieres los metadatos. Y esta versión falla con métodos: la instancia no tiene __get__, así que el self del método decorado nunca se pasa. Para decorar métodos, un decorador basado en función es la opción más simple.
Decoradores integrados: @staticmethod, @property y @lru_cache
Los decoradores que trae Python usan el mismo mecanismo, por eso dejan de parecer opacos en cuanto has escrito uno tú mismo:
@staticmethody@classmethodcambian lo que recibe un método como primer argumento: nada extra, o la clase en lugar de la instancia.@propertyconvierte una llamada a método en acceso a un atributo,obj.valueen lugar deobj.value(), pero sigue ejecutando código por debajo.@functools.lru_cache(maxsize=128)devuelve un resultado cacheado cuando la función se llama de nuevo con los mismos argumentos.@functools.cache(Python 3.9+) hace lo mismo sin límite de tamaño. Usacachecuando el conjunto de entradas posibles es pequeño y acotado,lru_cachecuando no lo es.
| |
Sin la caché, fibonacci(35) recalcula los mismos resultados parciales millones de veces. Con ella, cada n se calcula una sola vez.
¿En qué orden se ejecutan los decoradores apilados?
Los decoradores se aplican de abajo hacia arriba y se ejecutan de arriba hacia abajo:
| |
timed envuelve fetch_price primero, y retry envuelve el resultado. Al llamar, el wrapper de retry se ejecuta primero y llama a la versión cronometrada en cada intento. Así timed imprime un número por intento y nunca cuenta la espera entre reintentos. Invierte las dos líneas y timed queda fuera de retry, reportando todo el ciclo de reintentos, esperas incluidas, como un solo número.
¿Cuándo un decorador es la herramienta equivocada?
Los decoradores ocultan el flujo de control. Una función cuyo cuerpo es una sola línea podría abrir una transacción en la base de datos y comprobar un permiso antes de que esa línea se ejecute, y nada en el punto de llamada te lo dice. Esa concesión vale la pena para responsabilidades que atraviesan muchas funciones y deben comportarse igual en todas partes: logging, cronometraje, caché, autenticación. Es un problema cuando el comportamiento pertenece solo a una función. Escríbelo en línea, donde el próximo lector lo verá.
Los stack traces también se vuelven más difíciles de leer. Tres decoradores apilados añaden tres marcos extra entre quien llama y tu código. Si un decorador absorbe excepciones (como retry capturando ConnectionError) o cambia el valor devuelto, dilo en el docstring de la función decorada, que wraps mantiene visible. Quien lea el próximo traceback necesita esa pista.
¿Cuándo deberías extraer un decorador en tu propio código?
La señal es la repetición: las mismas cinco líneas de preparación o limpieza al principio de una tercera función. Extráelas en un decorador antes de escribir la cuarta copia. Si estás experimentando, hazlo en un entorno aislado, como se describe en Entornos Virtuales de Python: venv, pip y uv. Cuando añadas pruebas para el decorador, pruébalo primero contra una función ficticia mínima. La estratificación de Pruebas Unitarias vs de Integración vs End-to-End también aplica aquí, y pocas cosas son tan fáciles de probar a nivel unitario como un decorador.
Un caso en el que deberías detenerte: si lo que quieres inyectar es una dependencia, como una conexión a base de datos o un cliente HTTP configurado, eso no es un comportamiento transversal. Pásala por el constructor, como se muestra en Qué Es la Dependency Injection (con Ejemplos).