104 - Concepto de decoradores

Imagina que eres un programador y, con el tiempo, te encuentras escribiendo bloques de código que, aunque ligeramente distintos, cumplen funciones muy similares. Tal vez necesitas medir cuánto tiempo tarda en ejecutarse una función, o quizá quieres asegurarte de que un usuario esté autenticado antes de acceder a ciertas partes de tu aplicación. En cada caso, podrías copiar y pegar ese código auxiliar alrededor de tus funciones principales. Sin embargo, esta práctica, conocida como "copiar y pegar", es enemiga de la buena programación: introduce redundancia, dificulta el mantenimiento y es propensa a errores.

¿Qué son los decoradores y por qué son útiles?

En su esencia más simple, un decorador es una función que toma otra función como argumento, le añade alguna funcionalidad y luego devuelve esa nueva función (modificada o "decorada"). Piensa en ello como envolver un regalo: el regalo sigue siendo el mismo, pero el envoltorio le añade presentación, protección o un mensaje especial. De manera similar, los decoradores "envuelven" funciones, alterando o extendiendo su comportamiento sin modificar su código fuente original.

Este proceso de modificar el comportamiento de funciones o clases en tiempo de ejecución se conoce como metaprogramación. Los decoradores son una de las formas más accesibles y utilizadas de metaprogramación en Python, permitiéndonos escribir código más limpio, modular y reutilizable.

Ventajas

1 - Separación de intereses: Permiten separar el código que implementa la lógica de negocio de la función del código que implementa funcionalidades accesorias (logging, caché, validación, etc.).

2 - Reusabilidad: una vez que defines un decorador, puedes aplicarlo a múltiples funciones en diferentes partes de tu código sin tener que reescribir la lógica auxiliar.

3 - Legibilidad: la sintaxis especial de los decoradores (@) hace que sea muy claro a simple vista qué funcionalidades adicionales se están aplicando a una función.

4 - Mantenimiento: si necesitas cambiar cómo funciona una funcionalidad transversal (por ejemplo, cómo se registra el tiempo de ejecución), solo tienes que modificar el decorador en un solo lugar, y todos los lugares donde se use ese decorador se actualizarán automáticamente.

Problema 1

Creación de un decorador mínimo que muestre un mensaje antes y después de ejecutar la función principal.

Programa: ejercicio352.py

def mi_primer_decorador(func):
    """
    Este es un decorador básico que imprime un mensaje
    antes y después de la ejecución de la función.
    """
    def envoltura():
        print("Antes de llamar a la función.")
        func() # Llama a la función original que fue pasada a 'mi_primer_decorador'
        print("Después de llamar a la función.")
    return envoltura # Retorna la función 'envoltura'

# Ahora, definimos una función y la "decoramos"
@mi_primer_decorador
def saludar():
    print("¡Hola desde la función saludar!")

# Llamamos a la función decorada
saludar()

mi_primer_decorador(func): Esta es nuestra función decoradora. Recibe una función (func) como argumento.

def envoltura(): Esta es la función anidada (el wrapper). Esta función es la que reemplazará a la función original después de la decoración.

print("Antes de llamar a la función."): Nuestra lógica adicional que se ejecuta antes.

func(): Aquí es donde se ejecuta la función original que pasamos al decorador. La función envoltura "cierra" sobre func (es un closure).

print("Después de llamar a la función."): Nuestra lógica adicional que se ejecuta después.

return envoltura: El decorador devuelve la función anidada envoltura. Esto es clave: ¡no devuelve el resultado de envoltura, sino la función en sí!

Aplicando el decorador con la sintaxis @.

Ahora que tenemos nuestro decorador, ¿cómo lo usamos? Python proporciona una sintaxis especial, el símbolo @, que hace que aplicar decoradores sea muy intuitivo.

@mi_primer_decorador
def saludar():
    print("¡Hola desde la función saludar!")

Cuando ejecutamos el programa aparece:

Antes de llamar a la función.
¡Hola desde la función saludar!
Después de llamar a la función.

