Closures y decoradores
Envolver funciones para añadir comportamiento sin tocar su código: caché, tiempos, logs y reintentos
Un decorador es una función que recibe otra función y devuelve una versión envuelta. Detrás hay un closure: un paquete de código más las variables que necesitaba. Aprenderás a escribirlos, a conservar los metadatos con functools.wraps y a medir tiempos o registrar llamadas sin repetir código.
- Explicar por qué las funciones son objetos y qué permite eso
- Describir un closure y comprobar qué variables captura
- Escribir decoradores con *args y **kwargs que acepten cualquier firma
- Conservar nombre y documentación con functools.wraps
- Crear decoradores con parámetros y medir el tiempo de una función
Antes necesitas: Funciones · *args, **kwargs y ámbito de variables
En esta lección
- ¿Qué es una función de orden superior?
- ¿Para qué sirven los decoradores?
- ¿Cómo funciona por dentro? Closures
- Sintaxis: la arroba, *args y wraps
- Ejemplo básico: una fábrica de funciones
- Ejemplo intermedio: medir el tiempo
- Caso práctico: registro y reintentos en una descarga
- Visualización: quién llama a quién
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
En Python una función no es solo un bloque de código con nombre: es un objeto como un número o una lista. Puedes guardarla en una variable, meterla en un diccionario, pasarla como argumento y devolverla desde otra función. Esa libertad es la que hace posibles los decoradores.
Los has usado sin saberlo cada vez que escribiste @property, @dataclass o @functools.lru_cache. Detrás de esa arroba hay una idea sencilla y muy potente: envolver una función con otra para añadir comportamiento —medir, registrar, cachear, reintentar— sin ensuciar el cuerpo de la original.
¿Qué es una función de orden superior?
Una función de orden superior es la que recibe una función como argumento, devuelve una función, o ambas cosas. map, filter, sorted(key=...) y max(key=...) ya lo son. Escribirlas tú es el primer paso hacia los decoradores:
def aplicar(func, valores):
return [func(valor) for valor in valores]
print(aplicar(abs, [-3, 4, -5]))
print(aplicar(str.upper, ["ana", "luis"]))abs y str.upper se pasan sin paréntesis: no estás llamándolas, estás entregando el objeto función. Si escribieras abs(), Python la ejecutaría y pasaría su resultado, que no es lo que quieres. Esta confusión entre f y f() es el error número uno al empezar con este tema.
¿Para qué sirven los decoradores?
- Medir: cuánto tarda una función o un pipeline, sin llenar el código de
perf_counter. - Registrar: dejar traza de qué se llamó, con qué argumentos y qué devolvió.
- Cachear: recordar resultados costosos, como hace
functools.lru_cache. - Reintentar: repetir una descarga o una consulta que falla por red.
- Validar o autorizar: comprobar permisos o argumentos antes de ejecutar la función real.
La ventaja no es ahorrar líneas, es separar responsabilidades. La función descargar se ocupa de descargar; el decorador se ocupa de reintentar si falla. Si mañana cambias la política de reintentos, tocas un solo sitio y todas las funciones decoradas se benefician.
¿Cómo funciona por dentro? Closures
Una clausura (closure) es una función interna que usa variables de la función que la contiene, y que sigue teniendo acceso a ellas después de que la externa haya terminado. Normalmente las variables locales mueren al salir de la función; el closure las mantiene vivas porque las necesita.
Python guarda esas variables en celdas y las enlaza al atributo __closure__ de la función interna. No guarda una copia del valor: guarda una referencia a la variable. Por eso, si la variable externa cambia después, el closure ve el valor nuevo. Esa unión de código y entorno es exactamente lo que un decorador necesita para recordar la función que envuelve.
Un decorador, entonces, no es magia: es una función de orden superior que recibe func, define dentro una envoltura que llama a func y devuelve esa envoltura. El @ es solo azúcar para reasignar el nombre. @decorar sobre def f(): equivale a f = decorar(f), y se aplica una vez, cuando se define la función, no en cada llamada.
Sintaxis: la arroba, *args y wraps
import functools
def decorar(func):
@functools.wraps(func)
def envoltura(*args, **kwargs):
# ... antes de la llamada
resultado = func(*args, **kwargs)
# ... después de la llamada
return resultado
return envoltura
@decorar
def saludar(nombre):
return f"Hola, {nombre}"| Pieza | Qué recibe | Qué devuelve | Para qué |
|---|---|---|---|
decorar | La función original | La función envuelta | Punto de entrada del decorador |
envoltura | Los argumentos de la llamada | El resultado de func | Añade el comportamiento extra |
*args, **kwargs | Cualquier firma | — | Acepta funciones con 0, 1 o 10 parámetros |
@functools.wraps | La función original | La envoltura con metadatos | Conserva __name__, __doc__ y __wrapped__ |
*args recoge los posicionales en una tupla y **kwargs los nombrados en un diccionario; al llamar a func(*args, **kwargs) los vuelves a repartir. Sin ellos, un decorador escrito para f() rompe cualquier función que reciba argumentos con TypeError.
Para un decorador con parámetros hacen falta tres niveles: el primero recibe el parámetro (@repetir(3)), el segundo la función y el tercero los argumentos de la llamada. Se lee de dentro hacia fuera y se aplica al definir.
Ejemplo básico: una fábrica de funciones
El caso más pequeño de closure: una función que devuelve otra función recordando un valor.
def multiplicador(factor):
def aplicar(x):
return x * factor # "factor" vive en el closure
return aplicar
doble = multiplicador(2)
triple = multiplicador(3)
print(doble(10), triple(10))
print(doble.__closure__[0].cell_contents)Cada llamada a multiplicador crea un closure independiente: doble guarda factor = 2 y triple guarda factor = 3, aunque la función multiplicador ya terminó. La última línea inspecciona la celda y muestra 2. Esa es la prueba de que el valor no se copió al llamar, sino que quedó enlazado.
Ejemplo intermedio: medir el tiempo
Medir es el decorador más útil para empezar, porque el código de alrededor no cambia y el resultado se ve al instante. Usa time.perf_counter, que es monótono y de alta resolución; time.time puede saltar si el reloj del sistema se ajusta.
import functools
import time
def cronometrar(func):
@functools.wraps(func)
def envoltura(*args, **kwargs):
inicio = time.perf_counter()
resultado = func(*args, **kwargs)
duracion = time.perf_counter() - inicio
print(f"{func.__name__}: {duracion:.4f} s")
return resultado
return envoltura
@cronometrar
def sumar_cuadrados(n):
return sum(i * i for i in range(n))
print(sumar_cuadrados(200_000))
print(sumar_cuadrados.__name__)La salida muestra el tiempo —que cambia en cada ejecución— y después el resultado 2666646666700000. La última línea imprime sumar_cuadrados, no envoltura: eso lo consigue functools.wraps, que copia nombre, documentación y anotaciones de la función original y deja la original accesible en __wrapped__. Sin wraps, las trazas de error y la ayuda apuntarían a envoltura, y depurar se vuelve más difícil.
Fíjate también en que el cronómetro mide la llamada completa, incluido el sum perezoso: si func devolviera un generador sin consumirlo, el tiempo medido sería casi cero y el trabajo ocurriría fuera del decorador. Mide lo que de verdad se ejecuta dentro.
Caso práctico: registro y reintentos en una descarga
Un pipeline que descarga datos de una API necesita dos cosas que no son responsabilidad de la descarga: dejar rastro de las llamadas y sobrevivir a fallos temporales. Dos decoradores, apilados, resuelven ambas.
import functools
def registrar(func):
@functools.wraps(func)
def envoltura(*args, **kwargs):
print(f"-> {func.__name__}{args}")
resultado = func(*args, **kwargs)
print(f"<- {func.__name__} = {resultado}")
return resultado
return envoltura
def reintentar(veces):
def decorador(func):
@functools.wraps(func)
def envoltura(*args, **kwargs):
for intento in range(1, veces + 1):
try:
return func(*args, **kwargs)
except ValueError as error:
print(f"intento {intento} falló: {error}")
raise RuntimeError(f"{func.__name__} falló {veces} veces")
return envoltura
return decorador
intentos = {"n": 0}
@registrar
@reintentar(3)
def descargar(url):
intentos["n"] += 1
if intentos["n"] < 3:
raise ValueError("timeout")
return f"datos de {url}"
print(descargar("api/ventas"))La salida es: -> descargar('api/ventas',), dos líneas intento 1 falló: timeout e intento 2 falló: timeout, luego <- descargar = datos de api/ventas y finalmente datos de api/ventas. Solo se registra una llamada porque registrar está por fuera: envuelve al descargar que ya devolvió reintentar. Los decoradores se aplican de abajo hacia arriba.
El estado compartido vive en el diccionario intentos, definido en el módulo. Si lo hubieras puesto como valor por defecto o dentro del decorador, cada ejecución empezaría de cero. Y el raise RuntimeError final es importante: agotar los reintentos no puede terminar devolviendo None en silencio, porque el error se manifestaría mucho más lejos.
Visualización: quién llama a quién
Sigue la pila de llamadas. Verás cómo saludar deja de apuntar a la función original después de decorarla y cómo la envoltura ocupa su lugar.
def saludar(nombre): return f"Hola, {nombre}"def decorar(func): def envoltura(nombre): print("antes") resultado = func(nombre) print("después") return resultado return envolturasaludar = decorar(saludar)print(saludar("Ada"))saludar. Es un objeto función como cualquier otro: todavía no se ejecuta su cuerpo.—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Errores comunes
| Mensaje o síntoma | Causa | Solución |
|---|---|---|
TypeError: 'NoneType' object is not callable | El decorador no devuelve la envoltura | Añade return envoltura al final |
TypeError: envoltura() takes 1 positional argument but 2 were given | La envoltura no acepta los argumentos de la función | Firma def envoltura(*args, **kwargs) y llama func(*args, **kwargs) |
print(f.__name__) muestra envoltura | Falta @functools.wraps(func) | Decora la envoltura con functools.wraps |
| Al decorar una función de 0 argumentos todo falla | Se aplicó @decorador() en lugar de @decorador | Quita los paréntesis o implementa el tercer nivel |
def decorar(func):
def envoltura():
return func()
# falta: return envoltura
@decorar
def hola():
return "hola"
print(hola())def decorar(func):
def envoltura():
return func()
return envoltura
@decorar
def hola():
return "hola"
print(hola())El decorador devuelve `None` implícitamente, así que `hola` deja de ser una función y pasa a ser `None`. El error aparece en la llamada, lejos de la causa. Devuelve siempre la envoltura.
Buenas prácticas
- Decora siempre con
@functools.wraps(func): cuesta una línea y salva las trazas. - Acepta
*args, **kwargsy reenvíalos tal cual; no adivines la firma de la función envuelta. - Devuelve el resultado de
func; un decorador que no devuelve nada rompe a quien lo usa. - Un decorador debe hacer una cosa: medir, o registrar, o reintentar. Si hace tres, sepáralos.
- No escondas excepciones dentro del decorador salvo que sea su propósito explícito (reintentar).
- Comprueba el orden al apilar: el de abajo envuelve primero y el de arriba envuelve al resultado.
- Si el decorador necesita configuración, usa un tercer nivel con parámetros y valores por defecto.
Ejercicios
Contador de llamadas
contar_llamadas que lleve la cuenta de cuántas veces se invoca cada función y lo muestre antes del resultado. El contador debe sobrevivir entre llamadas. Aplícalo a una función saludar(nombre) y comprueba el total al final.Pista
Puedes guardar el contador como atributo de la propia envoltura (envoltura.llamadas += 1) inicializándolo en cero antes de devolverla.
Decorador con parámetros
@repetir(n), un decorador que llame a la función n veces y devuelva la lista de resultados. Úsalo con una función sin argumentos y con otra que reciba uno. Después prueba @repetir(0) y explica qué devuelve.Pista
Necesitas tres niveles: repetir(veces) devuelve decorador, que recibe func y devuelve envoltura. Usa una comprensión de lista [func(...) for _ in range(veces)].
import functools def saludar(func): @functools.(func) def envoltura(*args, **kwargs): print("inicio") return (args, **kwargs) return envoltura
`wraps` copia los metadatos de la función original. Dentro de la envoltura, la función que hay que llamar se llama `func`.
Soluciones explicadas
En el contador, la decisión de guardar el estado como atributo de la envoltura evita dos problemas: las variables globales (que se comparten entre módulos) y el cierre sobre un entero (que en Python no se puede reasignar desde la función interna sin nonlocal). El atributo es explícito, visible desde fuera y propio de cada función decorada.
En repetir, lo importante es entender la evaluación: @repetir(3) no decora con repetir, decora con el resultado de repetir(3). Por eso el primer nivel no recibe la función, sino el parámetro. Si intentas @repetir sin paréntesis, Python pasa la función como veces y el decorador devuelve un decorador que nunca se aplica.
Como alternativa, un decorador con estado se puede escribir como clase con __call__: el __init__ guarda la configuración y __call__ envuelve. Es más verboso, pero útil cuando hay mucho estado que gestionar. Para medir tiempos o registrar llamadas, la función con closure es más directa.
Resumen
- Las funciones son objetos: se pasan y se devuelven sin paréntesis.
- Una función de orden superior recibe o devuelve funciones; un decorador es una de ellas.
- Un closure conserva variables del ámbito donde nació, aunque ese ámbito ya terminó.
@decorarequivale af = decorar(f)y ocurre al definir, no en cada llamada.*args, **kwargspermiten que la envoltura acepte cualquier firma.functools.wrapsconserva__name__y__doc__, y expone la original en__wrapped__.- Un decorador con parámetros necesita tres niveles; se aplica de abajo hacia arriba.
Evaluación
¿Qué significa exactamente escribir @cronometrar encima de def tarea():?
¿Por qué la envoltura se escribe con def envoltura(*args, **kwargs)?
Aplicas @registrar sobre @reintentar(3) sobre def descargar(). ¿Qué se ejecuta primero al llamar a descargar()?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.