UdelaR  ·  Máster en Análisis de Datos

Introducción a Python

Clase 6d — Lambda functions y decoradores

Asignatura  Introducción a Python Profesor  Daniel Miles Touya Universidad  UdelaR-Uvigo
Parte I

Lambda functions: funciones anónimas

Lambda functions: funciones anónimas de una sola expresión teoría

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
  1. Reescribe estas funciones como lambda y viceversa:
    # 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
  2. ¿Cuál de estas lambdas es problemática y por qué?
    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
  3. ¿Cuándo usarías lambda y cuándo def en cada caso?
    — Ordenar una lista de dicts por un campo
    — Calcular el IVA de un precio en diez sitios distintos del código
    — Transformar una columna de un DataFrame
    — Validar un email con expresión regular
  4. ¿Por qué asignar una lambda a una variable con un nombre — como f = lambda x: x*2 — derrota el propósito de la lambda? ¿Cuándo tiene sentido hacerlo de todas formas?
Respuesta orientativa
a
# 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
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.

c

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.

d

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.

La guía de uso: usa lambda cuando la función va directamente como argumento a otra función — key=lambda x: x[1], map(lambda x: x*2, lista). En cualquier otro caso, def.
Lambda en la práctica: 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))
La regla de oro: usa lambda cuando la expresión es simple y el contexto hace obvio qué hace. 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.
  1. Predice qué produce cada línea antes de ejecutar:
    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)))
  2. Reescribe este código usando lambda donde sea apropiado y 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)
  3. Este código de IA usa lambda. ¿Es correcto y legible? ¿Lo reescribirías?
    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)
  4. ¿Cuál es la diferencia entre estos dos usos de lambda en pandas? ¿Cuándo usarías cada uno?
    # 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
    )
Respuesta orientativa
a

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

b

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.

c

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
)
d

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.

Regla práctica: si la lambda solo usa 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.
Parte II

Decoradores: envolver funciones

El mecanismo: wrapper y sintaxis @ 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.

  1. ¿Qué imprime este código? Predice antes de ejecutar:
    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}")
  2. ¿Por qué el wrapper debe usar return resultado si quiere preservar el valor de retorno de la función original? ¿Qué pasaría si no lo hiciera?
  3. Escribe un decorador 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.
  4. ¿Por qué @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?
Respuesta orientativa
a

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ó.

b

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.

c
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))
d

@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.

El problema del nombre: 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.

Señal de alerta en código de IA: si ves un decorador sin 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.
  1. ¿Qué imprime este código? ¿Por qué el segundo bloque da un resultado distinto?
    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__)
  2. Este decorador generado por IA tiene dos problemas. Identifícalos y corrígelos:
    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__)
  3. Escribe un decorador 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.
  4. ¿Por qué @wraps(func) es en sí mismo un decorador? ¿Qué función decora? ¿Dónde se aplica exactamente?
Respuesta orientativa
a

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.

b

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'
c
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...'
d

@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.

Decoradores con parámetros: la fábrica de decoradores teoría

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 —

def fábrica(parámetros):
↳ recibe los parámetros de configuración
    def decorator(func):
↳ recibe la función a decorar
        @wraps(func)
        def wrapper(*args, **kwargs):
↳ recibe los argumentos de func
↳ usa parámetros (free var de fábrica)
↳ usa func (free var de decorator)
↳ devuelve resultado
    return wrapper
    return decorator
Las free variables del closure: el 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.
  1. ¿Qué imprime este código? Traza la ejecución de los tres niveles:
    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__)
  2. ¿Cuántas free variables tiene el wrapper de logger("DEBUG")? ¿Cuáles son? ¿De qué nivel de anidamiento viene cada una?
  3. Escribe una fábrica de decoradores 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
  4. ¿Cuál es la diferencia entre estos dos usos? ¿Por qué el segundo falla?
    # Uso A — correcto
    @repetir(3)
    def f():
        print("hola")
    
    # Uso B — ¿qué pasa?
    @repetir
    def g():
        print("hola")
Respuesta orientativa
a

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.

b

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.

c
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
Nota: este es exactamente el patrón de 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.
d

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.

La regla: un decorador con parámetros siempre necesita los paréntesis — aunque uses los valores por defecto. @reintentar() funciona. @reintentar falla — pasa la función donde se espera el número de reintentos.
Parte III

Decoradores en la práctica

Cómo leer un decorador y decoradores apilados leer

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)

@duration_decorator ←② se aplica segundo
@decorator       ←① se aplica primero
def funct(): ...

Equivalente a:
funct = duration_decorator(
    decorator(funct)
)

Ejecución (de fuera a dentro)

duration wrapper — antes ①
decorator wrapper — antes ②
funct() ③
decorator wrapper — después ④
duration wrapper — después ⑤

— 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}")
La regla para apilar decoradores: piensa en capas de cebolla — el decorador más cercano a la función es la capa más interior. Cuando se ejecuta, se recorre de fuera a dentro en el "antes" y de dentro a fuera en el "después". El orden correcto depende de qué quieres que ocurra primero — validar antes de registrar, o registrar antes de validar.
  1. Aplica el protocolo de tres pasos a este decorador generado por IA. ¿Qué añade? ¿Está completo?
    def cache(func):
        almacen = {}
        def wrapper(*args):
            if args not in almacen:
                almacen[args] = func(*args)
            return almacen[args]
        return wrapper
  2. Predice el orden exacto de los prints cuando se llama a 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()
  3. En el ejemplo de 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?
  4. Este código de IA apila tres decoradores. Describe qué hace cada capa y en qué orden se ejecutan cuando se llama a procesar(datos):
    @medir_tiempo
    @logger("INFO")
    @validar_rango(0, 100)
    def procesar(valor):
        return valor * 2
Respuesta orientativa
a

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.

b

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.

c

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.

Regla práctica: el logging y la auditoría van en la capa más exterior para capturar todo. La validación y el control van en capas interiores para actuar sobre datos ya auditados.
d

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.

Nota sobre el orden: 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.
Decoradores en librerías reales y cuándo usarlos leer

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
El ciclo completo de la Clase 6: las funciones son objetos — se pueden pasar y devolver. Las lambdas son funciones anónimas de una expresión. Los closures recuerdan su entorno. Los decoradores son closures cuya free variable es otra función. El paradigma funcional combina todo esto para escribir código predecible, reutilizable y composable. La IA usa estos patrones constantemente — ahora sabes leerlos, entenderlos y supervisarlos.
  1. @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?
  2. En el ejemplo de @property: ¿por qué c.area no necesita paréntesis aunque area es un método? ¿Qué hace exactamente @property?
  3. Este código de IA usa tres decoradores sobre una función de análisis. Aplica la lista de comprobación: ¿está completo y correcto? ¿Cambiarías algo?
    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()
  4. ¿Cuál es la diferencia entre estos dos patrones? ¿Cuándo usarías cada uno?
    # 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")
Respuesta orientativa
a

@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.

b

@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.

c

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.

d

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.

La pregunta clave: ¿quiero que este comportamiento esté siempre activo para esta función? Si sí → decorador. Si es puntual o experimental → llamada directa.