108 - Encadenamiento de decoradores

En Python, podemos aplicar más de un decorador sobre una misma función.
Esto se llama encadenamiento de decoradores y es muy útil cuando queremos aplicar varias mejoras combinadas, como:

Medir el tiempo de ejecución.
Registrar llamadas en un log.
Validar permisos o datos.
Agregar trazas de depuración.

El orden de ejecución cuando apilamos decoradores:

@decorador1
@decorador2
def funcion():
    ...

Esto equivale a:

funcion = decorador1(decorador2(funcion))

Es decir, el decorador más cercano a la función se aplica primero, pero en tiempo de ejecución la llamada pasa primero por el decorador que quedó más arriba en la pila.

Programa: ejercicio364.py

def decorador_a(func):
    def envoltura(*args, **kwargs):
        print("Entrando en A")
        resultado = func(*args, **kwargs)
        print("Saliendo de A")
        return resultado
    return envoltura

def decorador_b(func):
    def envoltura(*args, **kwargs):
        print("Entrando en B")
        resultado = func(*args, **kwargs)
        print("Saliendo de B")
        return resultado
    return envoltura

@decorador_a
@decorador_b
def saludar():
    print("Hola mundo")

saludar()

Cuando se ejecuta tenemos como salida:

Entrando en A
Entrando en B
Hola mundo
Saliendo de B
Saliendo de A

Primero se aplica decorador_b (el más cercano a la función).
Luego decorador_a envuelve todo.
En tiempo de ejecución, se entra primero en A ? luego en B ? función ? se sale de B ? se sale de A.

Problema 1

Encadenar un decorador de autenticación y otro de llamada.

Programa: ejercicio365.py

def autenticar(func):
    def envoltura(*args, **kwargs):
        print(" Verificando credenciales...")
        # Aquí podrías validar usuario/contraseña, token, etc.
        return func(*args, **kwargs)
    return envoltura

def registrar(func):
    def envoltura(*args, **kwargs):
        print(f"Llamando a {func.__name__} con args={args}, kwargs={kwargs}")
        resultado = func(*args, **kwargs)
        print(f"{func.__name__} ejecutada con éxito")
        return resultado
    return envoltura

@autenticar
@registrar
def transferir(dinero, cuenta_destino):
    print(f"Transfiriendo {dinero} a {cuenta_destino}")
    return True

# Ejecutar
transferir(100, "Cuenta123")

Flujo de ejecución
Primero se aplica @registrar envuelve la función transferir.

Luego se aplica @autenticar envuelve el resultado anterior.

El orden final al llamar transferir(100, "Cuenta123") es:

Verificando credenciales...
Llamando a transferir con args=(100, 'Cuenta123'), kwargs={}
Transfiriendo 100 a Cuenta123
transferir ejecutada con éxito

Problema 2

Definir dos decoradores con parámetros, uno que permita definir el rango de valores válidos y otro que haga un redondeo.

Programa: ejercicio366.py

def validar_rango(min_val, max_val):
    def decorador(func):
        def envoltura(*args, **kwargs):
            for arg in args:
                if not (arg >= min_val and arg <= max_val):
                    raise ValueError(f"Valor {arg} fuera del rango [{min_val}, {max_val}]")
            return func(*args, **kwargs)
        return envoltura
    return decorador

def redondear(decimales=2):
    def decorador(func):
        def envoltura(*args, **kwargs):
            resultado = func(*args, **kwargs)
            return round(resultado, decimales)
        return envoltura
    return decorador

@validar_rango(0, 100)   # primero valida que los valores estén en 0–100
@redondear(3)            # luego redondea el resultado a 3 decimales
def promedio(a, b):
    return (a + b) / 2

# Pruebas
print(promedio(50, 75))   #  válido ? 62.5
print(promedio(10, 20))   #  válido ? 15.0
print(promedio(150, 20))  #  lanza ValueError

El decorador @redondear(3) envuelve primero promedio.

Luego @validar_rango(0, 100) envuelve todo lo anterior.

(El orden de escritura es inverso al orden de ejecución).