AulaML Python · C++
Python avanzado · 42 min · Python avanzado

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.

Lección 2 de 6 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es una función de orden superior?
  2. ¿Para qué sirven los decoradores?
  3. ¿Cómo funciona por dentro? Closures
  4. Sintaxis: la arroba, *args y wraps
  5. Ejemplo básico: una fábrica de funciones
  6. Ejemplo intermedio: medir el tiempo
  7. Caso práctico: registro y reintentos en una descarga
  8. Visualización: quién llama a quién
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. 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:

PYTHON orden_superior.py
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

PYTHON La receta completa
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}"
PiezaQué recibeQué devuelvePara qué
decorarLa función originalLa función envueltaPunto de entrada del decorador
envolturaLos argumentos de la llamadaEl resultado de funcAñade el comportamiento extra
*args, **kwargsCualquier firma—Acepta funciones con 0, 1 o 10 parámetros
@functools.wrapsLa función originalLa envoltura con metadatosConserva __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.

PYTHON multiplicador.py
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.

PYTHON cronometro.py
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.

PYTHON descarga.py
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.

Ejecución visual ·Una función que envuelve a otra
1 / 7
1 def saludar(nombre):
2 return f"Hola, {nombre}"
3
4 def decorar(func):
5 def envoltura(nombre):
6 print("antes")
7 resultado = func(nombre)
8 print("después")
9 return resultado
10 return envoltura
11
12 saludar = decorar(saludar)
13 print(saludar("Ada"))
Se define saludar. Es un objeto función como cualquier otro: todavía no se ejecuta su cuerpo.
Variables
saludar = <function saludar>
Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Errores comunes

Mensaje o síntomaCausaSolución
TypeError: 'NoneType' object is not callableEl decorador no devuelve la envolturaAñade return envoltura al final
TypeError: envoltura() takes 1 positional argument but 2 were givenLa envoltura no acepta los argumentos de la funciónFirma def envoltura(*args, **kwargs) y llama func(*args, **kwargs)
print(f.__name__) muestra envolturaFalta @functools.wraps(func)Decora la envoltura con functools.wraps
Al decorar una función de 0 argumentos todo fallaSe aplicó @decorador() en lugar de @decoradorQuita los paréntesis o implementa el tercer nivel
Un decorador que olvida devolver la función
Incorrecto
PYTHON TypeError al llamar
def decorar(func):
    def envoltura():
        return func()
    # falta: return envoltura

@decorar
def hola():
    return "hola"

print(hola())
Correcto
PYTHON Correcto
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, **kwargs y 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

Ejercicio 1

Contador de llamadas

Escribe un decorador 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.

Ejercicio 2

Decorador con parámetros

Crea @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)].

Completar código Completa el decorador
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ó.
  • @decorar equivale a f = decorar(f) y ocurre al definir, no en cada llamada.
  • *args, **kwargs permiten que la envoltura acepte cualquier firma.
  • functools.wraps conserva __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

Evaluación 1

¿Qué significa exactamente escribir @cronometrar encima de def tarea():?

Evaluación 2

¿Por qué la envoltura se escribe con def envoltura(*args, **kwargs)?

Evaluación 3

Aplicas @registrar sobre @reintentar(3) sobre def descargar(). ¿Qué se ejecuta primero al llamar a descargar()?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.