Desglose del proceso: ¿Qué ocurre realmente cuando decoramos una función?

La sintaxis @mi_primer_decorador pasa una función como argumento y asigna a una variable.

Cuando encuentra:

@mi_primer_decorador
def saludar():
    print("¡Hola desde la función saludar!")

Lo que realmente hace internamente es el equivalente a:

def saludar():
    print("¡Hola desde la función saludar!")

saludar = mi_primer_decorador(saludar)

Vamos a desglosarlo paso a paso:

Definición de saludar: Python primero define la función saludar tal como la conocemos. En este punto, saludar es una función normal que imprime "¡Hola desde la función saludar!".

Llamada al decorador: Inmediatamente después de definir saludar, Python llama a mi_primer_decorador, pasándole la función saludar como argumento.

mi_primer_decorador recibe saludar (que ahora internamente se conoce como func).

Dentro de mi_primer_decorador, se define la función anidada envoltura.

envoltura "cierra" sobre la función func (que es nuestra saludar original).

mi_primer_decorador finalmente retorna la función envoltura.

Reasignación de saludar: El valor de retorno de mi_primer_decorador(saludar) (que es la función envoltura) se asigna de nuevo a la variable saludar.

Esto significa que, después de la decoración, la variable saludar ya no apunta a la función original que solo imprimía "¡Hola...!", sino que ahora apunta a la función envoltura que definimos dentro del decorador.

Llamada a la función decorada: Cuando luego llamamos a saludar(), en realidad estamos llamando a la función envoltura().

envoltura() ejecuta print("Antes de llamar a la función.").

Luego, envoltura() llama a func(). Recuerda que func es la saludar original que fue capturada por el closure, así que se imprime "¡Hola desde la función saludar!".

Finalmente, envoltura() ejecuta print("Después de llamar a la función.").

Este es el mecanismo central de cómo funcionan los decoradores. Permiten modificar o extender el comportamiento de funciones de una manera limpia y declarativa, sin alterar su definición original.

Problema 2

Medir el tiempo de ejecución de dos funciones que ordenan una lista, primero sin uso de decoradores y luego empleando decoradores.

Programa: ejercicio353.py

import random
import time

# Ordenamiento Burbuja
def burbuja(lista):
    inicio = time.time()
    n = len(lista)
    for i in range(n - 1):
        for j in range(n - 1 - i):
            if lista[j] > lista[j + 1]:
                lista[j], lista[j + 1] = lista[j + 1], lista[j]
    fin = time.time()
    print(f"Tiempo Burbuja: {fin - inicio:.4f} segundos")

