Decoradores en Python: desde lo básico hasta con parámetros
1. Descripción General
Los decoradores son una de las características más “mágicas” de Python, pero también pueden causar confusión si solo copias y pegas sin entender el concepto subyacente. Este artículo comienza con el concepto de que las funciones son first-class citizens, pasa por decoradores básicos, decoradores que preservan metadatos con functools.wraps, hasta decoradores que aceptan parámetros y decoradores basados en clases. Después de leer esto, serás capaz de escribir tus propios decoradores de logging, timing o retry para tu proyecto sin “perder el nombre de la función original”.
2. Las Funciones son First-Class Citizens
Antes de aprender sobre decoradores, necesitas entender un hecho: en Python, las funciones también son objetos. Puedes asignar funciones a variables, pasar funciones como parámetros o devolver funciones desde otras funciones.
def greet(name: str) -> str:
return f"Hello, {name}!"
say_hello = greet
print(say_hello("Alice"))
Hello, Alice!
say_hello es solo otro nombre que apunta al mismo objeto función greet. Gracias a esta característica, Python permite escribir funciones que reciben funciones como entrada y devuelven funciones como salida — esa es la base de los decoradores.
3. Decorador Básico: Función Envolviendo Función
Un decorador es simplemente una función que recibe otra función, y devuelve una nueva función que “envuelve” (wrap) el comportamiento de la función original.
def log_call(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
def add(a: int, b: int) -> int:
return a + b
add = log_call(add)
print(add(2, 3))
Calling add with args=(2, 3), kwargs={}
add returned 5
5
log_callrecibefunc(la función originaladd) y definewrapper— una función interna que llama afuncnuevamente pero con log antes/después.log_call(add)devuelvewrapper, y lo reasignamos aadd. De ahora en adelante, llamar aadd(...)es en realidad llamar awrapper(...).
3.1. Sintaxis @ — Azúcar Sintáctico
Escribir func = decorator(func) manualmente es bastante verboso. Python proporciona la sintaxis @decorator para hacer exactamente lo mismo, de forma más concisa:
@log_call
def multiply(a: int, b: int) -> int:
return a * b
print(multiply(4, 5))
Calling multiply with args=(4, 5), kwargs={}
multiply returned 20
20
@log_call colocado justo encima de def multiply es equivalente a multiply = log_call(multiply). Esto es solo azúcar sintáctico — no hay magia oculta.
4. El Problema de Perder Metadatos y functools.wraps
Cuando un decorador “reemplaza” la función original con wrapper, pierdes atributos como __name__ y __doc__ de la función original.
@log_call
def subtract(a: int, b: int) -> int:
"""Restar dos números."""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
wrapper
None
Este es un error común al debuggear o usar herramientas de generación de documentación — crees que estás llamando subtract pero Python te dice que el nombre es wrapper. Soluciona esto con functools.wraps:
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def subtract(a: int, b: int) -> int:
"""Restar dos números."""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
subtract
Restar dos números.
functools.wraps(func) es un decorador de decoradores: copia __name__, __doc__, __module__… desde func hacia wrapper.
Nota: Siempre usa
functools.wrapsen decoradores reales. Omitirlo causará dificultades para debuggear y romperá introspection (por ejemplohelp(), frameworks que se basan en nombres de funciones).
5. Decoradores que Aceptan Parámetros
A menudo quieres que tu decorador sea personalizable, por ejemplo @retry(times=3). Para esto necesitas agregar una capa adicional de función: la función externa recibe los parámetros y devuelve el decorador real, que es el que recibe la func.
import functools
import time
def retry(times: int = 3, delay: float = 1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exception = exc
print(f"Attempt {attempt} failed: {exc}")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(times=2, delay=0)
def call_payment_api(order_id: int) -> str:
raise ConnectionError(f"Timeout for order {order_id}")
try:
call_payment_api(101)
except ConnectionError as exc:
print(f"Giving up: {exc}")
Attempt 1 failed: Timeout for order 101
Attempt 2 failed: Timeout for order 101
Giving up: Timeout for order 101
La estructura de tres capas que debes recordar:
retry(times, delay)— función factory, recibe parámetros de configuración, devuelve eldecorator.decorator(func)— la función decoradora real, recibe la función original.wrapper(*args, **kwargs)— la función que se ejecuta cuando se llama la función decorada.
@retry(times=2, delay=0) en realidad llama primero a retry(times=2, delay=0) (que devuelve decorator), y luego aplica decorator a call_payment_api.
5.1. Decorador que Funciona Con y Sin Parámetros
A veces quieres que @retry funcione tanto sin parámetros (@retry en lugar de @retry()) como con ellos. Una forma limpia es usar functools.partial combinado con verificación:
import functools
def retry(func=None, *, times: int = 3):
if func is None:
return functools.partial(retry, times=times)
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
print(f"Attempt {attempt} failed: {exc}")
raise RuntimeError("All attempts failed")
return wrapper
@retry
def task_a():
return "ok"
@retry(times=5)
def task_b():
return "ok"
Cuando usas @retry (sin llamar a la función), func recibe directamente la función task_a, la rama if func is None no se ejecuta. Cuando usas @retry(times=5), func es None inicialmente, la función devuelve functools.partial(retry, times=5) — una función esperando recibir func en el siguiente paso, que es task_b.
6. Decoradores Escritos como Clase
En lugar de usar funciones anidadas, puedes escribir un decorador como una clase, implementando __call__. Este enfoque es útil cuando el decorador necesita mantener un estado complejo.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"{self.func.__name__} called {self.call_count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def process_order(order_id: int) -> None:
print(f"Processing order {order_id}")
process_order(1)
process_order(2)
print(process_order.call_count)
process_order called 1 time(s)
Processing order 1
process_order called 2 time(s)
Processing order 2
2
__init__guarda la función original e inicializacall_count = 0.functools.update_wrapper(self, func)— equivalente afunctools.wrapspero para instancias de clase, copia metadatos al objetoself.__call__hace que la instanciaprocess_order(que ahora es un objetoCountCalls, ya no una función) sea invocable como una función normal.
Nota: Los decoradores basados en clases no funcionan bien cuando decoran métodos en otras clases (porque
selfdel decorador y la clase original pueden entrar en conflicto). Con métodos, prefiere funciones anidadas ofunctools.singledispatchmethodsi necesitas dispatch.
7. Encadenar Múltiples Decoradores (Stacking)
Puedes apilar múltiples decoradores en una función. El orden de aplicación es de abajo hacia arriba (el decorador más cercano a la función se ejecuta primero).
@log_call
@retry(times=2, delay=0)
def charge_card(amount: float) -> str:
if amount <= 0:
raise ValueError("Amount must be positive")
return f"Charged {amount}"
print(charge_card(10))
Calling wrapper
Charged 10
Aquí retry(times=2, delay=0) se aplica primero, envolviendo charge_card en una función con capacidad de reintentos; luego log_call lo envuelve nuevamente. Si no usas functools.wraps correctamente en retry, verías que se imprime wrapper en lugar de charge_card — otra prueba de la importancia de la sección 4.
8. Escribir Pruebas para Decoradores
Los decoradores siguen siendo funciones Python normales, así que puedes testearlas con pytest como cualquier otra lógica. Instala la librería si aún no la tienes:
pip install pytest
Ejemplo de test para el decorador retry:
import pytest
def test_retry_raises_after_exhausting_attempts():
call_log = []
@retry(times=3, delay=0)
def always_fail():
call_log.append("called")
raise ValueError("boom")
with pytest.raises(ValueError):
always_fail()
assert len(call_log) == 3
def test_retry_succeeds_on_second_attempt():
attempts = {"count": 0}
@retry(times=3, delay=0)
def fail_once_then_succeed():
attempts["count"] += 1
if attempts["count"] < 2:
raise ValueError("boom")
return "ok"
result = fail_once_then_succeed()
assert result == "ok"
assert attempts["count"] == 2
Ejecuta las pruebas:
pytest test_retry.py -v
test_retry.py::test_retry_raises_after_exhausting_attempts PASSED
test_retry.py::test_retry_succeeds_on_second_attempt PASSED
call_log y attempts actúan como espías para verificar cuántas veces se llamó la función original — verificando el comportamiento esperado del retry, sin necesidad de usar mock de librerías externas.
9. Errores Comunes
- Olvidar
functools.wraps: la función pierde su nombre/docstring, causando dificultades para debuggear y errores cuando se usa con frameworks que se basan en__name__(por ejemplo routing en Flask). - Usar argumentos por defecto mutables en wrapper: igual que el error común de Python, evita tener
args=[]como default. - Decoradores con side-effects pesados en tiempo de import: si el decorador realiza I/O (llama API, lee archivo) directamente en
decorator(func)en lugar de enwrapper, se ejecutará cada vez que el módulo se importe, no cada vez que se llame la función. - Abusar de decoradores para lógica compleja: si el decorador contiene demasiadas ramas, el código es más difícil de leer que una función que llama directamente. Cuando la lógica es compleja, considera context managers o separa en una clase dedicada en lugar de meter todo en el decorador.
Cuándo NO debes usar decoradores: si el comportamiento “envolvente” solo se usa una vez, no es reutilizable, escribir un decorador solo hace el código más difícil de rastrear que llamar a la función directamente. Los decoradores brillan cuando aplicas lógica transversal (cross-cutting concern) como logging, caching, retry, autenticación a muchas funciones diferentes.
10. Conclusión
Los decoradores en Python no tienen nada de misterioso — son simplemente funciones que reciben funciones y devuelven funciones, basándose en el hecho de que las funciones son first-class citizens. Comenzando con un decorador básico, añades functools.wraps para preservar metadatos, agregas una capa de función para aceptar parámetros de configuración, o cambias a una versión basada en clase cuando necesitas mantener un estado complejo. Domina estos patrones y serás capaz de escribir decoradores limpios y testables de logging, caching, retry o validación para tu proyecto real.