Clase 6d — Lambda functions y decoradores
Una función lambda
es una forma compacta de definir
una función sin nombre —
una función anónima.
Es exactamente equivalente a un
def pero escrita
en una sola línea y sin
return explícito:
la expresión se devuelve
automáticamente.
No es un mecanismo nuevo —
es azúcar sintáctico para
el caso muy frecuente de necesitar
una función simple de un solo uso
que no merece un nombre propio.
La IA la usa constantemente
en sorted,
map, filter
y df.apply().
Saber leerla es imprescindible.
— Sintaxis —
lambda
argumentos
:
expresión
argumentos:
los mismos que en un def
— puede haber cero, uno o varios,
con o sin valores por defecto.
expresión:
exactamente una expresión
cuyo resultado se devuelve.
No puede contener
if en bloque,
for, print,
asignaciones ni múltiples líneas.
# def vs lambda — equivalentes exactos
# Con def
def suma_def(x, y):
return x + y
# Con lambda
suma_lambda = lambda x, y: x + y
# Mismo resultado
print(suma_def(3, 4)) # 7
print(suma_lambda(3, 4)) # 7
print("---")
# Sin argumentos
saludar = lambda: "Hola"
print(saludar()) # "Hola"
# Un argumento
cuadrado = lambda x: x ** 2
print(cuadrado(5)) # 25
# Con valor por defecto
potencia = lambda x, n=2: x ** n
print(potencia(3)) # 9 — n=2 por defecto
print(potencia(3, 3)) # 27 — n=3
print("---")
# Llamada directa sin asignar a variable
resultado = (lambda x, y: x + y)(3, 4)
print(resultado) # 7
# Lambda con condicional — ternario, no if en bloque
es_par = lambda x: "par" if x % 2 == 0 else "impar"
print(es_par(4)) # "par"
print(es_par(7)) # "impar"
— Por qué existe: no contaminar el espacio de nombres —
Cuando defines una función con
def, el nombre
queda registrado en el scope
como una variable más.
Si la función solo se usa una vez
— como argumento de
sorted o map —
ese nombre es innecesario.
Una lambda evita crear un nombre
que no se volverá a usar.
Dicho esto: si la misma operación
aparece en varios sitios,
es mejor definirla con
def y darle un nombre.
Una lambda repetida es una señal
de que debería ser una función con nombre.
# Con def — nombre innecesario si solo se usa una vez
def por_segundo(tupla):
return tupla[1]
pares = [(3, 2), (1, 7), (2, 4)]
pares_ordenados = sorted(pares, key=por_segundo)
print(pares_ordenados)
# Con lambda — más conciso, sin nombre innecesario
pares_ordenados = sorted(pares, key=lambda x: x[1])
print(pares_ordenados)
print("---")
# Si la operación se reutiliza — mejor def con nombre
nombres = ["ana", "sofía", "lucía", "eva"]
print(sorted(nombres, key=lambda x: len(x)))
# Equivalente con def — más legible si la clave es compleja
def longitud(s):
return len(s)
print(sorted(nombres, key=longitud))
# Para este caso: sorted(nombres, key=len) es lo más limpio
— Lo que lambda no puede hacer —
Una lambda es deliberadamente
limitada — solo puede contener
una expresión.
Esta restricción existe para
evitar que las lambdas se conviertan
en funciones complejas sin nombre —
que serían difíciles de leer
y de depurar.
Si necesitas algo más complejo,
usa def.
# ❌ Lo que NO puedes hacer en una lambda
# Declaraciones — print, for, while, assert
# lambda x: print(x) → print es una declaración, no una expresión
# (en Python 3 es una función — funciona,
# pero devuelve None — no tiene sentido)
# Múltiples líneas
# lambda x:
# y = x * 2 → SyntaxError
# return y
# Asignaciones
# lambda x: y = x * 2 → SyntaxError
# Try/except
# lambda x: try: ... except: ... → SyntaxError
# Docstrings
# lambda x: """calcula algo""" x * 2 → SyntaxError
# Anotaciones de tipo
# lambda x: int: x * 2 → SyntaxError
print("Lambda solo admite una expresión — nada más")
print("---")
# Cuando la lógica es compleja — usa def
# ❌ Lambda ilegible
clasificar = lambda x: "alto" if x > 100 else "medio" if x > 50 else "bajo"
# ✓ def más legible
def clasificar(x):
if x > 100:
return "alto"
elif x > 50:
return "medio"
else:
return "bajo"
— lambda vs def: cuándo usar cada una —
| Característica | lambda | def |
|---|---|---|
| Nombre | Anónima | Con nombre |
| Longitud | Una expresión | Múltiples líneas |
| Return | Implícito | Explícito |
| Docstring | No | Sí |
| Try/except | No | Sí |
| Reutilización | Limitada — uso inmediato | Alta |
| Uso típico |
key=, map,
filter, apply
|
Funciones generales reutilizables |
# A: reescribir como lambda
def triple(x):
return x * 3
# B: reescribir como lambda
def mayor(x, y):
return x if x > y else y
# C: reescribir como def
negativo = lambda x: -x
# D: reescribir como def
en_rango = lambda x, a, b: a <= x <= b
f1 = lambda x: x * 2
f2 = lambda x: x if x > 0 else 0
f3 = lambda x: x ** 2 if x > 0 else x ** 3 if x > -10 else 0
f4 = lambda x, y=1: x / y
lambda
y cuándo def
en cada caso?
f = lambda x: x*2
— derrota el propósito de la lambda?
¿Cuándo tiene sentido hacerlo
de todas formas?
# A
triple = lambda x: x * 3
# B
mayor = lambda x, y: x if x > y else y
# C
def negativo(x):
return -x
# D
def en_rango(x, a, b):
return a <= x <= b
f1, f2
y f4 son correctas
— simples y legibles.
f3 es problemática —
dos ternarios anidados
en una lambda son
muy difíciles de leer
y de verificar.
Debería ser un
def
con if/elif/else.
Ordenar por campo →
lambda:
key=lambda d: d["campo"]
— un solo uso, una expresión.
Calcular IVA en diez sitios →
def calcular_iva(precio):
se reutiliza — merece nombre.
Transformar columna DataFrame →
depende: si es simple,
lambda row: ...
en apply.
Si es compleja,
def.
Validar email con regex →
def: necesita
importar módulo,
manejar excepciones,
documentar el patrón.
Demasiado complejo para lambda.
Asignar una lambda a una variable
derrota su propósito
porque la ventaja de lambda
es precisamente no crear un nombre.
Si la asignas a f,
es exactamente igual
que un def f
— pero sin docstring,
sin anotaciones
y con peor legibilidad.
Tiene sentido hacerlo
en prototipos rápidos
o en sesiones interactivas
donde no merece la pena
escribir un def
completo.
En código de producción
— o en código que leerán
otros — siempre es mejor
def.
key=lambda x: x[1],
map(lambda x: x*2, lista).
En cualquier otro caso,
def.
sorted,
map,
filter
y pandas
práctica
Lambda brilla cuando se usa
como argumento de otras funciones —
exactamente el paradigma funcional
de la Clase 6c.
Los cuatro contextos donde
aparece más frecuentemente
en código real y de IA son
sorted con key,
map, filter
y df.apply() en pandas.
En todos ellos lambda evita
definir una función auxiliar
que solo se usaría una vez.
— sorted con key: el uso más frecuente —
# Ordenar strings por longitud
nombres = ["ana", "sofía", "lucía", "eva"]
print(sorted(nombres, key=lambda x: len(x)))
# ['ana', 'eva', 'sofía', 'lucía']
print("---")
# Ordenar tuplas por el segundo elemento
pares = [(3, 2), (1, 7), (2, 4)]
print(sorted(pares, key=lambda x: x[1]))
# [(3, 2), (2, 4), (1, 7)]
# Por la suma de los dos elementos
print(sorted(pares, key=lambda par: par[0] + par[1]))
# [(3, 2)=5, (2, 4)=6, (1, 7)=8]
print("---")
# Ordenar dicts por un campo
alumnos = [
{"nombre": "Ana", "nota": 8.5},
{"nombre": "Luis", "nota": 6.0},
{"nombre": "Sofía", "nota": 9.0},
]
# Por nota descendente
for a in sorted(alumnos, key=lambda x: x["nota"], reverse=True):
print(f"{a['nombre']}: {a['nota']}")
print("---")
# Con condicional — ajustar el criterio de ordenamiento
# Notas >= 7 se ordenan normalmente, el resto se tratan como 0
ordenado = sorted(
alumnos,
key=lambda x: x["nota"] if x["nota"] >= 7 else 0
)
for a in ordenado:
print(f"{a['nombre']}: {a['nota']}")
— map con lambda: transformar —
numeros = [1, 2, 3, 4]
# Cuadrados
print(list(map(lambda x: x ** 2, numeros)))
# [1, 4, 9, 16]
# map con dos iterables
a = [1, 2, 3]
b = [10, 20, 30]
print(list(map(lambda x, y: x + y, a, b)))
# [11, 22, 33]
# map con condicional
a = [5, 2, 8]
b = [3, 4, 8]
print(list(map(lambda x, y: x if x > y else y, a, b)))
# [5, 4, 8] — el mayor de cada par
print("---")
# map vs comprehension — equivalentes
precios = [10.0, 25.5, 8.0, 15.0]
# Con map + lambda
con_iva = list(map(lambda p: round(p * 1.21, 2), precios))
print(con_iva)
# Con comprehension — más pythónico para la mayoría
con_iva = [round(p * 1.21, 2) for p in precios]
print(con_iva)
— filter con lambda: seleccionar —
numeros = [-3, -1, 0, 2, 4, 5, 7, 8]
# Solo positivos
print(list(filter(lambda x: x > 0, numeros)))
# [2, 4, 5, 7, 8]
# Solo pares
print(list(filter(lambda x: x % 2 == 0, numeros)))
# [0, 2, 4, 8]
print("---")
# map + filter encadenados con lambda
ventas = [150, 80, 200, 30, 120]
# Bonus del 10% solo a ventas altas
con_bonus = list(map(
lambda v: v * 1.10,
filter(lambda v: v > 100, ventas)
))
print(con_bonus) # [165.0, 220.0, 132.0]
# Equivalente con comprehension
con_bonus = [v * 1.10 for v in ventas if v > 100]
print(con_bonus)
— Lambda en pandas: apply y map —
En pandas, lambda aparece
principalmente en tres contextos:
df.apply(lambda row: ..., axis=1)
para operar fila a fila,
serie.map(lambda x: ...)
para transformar elemento a elemento,
y en el encadenamiento funcional
con .assign(col=lambda d: ...).
import pandas as pd
import numpy as np
np.random.seed(0)
df = pd.DataFrame({
"ventas_2024": np.random.randint(80, 120, size=5),
"ventas_2025": np.random.randint(90, 130, size=5)
}, index=[f"región {i}" for i in range(1, 6)])
print(df)
print("---")
# apply fila a fila — año con más ventas
df["mejor_año"] = df.apply(
lambda row: "2024" if row["ventas_2024"] > row["ventas_2025"] else "2025",
axis=1
)
print(df)
print("---")
# serie.map — transformar una columna
df["categoria"] = df["ventas_2024"].map(
lambda v: "alto" if v >= 100 else "bajo"
)
print(df[["ventas_2024", "categoria"]])
print("---")
# assign — encadenamiento funcional con lambda
resultado = (
df
.assign(
total=lambda d: d["ventas_2024"] + d["ventas_2025"],
media=lambda d: (d["ventas_2024"] + d["ventas_2025"]) / 2
)
.sort_values("total", ascending=False)
)
print(resultado[["ventas_2024", "ventas_2025", "total", "media"]])
— Lambda en composición de funciones —
# Composición con lambda — de las slides
componer = lambda f, g: lambda x: f(g(x))
doble = lambda x: x * 2
incrementa = lambda x: x + 1
# Componer: primero incrementa, luego doble
c_fg = componer(doble, incrementa)
print(c_fg(3)) # (3+1)×2 = 8
print("---")
# Pipeline funcional con lambdas
pipeline = [
lambda datos: [x for x in datos if x is not None],
lambda datos: [x for x in datos if x > 0],
lambda datos: [round(x / max(datos), 2) for x in datos],
]
datos = [10, None, -5, 20, 30, None, 15]
for paso in pipeline:
datos = paso(datos)
print(datos) # [0.33, 0.67, 1.0, 0.5]
print("---")
# sorted con criterio complejo en una línea
alumnos = [("Ana", 8), ("Luis", 6), ("Sofía", 9)]
# Ordenar: notas >= 7 en orden, el resto al final con 0
ordenado = sorted(alumnos, key=lambda x: x[1] if x[1] >= 7 else 0)
print(ordenado)
# Transformar a def — más legible
def criterio_nota(alumno):
nombre, nota = alumno
return nota if nota >= 7 else 0
print(sorted(alumnos, key=criterio_nota))
key=lambda x: x[1]
es inmediatamente legible.
key=lambda x: x[1] if x[1] >= 7 else 0
está en el límite.
key=lambda x: x[1]**2 if x[1] >= 7
else x[0] if x[1] >= 5 else 0
debería ser un def.
datos = [("manzana", 150), ("pera", 80),
("kiwi", 200), ("fresa", 95)]
print(sorted(datos, key=lambda x: x[1]))
print(sorted(datos, key=lambda x: x[0]))
print(list(filter(lambda x: x[1] > 100, datos)))
print(list(map(lambda x: (x[0], x[1] * 1.10), datos)))
def donde no lo sea:
import pandas as pd
df = pd.DataFrame({
"nombre": ["Ana", "Bob", "Carlos"],
"ventas": [150, 80, 200],
"costes": [90, 60, 120]
})
def calcular_margen(fila):
return fila["ventas"] - fila["costes"]
def es_rentable(margen):
return "sí" if margen > 50 else "no"
df["margen"] = df.apply(calcular_margen, axis=1)
df["rentable"] = df["margen"].map(es_rentable)
productos = [
{"nombre": "A", "precio": 10, "stock": 5},
{"nombre": "B", "precio": 25, "stock": 0},
{"nombre": "C", "precio": 15, "stock": 3},
]
resultado = sorted(
filter(lambda p: p["stock"] > 0, productos),
key=lambda p: p["precio"] * p["stock"],
reverse=True
)
print(resultado)
# Versión A
df["resultado"] = df["ventas"].map(
lambda v: "alto" if v > 100 else "bajo"
)
# Versión B
df["resultado"] = df.apply(
lambda row: "alto" if row["ventas"] > 100 else "bajo",
axis=1
)
sorted(datos, key=lambda x: x[1])
→ por ventas ascendente:
[('pera', 80), ('fresa', 95),
('manzana', 150), ('kiwi', 200)].
sorted(datos, key=lambda x: x[0])
→ alfabético por nombre:
[('fresa', 95), ('kiwi', 200),
('manzana', 150), ('pera', 80)].
filter(lambda x: x[1] > 100, datos)
→ ventas mayores de 100:
[('manzana', 150), ('kiwi', 200)].
map(lambda x: (x[0], x[1]*1.10), datos)
→ ventas con bonus:
[('manzana', 165.0), ('pera', 88.0),
('kiwi', 220.0), ('fresa', 104.5)].
calcular_margen
es simple — puede ser lambda.
es_rentable
también — puede ser lambda.
df["margen"] = df.apply(
lambda fila: fila["ventas"] - fila["costes"],
axis=1
)
df["rentable"] = df["margen"].map(
lambda m: "sí" if m > 50 else "no"
)
Pero la versión con
def
también es válida —
los nombres
calcular_margen
y es_rentable
hacen el código
más legible y reutilizable.
Ninguna versión es
estrictamente mejor —
depende del contexto.
El código es correcto —
filtra productos con stock
y los ordena por valor total
(precio × stock) descendente.
Resultado:
[C: 15×3=45, A: 10×5=50]
— espera, A tiene mayor valor
así que:
[A (50), C (45)].
Es legible — cada lambda hace una cosa clara. La única mejora posible sería extraer el criterio de ordenamiento si se reutiliza:
def valor_total(p):
return p["precio"] * p["stock"]
resultado = sorted(
filter(lambda p: p["stock"] > 0, productos),
key=valor_total,
reverse=True
)
La versión A usa
serie.map()
— opera sobre cada valor
de una sola columna.
Es más eficiente y más clara
cuando la lógica depende
de una sola columna.
La versión B usa
df.apply(axis=1)
— opera sobre cada fila completa.
Necesaria cuando la lógica
depende de varias columnas.
Para este caso es innecesaria
y más lenta — accede a la fila
entera para leer solo
una columna.
serie.map().
Si necesita varias columnas
de la misma fila →
df.apply(axis=1).
Si la operación es vectorizable
— comparación, suma, multiplicación —
usa operaciones de pandas directamente
sin lambda ni apply.
@
teoría
En la Clase 6c vimos que una función puede recibir otra función como argumento y devolver una función nueva. Un decorador es exactamente ese patrón aplicado a un caso concreto: una función que recibe otra función, le añade comportamiento sin tocar su código original, y devuelve la versión mejorada.
La idea central: en lugar de modificar una función para añadirle algo — medición de tiempo, logging, validación de argumentos — la envuelves con otra función que añade ese comportamiento alrededor. El código original no cambia. El decorador es el envoltorio.
— El mecanismo paso a paso —
# Paso 1: una función simple
def funct():
print("Función base")
# Paso 2: pasarla como argumento — ya lo sabemos
def wrapper(f):
print(f"Ejecutar {f.__name__}")
f()
wrapper(funct) # imprime el nombre y llama a funct
print("---")
# Paso 3: el decorador — devuelve la función envuelta
def decorator(f):
def wrapper():
print(f"Ejecutar {f.__name__}") # antes
f() # llama a la original
return wrapper # devuelve wrapper — no lo llama
# Aplicar el decorador manualmente
funct = decorator(funct) # funct ahora es wrapper
funct()
# Ejecutar funct
# Función base
print("---")
# El decorador es un closure:
# wrapper recuerda f como free variable
print(funct.__name__) # 'wrapper' — problema que veremos
print(funct.__code__.co_freevars) # ('f',)
print(funct.__closure__[0].cell_contents) #
— La sintaxis @: azúcar sintáctico —
La sintaxis @decorador
es exactamente equivalente a escribir
funcion = decorador(funcion)
justo después de definir la función.
Nada más — es una abreviación
que Python procesa antes
de ejecutar el código.
El resultado es idéntico.
# Sin @ — equivalente manual
def funct_a():
print("Función A")
funct_a = decorator(funct_a)
funct_a()
print("---")
# Con @ — exactamente lo mismo, más legible
@decorator
def funct_b():
print("Función B")
funct_b()
print("---")
# Un decorador más útil: medir tiempo
# — ya lo vimos en la Clase 6c como closure
import time
def medir_tiempo(func):
def wrapper():
inicio = time.time()
func()
duracion = time.time() - inicio
print(f"{func.__name__} tardó {duracion:.6f}s")
return wrapper
@medir_tiempo
def proceso_lento():
total = sum(range(1_000_000))
print(f"total: {total}")
proceso_lento()
— El wrapper y los parámetros de la función original —
El wrapper del ejemplo anterior
solo funciona con funciones
sin argumentos — wrapper()
no acepta parámetros.
Si la función original los tiene,
el wrapper tiene que aceptarlos
y pasarlos.
La solución general es usar
*args y **kwargs
para que el wrapper sea compatible
con cualquier función.
# ❌ Wrapper sin parámetros — falla si la función los tiene
def decorator_sin_args(f):
def wrapper():
print(f"Ejecutar {f.__name__}")
f() # ← llama sin argumentos
return wrapper
@decorator_sin_args
def saludar(nombre):
print(f"Hola, {nombre}")
try:
saludar("Ana") # TypeError — wrapper() no acepta argumentos
except TypeError as e:
print(f"TypeError: {e}")
print("---")
# ✓ Wrapper con parámetro fijo — funciona pero no es general
def decorator_un_arg(f):
def wrapper(m):
print(f"Ejecutar {f.__name__}")
f(m)
return wrapper
# ✓ Wrapper con *args, **kwargs — general
# funciona con cualquier función sin importar sus parámetros
def decorator(f):
def wrapper(*args, **kwargs):
print(f"Ejecutar {f.__name__}")
resultado = f(*args, **kwargs) # pasa todos los argumentos
return resultado # devuelve el resultado
return wrapper
@decorator
def saludar(nombre):
print(f"Hola, {nombre}")
@decorator
def sumar(x, y):
return x + y
saludar("Ana")
print(sumar(3, 4)) # 7
Anatomía de un decorador completo
Función exterior
Recibe func
como argumento.
Define wrapper
internamente.
Devuelve wrapper.
Wrapper
Acepta *args, **kwargs.
Ejecuta código antes y/o después.
Llama a func
y devuelve su resultado.
Closure
wrapper recuerda
func
como free variable
aunque la exterior
haya terminado.
def mi_decorator(f):
def wrapper(*args, **kwargs):
print("antes")
resultado = f(*args, **kwargs)
print("después")
return resultado
return wrapper
@mi_decorator
def calcular(x, y):
print(f"calculando {x} + {y}")
return x + y
resultado = calcular(3, 4)
print(f"resultado: {resultado}")
return resultado
si quiere preservar
el valor de retorno
de la función original?
¿Qué pasaría si no lo hiciera?
log_llamada
que imprima el nombre
de la función y sus argumentos
cada vez que se llama.
Úsalo con @
sobre una función con
al menos dos parámetros.
@medir_tiempo
equivale exactamente a
proceso_lento = medir_tiempo(proceso_lento)?
¿Qué tipo tiene
proceso_lento
después de decorar?
¿Cuál es su free variable?
Imprime en orden:
antes,
calculando 3 + 4,
después,
resultado: 7.
El wrapper ejecuta
print("antes"),
llama a calcular(3, 4)
que imprime su mensaje
y devuelve 7,
luego print("después"),
y finalmente devuelve
7 a quien llamó.
Si el wrapper no devuelve
resultado,
devuelve None
implícitamente —
el valor de retorno
de la función original
se pierde.
El llamante recibe
None
en lugar del resultado esperado.
Es uno de los errores
más frecuentes al escribir
decoradores a mano
y el más difícil de detectar
porque no produce un error
— solo un resultado incorrecto.
def log_llamada(func):
def wrapper(*args, **kwargs):
args_str = ', '.join(repr(a) for a in args)
kwargs_str = ', '.join(
f"{k}={repr(v)}" for k, v in kwargs.items()
)
todos = ', '.join(filter(None, [args_str, kwargs_str]))
print(f"llamando {func.__name__}({todos})")
return func(*args, **kwargs)
return wrapper
@log_llamada
def calcular_precio(base, iva=0.21):
return round(base * (1 + iva), 2)
print(calcular_precio(100))
print(calcular_precio(100, iva=0.10))
@medir_tiempo
equivale a
proceso_lento = medir_tiempo(proceso_lento)
porque Python procesa
el decorador exactamente así:
llama a medir_tiempo
con proceso_lento
como argumento
y reasigna el nombre
proceso_lento
al resultado — que es
wrapper.
Después de decorar,
proceso_lento
es de tipo function
— concretamente el objeto
wrapper.
Su free variable es
func
— que contiene
la función original
proceso_lento.
functools.wraps
teoría
Después de aplicar un decorador,
la función original ha sido
reemplazada por wrapper.
Eso significa que su
__name__,
su __doc__
y otros atributos son los de
wrapper —
no los de la función original.
Este es un problema silencioso
que aparece en el debugging,
en el logging y en la documentación.
La solución es
functools.wraps
— un decorador que se aplica
al wrapper y copia los atributos
de la función original.
La IA no siempre lo usa —
saber cuándo exigirlo
es parte de supervisar el código.
— El problema: los atributos se pierden —
def mi_decorator(func):
def wrapper(*args, **kwargs):
print(f"llamando a {func.__name__}")
return func(*args, **kwargs)
return wrapper
@mi_decorator
def calcular_media(datos):
"""Calcula la media de una lista de números."""
return sum(datos) / len(datos)
# El nombre y el docstring se han perdido
print(calcular_media.__name__) # 'wrapper' — no 'calcular_media'
print(calcular_media.__doc__) # None — el docstring desapareció
print("---")
# El problema en la práctica
# En debugging — el traceback muestra 'wrapper' no 'calcular_media'
# En logging — los logs dicen 'wrapper' en lugar del nombre real
# En help() — la documentación desaparece
help(calcular_media)
# Help on function wrapper in module __main__:
# wrapper(*args, **kwargs)
# — ni rastro del docstring original
— La solución: functools.wraps —
functools.wraps(func)
es un decorador que se aplica
al wrapper interno.
Copia los atributos de
func al wrapper:
__name__,
__doc__,
__module__,
__qualname__
y __annotations__.
El wrapper sigue siendo el wrapper —
pero se presenta con los atributos
de la función original.
from functools import wraps
# Sin functools.wraps — problema
def decorator_sin_wraps(func):
def wrapper(*args, **kwargs):
print("antes")
return func(*args, **kwargs)
return wrapper
# Con functools.wraps — solución
def decorator_con_wraps(func):
@wraps(func) # ← aplicar wraps al wrapper
def wrapper(*args, **kwargs):
print("antes")
return func(*args, **kwargs)
return wrapper
def calcular_media(datos):
"""Calcula la media de una lista de números."""
return sum(datos) / len(datos)
# Comparar los dos
f_sin = decorator_sin_wraps(calcular_media)
f_con = decorator_con_wraps(calcular_media)
print(f"Sin wraps: __name__ = {f_sin.__name__}") # 'wrapper'
print(f"Con wraps: __name__ = {f_con.__name__}") # 'calcular_media'
print(f"Sin wraps: __doc__ = {f_sin.__doc__}") # None
print(f"Con wraps: __doc__ = {f_con.__doc__}") # 'Calcula la media...'
print("---")
# Con wraps: help() muestra la documentación original
help(f_con)
# Help on function calcular_media in module __main__:
# calcular_media(datos)
# Calcula la media de una lista de números.
— El decorador completo y correcto —
Esta es la plantilla correcta
para cualquier decorador —
con *args,
**kwargs,
return resultado
y @wraps(func).
Estos cuatro elementos juntos
garantizan que el decorador
es transparente: la función
decorada se comporta externamente
igual que la original.
from functools import wraps
import time
# Plantilla correcta de decorador
def medir_tiempo(func):
"""Decorador que mide el tiempo de ejecución."""
@wraps(func)
def wrapper(*args, **kwargs):
inicio = time.time()
resultado = func(*args, **kwargs) # ← capturar resultado
duracion = time.time() - inicio
print(f"[{func.__name__}] {duracion:.6f}s")
return resultado # ← devolver resultado
return wrapper
@medir_tiempo
def calcular_suma(n):
"""Calcula la suma de 0 a n."""
return sum(range(n))
@medir_tiempo
def calcular_media(datos):
"""Calcula la media de una lista."""
return sum(datos) / len(datos)
# Los atributos se conservan
print(calcular_suma.__name__) # 'calcular_suma'
print(calcular_suma.__doc__) # 'Calcula la suma de 0 a n.'
print("---")
# Funciona con cualquier firma
print(calcular_suma(1_000_000))
print(calcular_media([1, 2, 3, 4, 5]))
— Cuándo importa functools.wraps —
Siempre usar wraps
En código de producción.
Cuando la función tiene docstring.
Cuando el decorador se usará
con otras herramientas
que leen __name__
— logging, pytest, Flask.
La IA a veces no lo usa
En código generado rápidamente
o en ejemplos simples.
Si ves un decorador sin
@wraps
en código de IA,
añádelo antes de usar
el código en producción.
from functools import wraps
y sin @wraps(func)
en el wrapper,
el decorador es incompleto.
Funciona — pero pierde
el nombre y el docstring
de la función original.
Añadir @wraps(func)
es siempre la corrección correcta.
from functools import wraps
def decorador_a(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def decorador_b(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@decorador_a
def funcion_a():
"""Docstring de funcion_a."""
pass
@decorador_b
def funcion_b():
"""Docstring de funcion_b."""
pass
print(funcion_a.__name__, funcion_a.__doc__)
print(funcion_b.__name__, funcion_b.__doc__)
def validar_positivo(func):
def wrapper(x):
if x < 0:
raise ValueError(f"x debe ser positivo, recibido: {x}")
func(x) # ← ¿falta algo?
return wrapper
@validar_positivo
def raiz_cuadrada(x):
"""Calcula la raíz cuadrada de x."""
return x ** 0.5
print(raiz_cuadrada(9))
print(raiz_cuadrada.__name__)
medir_tiempo
completo y correcto
— con @wraps,
*args,
**kwargs
y return resultado —
y aplícalo a una función
que calcule la media
de una lista.
Verifica que
__name__
y __doc__
se conservan.
@wraps(func)
es en sí mismo un decorador?
¿Qué función decora?
¿Dónde se aplica exactamente?
funcion_a.__name__
→ 'wrapper',
funcion_a.__doc__
→ None
— sin wraps
los atributos de la función
original se pierden.
funcion_b.__name__
→ 'funcion_b',
funcion_b.__doc__
→ 'Docstring de funcion_b.'
— con @wraps(func)
los atributos se copian
del original al wrapper.
Dos problemas:
falta return
en la llamada a
func(x)
— el resultado de
raiz_cuadrada
se pierde y
print(raiz_cuadrada(9))
imprime None.
Y falta @wraps(func)
— raiz_cuadrada.__name__
devuelve 'wrapper'.
from functools import wraps
def validar_positivo(func):
@wraps(func) # ← añadir wraps
def wrapper(x):
if x < 0:
raise ValueError(
f"x debe ser positivo, recibido: {x}"
)
return func(x) # ← añadir return
return wrapper
@validar_positivo
def raiz_cuadrada(x):
"""Calcula la raíz cuadrada de x."""
return x ** 0.5
print(raiz_cuadrada(9)) # 3.0
print(raiz_cuadrada.__name__) # 'raiz_cuadrada'
from functools import wraps
import time
def medir_tiempo(func):
"""Mide el tiempo de ejecución de func."""
@wraps(func)
def wrapper(*args, **kwargs):
inicio = time.time()
resultado = func(*args, **kwargs)
duracion = time.time() - inicio
print(f"[{func.__name__}] {duracion:.6f}s")
return resultado
return wrapper
@medir_tiempo
def calcular_media(datos):
"""Calcula la media de una lista de números."""
return sum(datos) / len(datos)
print(calcular_media([1, 2, 3, 4, 5]))
print(calcular_media.__name__) # 'calcular_media'
print(calcular_media.__doc__) # 'Calcula la media...'
@wraps(func)
es un decorador con parámetros
— exactamente lo que veremos
en el Bloque 3.
wraps(func)
devuelve un decorador
configurado para copiar
los atributos de
func.
Ese decorador se aplica
a wrapper
— la función interior.
Es un decorador que decora
el wrapper dentro
de otro decorador —
tres niveles de anidamiento
que ya conocemos
gracias a la Clase 6c.
Hasta ahora los decoradores
no aceptan parámetros —
@medir_tiempo
simplemente se aplica.
Pero a veces necesitamos
configurar el decorador:
@reintentar(3),
@logger("INFO"),
@repetir(4).
¿Cómo se hace?
La solución es añadir un nivel más de anidamiento. En lugar de escribir directamente un decorador, escribimos una fábrica de decoradores — una función que recibe los parámetros de configuración y devuelve un decorador. El decorador devuelve el wrapper. Tres niveles en total: fábrica → decorador → wrapper.
— Por qué @decorador(param) necesita un nivel más —
# @decorador equivale a: funcion = decorador(funcion)
# @decorador(param) equivale a: funcion = decorador(param)(funcion)
#
# decorador(param) debe devolver un decorador
# ese decorador recibe func y devuelve wrapper
#
# Nivel 1: fábrica — recibe los parámetros
# Nivel 2: decorador — recibe func
# Nivel 3: wrapper — recibe los argumentos de func
print("La estructura:")
print(" @fabrica(param) → fabrica(param)(func)")
print(" @decorador_sin_parametros → decorador(func)")
— La fábrica de decoradores: repetir N veces —
El ejemplo de tus slides —
repetition_decorator —
es el más claro para entender
la estructura de tres niveles.
La fábrica recibe
repetitions,
el decorador recibe
func,
y el wrapper ejecuta
func el número
de veces indicado.
from functools import wraps
def repetir(veces):
"""Fábrica: devuelve un decorador que repite func N veces."""
def decorator(func): # nivel 2: recibe func
@wraps(func)
def wrapper(*args, **kwargs): # nivel 3: wrapper
resultado = None
for _ in range(veces):
resultado = func(*args, **kwargs)
return resultado
return wrapper
return decorator # nivel 1 devuelve el decorador
# Forma manual — equivalente a @repetir(4)
def saludar():
print("Hola")
saludar = repetir(4)(saludar) # repetir(4) → decorator → decorator(saludar) → wrapper
saludar() # imprime "Hola" 4 veces
print("---")
# Con sintaxis @ — más legible
@repetir(3)
def avisar(mensaje):
print(f"Aviso: {mensaje}")
avisar("temperatura alta") # imprime 3 veces
print("---")
# Las free variables de los tres niveles
print(avisar.__name__) # 'avisar' — wraps funciona
print(avisar.__code__.co_freevars) # ('func', 'veces')
print(avisar.__closure__[0].cell_contents) # la función avisar original
print(avisar.__closure__[1].cell_contents) # 3 — el parámetro veces
— Ejemplos aplicados: fábricas útiles —
from functools import wraps
import time
# 1. Logger configurable — nivel de detalle como parámetro
def logger(nivel="INFO"):
"""Fábrica: registra las llamadas con el nivel indicado."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{nivel}] llamando a {func.__name__}")
resultado = func(*args, **kwargs)
print(f"[{nivel}] {func.__name__} completada")
return resultado
return wrapper
return decorator
@logger("DEBUG")
def cargar_datos(ruta):
"""Carga datos desde una ruta."""
return [1, 2, 3] # simulación
@logger("INFO")
def procesar_datos(datos):
"""Procesa una lista de datos."""
return [x * 2 for x in datos]
datos = cargar_datos("datos.csv")
resultado = procesar_datos(datos)
print(f"resultado: {resultado}")
print("---")
# 2. Validador de rango — límites como parámetros
def validar_rango(minimo, maximo):
"""Fábrica: valida que el primer argumento esté en [minimo, maximo]."""
def decorator(func):
@wraps(func)
def wrapper(valor, *args, **kwargs):
if not (minimo <= valor <= maximo):
raise ValueError(
f"{func.__name__}: {valor} fuera de [{minimo}, {maximo}]"
)
return func(valor, *args, **kwargs)
return wrapper
return decorator
@validar_rango(0, 10)
def calcular_nota_final(nota, peso=1.0):
"""Calcula la nota ponderada."""
return nota * peso
@validar_rango(0, 100)
def calcular_porcentaje(valor):
"""Calcula el porcentaje."""
return valor / 100
print(calcular_nota_final(8.5, peso=0.6)) # 5.1
print(calcular_porcentaje(75)) # 0.75
try:
calcular_nota_final(11) # fuera de rango
except ValueError as e:
print(f"Error: {e}")
print("---")
# 3. Reintentar N veces si hay error
def reintentar(veces=3, excepcion=Exception):
"""Fábrica: reintenta func hasta N veces si lanza excepción."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for intento in range(1, veces + 1):
try:
return func(*args, **kwargs)
except excepcion as e:
print(f"Intento {intento}/{veces} fallido: {e}")
if intento == veces:
raise
return wrapper
return decorator
# Simular una función que falla las primeras veces
contador_fallos = [0]
@reintentar(veces=3)
def conectar_servidor():
"""Intenta conectar al servidor."""
contador_fallos[0] += 1
if contador_fallos[0] < 3:
raise ConnectionError("servidor no disponible")
return "conectado"
print(conectar_servidor())
— Los tres niveles de anidamiento —
wrapper del tercer nivel
tiene dos free variables:
func — de la función
del segundo nivel —
y los parámetros de configuración
— de la fábrica del primer nivel.
Ambos se guardan en el closure
y están disponibles en cada llamada
a wrapper.
Es el mismo mecanismo de los closures
de la Clase 6c — ahora con dos capas.
from functools import wraps
def prefijo(texto):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
resultado = func(*args, **kwargs)
return f"{texto}: {resultado}"
return wrapper
return decorator
@prefijo("RESULTADO")
def sumar(x, y):
return x + y
@prefijo("MEDIA")
def media(datos):
return sum(datos) / len(datos)
print(sumar(3, 4))
print(media([1, 2, 3, 4, 5]))
print(sumar.__name__)
wrapper
de logger("DEBUG")?
¿Cuáles son?
¿De qué nivel de anidamiento
viene cada una?
cache_simple
que guarde en un diccionario
los resultados de las llamadas
anteriores y los devuelva
directamente si los argumentos
se repiten — sin llamar
a la función original.
@cache_simple
def calcular_cuadrado(n):
print(f"calculando {n}²")
return n ** 2
print(calcular_cuadrado(4)) # calcula
print(calcular_cuadrado(4)) # devuelve del cache
print(calcular_cuadrado(5)) # calcula
# Uso A — correcto
@repetir(3)
def f():
print("hola")
# Uso B — ¿qué pasa?
@repetir
def g():
print("hola")
sumar(3, 4):
prefijo("RESULTADO")
devuelve decorator,
decorator(sumar)
devuelve wrapper
con texto="RESULTADO"
y func=sumar
como free variables.
wrapper(3, 4)
llama a sumar(3, 4)
que devuelve 7,
luego devuelve
"RESULTADO: 7".
media([1,2,3,4,5])
→ "MEDIA: 3.0".
sumar.__name__
→ 'sumar'
— @wraps
preserva el nombre.
El wrapper
de logger("DEBUG")
tiene dos free variables:
func
— del segundo nivel,
decorator —
que es la función
cargar_datos
original,
y nivel
— del primer nivel,
la fábrica logger
— con valor
"DEBUG".
func viene
del closure del decorador.
nivel viene
del closure de la fábrica.
Dos capas de closure
anidadas — exactamente
la mecánica de la Clase 6c.
from functools import wraps
def cache_simple(func):
cache = {}
@wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
else:
print(f"[cache] {func.__name__}{args}")
return cache[args]
return wrapper
@cache_simple
def calcular_cuadrado(n):
print(f"calculando {n}²")
return n ** 2
print(calcular_cuadrado(4)) # calculando 4² → 16
print(calcular_cuadrado(4)) # [cache] → 16
print(calcular_cuadrado(5)) # calculando 5² → 25
functools.lru_cache
— el decorador de caché
incluido en Python.
@lru_cache(maxsize=128)
es una fábrica de decoradores
con parámetros —
exactamente la estructura
que acabamos de ver.
El uso A es correcto:
@repetir(3)
llama a repetir(3)
que devuelve
decorator,
y luego
decorator(f)
devuelve wrapper.
Tres niveles — funciona.
El uso B falla:
@repetir
sin paréntesis equivale a
g = repetir(g).
repetir
espera recibir un entero
como primer argumento —
veces —
pero recibe la función
g.
Luego intenta
range(g)
— TypeError.
@reintentar()
funciona.
@reintentar
falla — pasa la función
donde se espera
el número de reintentos.
Cuando la IA genera código con decoradores, el objetivo no es escribirlos desde cero — es leerlos y entender qué hacen. Hay un protocolo mental de tres pasos que funciona para cualquier decorador, por complejo que sea.
— El protocolo de tres pasos —
Paso 1
Identificar la función original
— la que tiene el nombre real.
Es la free variable
func
del wrapper.
Paso 2
Leer el wrapper:
qué hace antes
de llamar a func
y qué hace
después.
Eso es lo que añade el decorador.
Paso 3
Verificar que el wrapper
tiene return resultado
y @wraps(func).
Si no — el decorador
es incompleto.
from functools import wraps
import time
# Leer este decorador con el protocolo de tres pasos
def medir_tiempo(func):
@wraps(func)
def wrapper(*args, **kwargs):
# ANTES — paso 2a
inicio = time.time()
# LA FUNCIÓN ORIGINAL — paso 1
resultado = func(*args, **kwargs)
# DESPUÉS — paso 2b
duracion = time.time() - inicio
print(f"[{func.__name__}] {duracion:.4f}s")
# DEVUELVE EL RESULTADO — paso 3
return resultado
return wrapper
# Lectura: medir_tiempo añade medición de tiempo
# antes (captura inicio) y después (calcula duración)
# sin modificar el resultado de la función original
@medir_tiempo
def calcular(n):
"""Suma de 0 a n."""
return sum(range(n))
print(calcular(1_000_000))
— Decoradores apilados: el orden importa —
Cuando se apilan varios decoradores
con @,
el orden de aplicación
es de abajo a arriba
— el decorador más cercano
a la función se aplica primero.
Pero el orden de ejecución
al llamar a la función
es de arriba a abajo.
Este es el punto más contraintuitivo
de los decoradores apilados.
from functools import wraps
import time
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[decorator] antes — {func.__name__}")
resultado = func(*args, **kwargs)
print(f"[decorator] después")
return resultado
return wrapper
def duration_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
inicio = time.time()
resultado = func(*args, **kwargs)
duracion = time.time() - inicio
print(f"[duration] {duracion:.4f}s")
return resultado
return wrapper
# De las slides — apilados
@duration_decorator # ← se aplica SEGUNDO — ejecuta PRIMERO
@decorator # ← se aplica PRIMERO — ejecuta SEGUNDO
def funct():
print("Función base")
# Equivalente exacto:
# funct = duration_decorator(decorator(funct))
funct()
# [duration] antes ← duration_decorator ejecuta primero
# [decorator] antes ← decorator ejecuta segundo
# Función base ← la función original
# [decorator] después
# [duration] después
# [duration] 0.0001s
print("---")
# El orden importa — cambiar el orden cambia el comportamiento
@decorator # ← ahora decorator ejecuta primero
@duration_decorator # ← duration ejecuta segundo
def funct_invertida():
print("Función base")
funct_invertida()
# [decorator] antes
# [duration] antes ← ahora duration está dentro
# Función base
# [duration] después
# [duration] 0.0001s ← mide solo la función base
# [decorator] después
El orden de aplicación vs el orden de ejecución
Aplicación (de abajo a arriba)
Equivalente a:
funct = duration_decorator(
decorator(funct)
)
Ejecución (de fuera a dentro)
— El orden tiene consecuencias reales —
from functools import wraps
def validar(func):
"""Valida que el argumento no sea negativo."""
@wraps(func)
def wrapper(x, *args, **kwargs):
if x < 0:
raise ValueError(f"Argumento negativo: {x}")
return func(x, *args, **kwargs)
return wrapper
def registrar(func):
"""Registra cada llamada."""
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Llamando a {func.__name__} con args={args}")
return func(*args, **kwargs)
return wrapper
# Orden A: primero validar, luego registrar
# registrar ve la llamada ANTES de validar
# — registra incluso las llamadas inválidas
@registrar
@validar
def raiz_a(x):
return x ** 0.5
# Orden B: primero registrar, luego validar
# validar actúa ANTES de registrar
# — solo registra las llamadas válidas
@validar
@registrar
def raiz_b(x):
return x ** 0.5
print("--- Orden A: registrar sobre validar ---")
try:
raiz_a(4) # registra y calcula
raiz_a(-1) # registra y luego falla
except ValueError as e:
print(f"Error: {e}")
print("--- Orden B: validar sobre registrar ---")
try:
raiz_b(4) # registra y calcula
raiz_b(-1) # falla antes de registrar
except ValueError as e:
print(f"Error: {e}")
def cache(func):
almacen = {}
def wrapper(*args):
if args not in almacen:
almacen[args] = func(*args)
return almacen[args]
return wrapper
funct():
def d1(func):
def wrapper(*args, **kwargs):
print("d1 antes")
r = func(*args, **kwargs)
print("d1 después")
return r
return wrapper
def d2(func):
def wrapper(*args, **kwargs):
print("d2 antes")
r = func(*args, **kwargs)
print("d2 después")
return r
return wrapper
def d3(func):
def wrapper(*args, **kwargs):
print("d3 antes")
r = func(*args, **kwargs)
print("d3 después")
return r
return wrapper
@d1
@d2
@d3
def funct():
print("función")
funct()
raiz_a
y raiz_b:
¿cuál es el comportamiento
correcto si queremos
un log de auditoría
que registre todos
los intentos —
incluyendo los fallidos?
¿Cuál orden usarías?
procesar(datos):
@medir_tiempo
@logger("INFO")
@validar_rango(0, 100)
def procesar(valor):
return valor * 2
Paso 1 — función original:
func —
la función decorada,
guardada como free variable.
Paso 2 — qué añade:
antes de llamar a func
comprueba si los argumentos
ya están en almacen.
Si están, devuelve
el resultado guardado
sin llamar a func.
Si no están, llama a
func,
guarda el resultado
y lo devuelve.
Es un caché simple
basado en los argumentos.
Paso 3 — ¿está completo?
No del todo:
falta @wraps(func)
— el nombre y el docstring
de la función original
se pierden.
Por lo demás el mecanismo
es correcto.
Orden de ejecución:
d1 antes,
d2 antes,
d3 antes,
función,
d3 después,
d2 después,
d1 después.
La aplicación es de abajo
a arriba —
primero d3,
luego d2,
luego d1.
La ejecución es de fuera
a dentro en los "antes"
y de dentro a fuera
en los "después" —
como una cebolla.
Para registrar todos
los intentos incluyendo
los fallidos hay que usar
el orden A:
@registrar
sobre @validar.
registrar
es la capa exterior
— ve la llamada antes
de que validar
pueda rechazarla.
Así registra tanto
las llamadas válidas
como las inválidas.
La aplicación es de abajo
a arriba:
primero validar_rango(0, 100),
luego logger("INFO"),
luego medir_tiempo.
La ejecución al llamar
a procesar(valor):
primero medir_tiempo
captura el tiempo de inicio,
luego logger
registra la llamada,
luego validar_rango
comprueba que
valor
está entre 0 y 100
— si no, lanza
ValueError
antes de llegar
a la función original —,
luego procesar
calcula valor * 2,
luego logger
registra la finalización,
luego medir_tiempo
imprime la duración.
medir_tiempo
en la capa más exterior
mide el tiempo total —
incluyendo el logging
y la validación.
Si solo quisiéramos medir
la función pura,
medir_tiempo
iría en la capa más interior.
El orden elegido aquí
mide el coste real
de toda la operación
decorada.
Los decoradores no son una construcción exótica — están en el núcleo de Python y en todas las librerías importantes. Es probable que ya hayas visto algunos sin reconocerlos como tales. Ahora que entiendes el mecanismo, puedes leer cualquier decorador de cualquier librería y entender exactamente qué hace.
— Decoradores en Python estándar —
from functools import lru_cache
# @lru_cache — caché con límite de entradas
# fábrica de decoradores — lru_cache(maxsize=128) devuelve el decorador
@lru_cache(maxsize=128)
def fibonacci(n):
"""Calcula el n-ésimo número de Fibonacci."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Sin caché: 2^50 llamadas recursivas
# Con caché: cada valor se calcula una sola vez
print(fibonacci(50))
print(fibonacci.cache_info()) # hits, misses, maxsize, currsize
print("---")
# @property — convierte un método en atributo
class Circulo:
def __init__(self, radio):
self.radio = radio
@property
def area(self):
"""Calcula el área — se accede como atributo."""
return 3.1416 * self.radio ** 2
@property
def diametro(self):
return self.radio * 2
c = Circulo(5)
print(c.area) # sin () — parece atributo, es método decorado
print(c.diametro) # sin ()
print("---")
# @staticmethod — método que no recibe self
# @classmethod — método que recibe la clase en lugar de self
class Temperatura:
@staticmethod
def celsius_a_fahrenheit(c):
return c * 9/5 + 32
@classmethod
def desde_fahrenheit(cls, f):
return cls((f - 32) * 5/9)
print(Temperatura.celsius_a_fahrenheit(100)) # 212.0
— Decoradores en librerías de análisis de datos —
En el ecosistema de análisis de datos
los decoradores aparecen
en contextos muy concretos.
El más frecuente en código de IA
es @lru_cache
para funciones costosas,
pero también aparecen en
Streamlit, Dash y otras librerías
de visualización y aplicaciones.
from functools import lru_cache, wraps
import time
# Patrón frecuente en IA: caché para cálculos costosos
@lru_cache(maxsize=256)
def calcular_estadisticas(datos_tuple):
"""
Calcula estadísticas de un conjunto de datos.
Recibe tupla — lru_cache requiere argumentos hashables.
"""
n = len(datos_tuple)
media = sum(datos_tuple) / n
varianza = sum((x - media)**2 for x in datos_tuple) / n
return {"n": n, "media": media, "varianza": varianza}
datos = (1, 2, 3, 4, 5)
print(calcular_estadisticas(datos)) # calcula
print(calcular_estadisticas(datos)) # devuelve del caché
print("---")
# Decorador propio útil en análisis:
# verificar que un DataFrame no está vacío
import pandas as pd
def requiere_datos(func):
"""Verifica que el primer argumento es un DataFrame no vacío."""
@wraps(func)
def wrapper(df, *args, **kwargs):
if not isinstance(df, pd.DataFrame):
raise TypeError(f"{func.__name__}: se esperaba DataFrame")
if df.empty:
raise ValueError(f"{func.__name__}: el DataFrame está vacío")
return func(df, *args, **kwargs)
return wrapper
@requiere_datos
def resumir(df):
"""Devuelve un resumen del DataFrame."""
return df.describe()
@requiere_datos
def limpiar(df, columna):
"""Elimina nulos de una columna."""
return df.dropna(subset=[columna])
df_ok = pd.DataFrame({"ventas": [100, None, 200]})
df_vacio = pd.DataFrame()
print(resumir(df_ok))
try:
resumir(df_vacio)
except ValueError as e:
print(f"Error: {e}")
try:
resumir("no soy un DataFrame")
except TypeError as e:
print(f"Error: {e}")
— Cuándo usar decoradores y cuándo no —
Un decorador es la herramienta correcta cuando quieres añadir comportamiento a múltiples funciones sin repetir código — y cuando ese comportamiento es ortogonal a lo que hace la función: medir tiempo, registrar llamadas, validar argumentos, cachear resultados. Si el comportamiento está mezclado con la lógica de la función, es mejor modificar la función directamente.
| Situación | Decorador | Por qué |
|---|---|---|
| Medir tiempo de varias funciones | ✓ Sí | Comportamiento transversal, no mezcla lógica |
| Cachear resultados costosos | ✓ Sí |
@lru_cache
ya existe — úsalo
|
| Validar argumentos en múltiples funciones | ✓ Sí | Evita repetir la misma validación en cada función |
| Añadir lógica de negocio específica a una función | ✗ No | Mejor modificar la función directamente — más legible |
| Comportamiento que solo aplica a una función | ✗ No | Un decorador para un solo uso es sobre-ingeniería |
| Logging, autenticación, reintentos en APIs | ✓ Sí | Casos de uso clásicos — ortogonales a la lógica |
— Cómo reconocer y supervisar decoradores en código de IA —
# Código de IA con decoradores — lista de comprobación
# ✓ Verificar: ¿tiene @wraps(func)?
# ✓ Verificar: ¿el wrapper usa *args, **kwargs?
# ✓ Verificar: ¿el wrapper devuelve el resultado con return?
# ✓ Verificar: ¿el orden de los decoradores apilados es correcto?
# ✓ Verificar: ¿el decorador es realmente necesario?
from functools import wraps
# Ejemplo: código de IA con un problema sutil
def verificar_tipo(tipo_esperado):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for arg in args:
if not isinstance(arg, tipo_esperado):
raise TypeError(
f"{func.__name__}: se esperaba {tipo_esperado.__name__}, "
f"recibido {type(arg).__name__}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@verificar_tipo(int)
def factorial(n):
"""Calcula el factorial de n."""
if n == 0:
return 1
return n * factorial(n - 1)
print(factorial(5)) # 120
try:
factorial(3.5) # TypeError — no es int
except TypeError as e:
print(f"Error: {e}")
# ¿Ves el problema sutil?
# factorial es recursivo — cada llamada recursiva
# también pasa por el decorador
# Es correcto pero añade overhead en cada nivel
print(factorial.__name__) # 'factorial' — wraps funciona
@lru_cache(maxsize=128)
es una fábrica de decoradores.
¿Cuántos niveles de anidamiento
tiene?
¿Cuáles son sus free variables?
¿Por qué los argumentos de la
función decorada deben ser
hashables — no pueden ser listas?
@property:
¿por qué c.area
no necesita paréntesis
aunque area
es un método?
¿Qué hace exactamente
@property?
import time
from functools import wraps
import pandas as pd
@medir_tiempo
@requiere_datos
@logger("INFO")
def analizar_ventas(df, columna="ventas"):
"""Analiza las ventas de un DataFrame."""
return df[columna].describe()
# Patrón A — decorador
@medir_tiempo
def calcular(datos):
return sum(datos) / len(datos)
# Patrón B — llamada directa
def calcular(datos):
return sum(datos) / len(datos)
inicio = time.time()
resultado = calcular(datos)
print(f"tardó {time.time()-inicio:.4f}s")
@lru_cache(maxsize=128)
tiene tres niveles:
la fábrica lru_cache
recibe maxsize,
el decorador recibe func,
el wrapper recibe los argumentos
de func.
Las free variables del wrapper
son func
y el caché interno —
un diccionario que asocia
argumentos con resultados.
Los argumentos deben ser
hashables porque el caché
usa los argumentos como
claves de un diccionario.
Las listas no son hashables
— no pueden ser claves —
porque son mutables.
Una lista podría cambiar
después de usarse como clave,
invalidando el caché.
Las tuplas sí son hashables
— por eso el ejemplo usa
datos_tuple.
@property
transforma el método
en un descriptor —
cuando Python ve
c.area,
en lugar de devolver
el objeto método,
lo llama automáticamente
y devuelve su resultado.
Los paréntesis no son necesarios
porque el descriptor
se encarga de la llamada.
Es un decorador especial
integrado en Python
que cambia cómo el intérprete
accede al atributo.
El orden tiene un problema:
@logger("INFO")
está en la capa más interior —
registra la llamada
después de que
requiere_datos
ya haya validado el DataFrame.
Si el DataFrame está vacío,
requiere_datos
lanza excepción
antes de que logger
pueda registrar nada.
Para auditoría completa,
el logger debería estar
en la capa más exterior.
El orden correcto para auditoría completa:
@logger("INFO") # exterior — registra todo
@medir_tiempo # mide la validación + cálculo
@requiere_datos # interior — valida antes de calcular
def analizar_ventas(df, columna="ventas"):
"""Analiza las ventas de un DataFrame."""
return df[columna].describe()
Los tres decoradores tienen
@wraps,
*args/**kwargs
y return resultado
— la estructura es correcta.
Solo el orden necesita ajuste.
El patrón A con decorador
es mejor cuando quieres
medir el tiempo
en todas
las llamadas a
calcular
de forma permanente —
en producción, en logging
sistemático.
La medición es transparente
— el llamante no sabe
que está ocurriendo.
El patrón B con llamada directa es mejor cuando quieres medir el tiempo de una llamada concreta — en debugging puntual, en comparaciones de rendimiento, en código desechable. La medición es explícita y no afecta al resto del código.