# Ordenamiento Quicksort
def quicksort(lista):
    inicio = time.time()

    def _quicksort(arr):
        if len(arr) <= 1:
            return arr
        pivote = arr[len(arr) // 2]
        izquierda = [x for x in arr if x < pivote]
        medio = [x for x in arr if x == pivote]
        derecha = [x for x in arr if x > pivote]
        return _quicksort(izquierda) + medio + _quicksort(derecha)

    resultado = _quicksort(lista)
    fin = time.time()
    print(f"Tiempo Quicksort: {fin - inicio:.4f} segundos")
    return resultado

# Generar listas con 10000 elementos aleatorios
lista1 = [random.randint(0, 100000) for _ in range(10000)]
lista2 = lista1[:]  # copia para quicksort

# Ejecutar
burbuja(lista1[:])  # paso copia para no modificar la lista original
quicksort(lista2)

Si ejecutamos la aplicación tendremos como resultado unos valores similares a esto (el método Quicksort es mucho más rápido que el método de la burbuja):

Tiempo Burbuja: 3.5378 segundos
Tiempo Quicksort: 0.0140 segundos

Si analizamos ambas funciones, tenemos en común que tienen te tomar la hora actual:

    inicio = time.time()

Y cuando finaliza el ordenamiento de los elementos de la lista, se toma la hora final y se muestran los segundos pasados:

    fin = time.time()
    print(f"Tiempo Quicksort: {fin - inicio:.4f} segundos")

Veamos cual es la sintaxis para crear nuestro primer decorador que tenga como objetivo medir el tiempo de ejecución de una función:

Programa: ejercicio354.py

import random
import time

# Decorador para medir tiempo
def medir_tiempo(func):
    def envoltura(*args, **kwargs):
        inicio = time.time()
        resultado = func(*args, **kwargs)
        fin = time.time()
        print(f"Tiempo {func.__name__}: {fin - inicio:.4f} segundos")
        return resultado
    return envoltura

# Ordenamiento Burbuja
@medir_tiempo
def burbuja(lista):
    n = len(lista)
    for i in range(n - 1):
        for j in range(n - 1 - i):
            if lista[j] > lista[j + 1]:
                lista[j], lista[j + 1] = lista[j + 1], lista[j]
    return lista

# Ordenamiento Quicksort (función pública con decorador)
@medir_tiempo
def quicksort(lista):
    def _quicksort(arr):
        if len(arr) <= 1:
            return arr
        pivote = arr[len(arr) // 2]
        izquierda = [x for x in arr if x < pivote]
        medio = [x for x in arr if x == pivote]
        derecha = [x for x in arr if x > pivote]
        return _quicksort(izquierda) + medio + _quicksort(derecha)
    
    return _quicksort(lista)

# Generar listas con 10000 elementos aleatorios
lista1 = [random.randint(0, 100000) for _ in range(10000)]
lista2 = [random.randint(0, 100000) for _ in range(10000)]

# Ejecutar
burbuja(lista1)   
quicksort(lista2)

El decorador medir_tiempo:

def medir_tiempo(func):
    def envoltura(*args, **kwargs):
        inicio = time.time()
        resultado = func(*args, **kwargs)
        fin = time.time()
        print(f"Tiempo {func.__name__}: {fin - inicio:.4f} segundos")
        return resultado
    return envoltura

Un decorador recibe como parámetro una función (func).

Define una función interna (envoltura) que envuelve a la original.

Dentro de la función 'envoltura':
Se toma el tiempo antes de ejecutar (inicio = time.time())
Se llama a la función original (resultado = func(*args, **kwargs)).
Se toma el tiempo después (fin = time.time()).
Se imprime cuánto tardó (fin - inicio).
Finalmente, devuelve el resultado original de la función.

Ahora cuando escribimos:

@medir_tiempo
def burbuja(lista):
    .........

Es lo mismo que hacer:

burbuja = medir_tiempo(burbuja)

O sea: cada vez que se llama a burbuja(), en realidad se ejecuta envoltura().

El bloque principal:

# Generar listas con 10000 elementos aleatorios
lista1 = [random.randint(0, 100000) for _ in range(10000)]
lista2 = [random.randint(0, 100000) for _ in range(10000)]

# Ejecutar
burbuja(lista1)   
quicksort(lista2)
burbuja(lista1)

Aquí llamas a la función burbuja y le pasas la lista lista1 (que tiene 10.000 números aleatorios).
Como burbuja está decorada con @medir_tiempo, en realidad no se ejecuta directamente la función burbuja que ordena, sino la función envoltura del decorador.

El flujo real es este:
envoltura toma nota del tiempo inicial (inicio = time.time()).
Llama a la función original burbuja(lista1), que hace el ordenamiento burbuja.
Cuando termina, toma nota del tiempo final (fin = time.time()).
Calcula la diferencia y la imprime en pantalla:

Tiempo burbuja: X.XXXX segundos

Devuelve la lista ya ordenada.
Resultado: lista1 queda ordenada con Burbuja, y además se muestra cuánto tiempo tardó.

Un decorador básico sigue un patrón muy específico

  1. Es una función que toma otra función como su único argumento.
  2. Define una función anidada (o envoltura) dentro de sí misma. Esta función anidada es la que contendrá la lógica adicional que queremos aplicar.
  3. Dentro de la función anidada, se llama a la función original que se pasó como argumento.
  4. La función anidada retorna el resultado de la función original.
  5. Finalmente, el decorador retorna la función anidada (o envoltura).