tutoriales.com

Optimiza Tareas Repetitivas con Decoradores en Python: Una Guía Avanzada

Este tutorial te guiará a través del concepto de decoradores en Python, desde sus fundamentos hasta su aplicación en escenarios avanzados. Aprenderás a crear decoradores personalizados, entenderás cómo encadenarlos y descubrirás patrones de diseño comunes que simplificarán tu código y aumentarán su reutilización. Ideal para desarrolladores que buscan llevar sus habilidades de Python al siguiente nivel.

Avanzado20 min de lectura22 views
Reportar error

Los decoradores en Python son una herramienta poderosa y elegante que te permiten modificar o extender la funcionalidad de funciones o métodos sin alterar su código fuente. Son esencialmente funciones que toman otras funciones como argumento y devuelven una nueva función (o la función original modificada).

Si alguna vez has necesitado añadir logging, medir el tiempo de ejecución, implementar caché, o realizar validación de permisos de forma repetitiva en varias funciones, los decoradores son tu solución ideal. Te ayudarán a mantener tu código DRY (Don't Repeat Yourself) y mucho más legible.

Este tutorial te sumergirá en el mundo de los decoradores, desde los conceptos básicos hasta técnicas avanzadas, para que puedas utilizarlos eficazmente en tus proyectos.

🎯 ¿Qué son los Decoradores en Python? Una Introducción

En su forma más simple, un decorador es una función que envuelve a otra función para extender su comportamiento. Esto se logra gracias a que Python trata a las funciones como ciudadanos de primera clase, lo que significa que pueden ser pasadas como argumentos, devueltas por otras funciones y asignadas a variables.

Imagina que tienes una función y quieres añadirle alguna funcionalidad extra antes o después de su ejecución, como registrar un mensaje o calcular cuánto tiempo tarda en ejecutarse. Podrías modificar la función directamente, pero si necesitas hacer esto en muchas funciones, el código se volvería repetitivo y difícil de mantener.

Aquí es donde los decoradores brillan. Permiten envolver la función original con una lógica adicional de manera limpia y declarativa usando la sintaxis @.

💡 Funciones de Primera Clase

Para entender los decoradores, es crucial comprender el concepto de funciones de primera clase en Python. Esto significa que las funciones pueden:

  • Ser asignadas a variables.
  • Ser pasadas como argumentos a otras funciones.
  • Ser devueltas como resultado de otras funciones.
  • Ser almacenadas en estructuras de datos como listas o diccionarios.
def saludo(nombre):
    return f"Hola, {nombre}!"

# Asignar a una variable
mi_saludo = saludo
print(mi_saludo("Alice"))

# Pasar como argumento
def ejecutar_funcion(func, arg):
    return func(arg)

print(ejecutar_funcion(saludo, "Bob"))

# Devolver como resultado
def crear_multiplicador(n):
    def multiplicador(x):
        return x * n
    return multiplicador

doble = crear_multiplicador(2)
triple = crear_multiplicador(3)

print(doble(5))
print(triple(5))

🛠️ Creando Tu Primer Decorador Sencillo

Vamos a construir un decorador simple que imprima un mensaje antes y después de que se ejecute una función. Este es el patrón básico que seguirás para crear la mayoría de los decoradores.

Un decorador típicamente tiene la siguiente estructura:

  1. Una función externa (el decorador en sí) que toma la función a decorar como argumento.
  2. Una función interna (o wrapper) que contendrá la lógica adicional y llamará a la función original.
  3. La función externa devuelve la función interna.
def mi_decorador(func):
    def wrapper(*args, **kwargs):
        print("Antes de llamar a la función.")
        resultado = func(*args, **kwargs)
        print("Después de llamar a la función.")
        return resultado
    return wrapper

@mi_decorador
def decir_hola():
    print("¡Hola desde la función original!")

@mi_decorador
def sumar(a, b):
    print(f"Sumando {a} y {b}")
    return a + b

decir_hola()
print("-" * 20)
print(f"Resultado de la suma: {sumar(5, 3)}")

Explicación:

  • mi_decorador(func): Esta es la función decoradora. Recibe func (la función que queremos decorar) como argumento.
  • wrapper(*args, **kwargs): Esta es la función interna. Se encarga de envolver la llamada a func. El uso de *args y **kwargs es crucial para que wrapper pueda aceptar cualquier número de argumentos posicionales y de palabra clave que la función original func pudiera recibir.
  • resultado = func(*args, **kwargs): Aquí se llama a la función original con sus argumentos.
  • return wrapper: El decorador devuelve la función wrapper.
  • @mi_decorador: Esta es la azúcar sintáctica de Python. Es equivalente a hacer decir_hola = mi_decorador(decir_hola).
💡 Consejo: Es una buena práctica usar functools.wraps para preservar la metadata de la función original (nombre, docstrings, etc.) cuando se usa un decorador.
import functools

def mi_decorador_con_wraps(func):
    @functools.wraps(func) # Esto es importante!
    def wrapper(*args, **kwargs):
        print(f"Antes de llamar a '{func.__name__}'.")
        resultado = func(*args, **kwargs)
        print(f"Después de llamar a '{func.__name__}'.")
        return resultado
    return wrapper

@mi_decorador_con_wraps
def mi_funcion_con_docstring(x, y):
    """Esta es una función de ejemplo con docstring."""
    return x * y

print(f"Nombre de la función: {mi_funcion_con_docstring.__name__}")
print(f"Docstring: {mi_funcion_con_docstring.__doc__}")
print(f"Resultado: {mi_funcion_con_docstring(2, 4)}")

Sin @functools.wraps(func), mi_funcion_con_docstring.__name__ devolvería 'wrapper' y __doc__ sería None.


⚙️ Decoradores con Argumentos

A menudo querrás que tus decoradores sean configurables, es decir, que acepten argumentos propios. Para lograr esto, necesitamos una capa adicional de anidamiento.

Un decorador con argumentos es una función que, al ser llamada con sus argumentos, devuelve el verdadero decorador (una función que toma la función a decorar como argumento y devuelve el wrapper).

def decorador_con_argumentos(arg1, arg2):
    def mi_verdadero_decorador(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            print(f"Decorador configurado con: {arg1}, {arg2}")
            print(f"Llamando a '{func.__name__}'.")
            resultado = func(*args, **kwargs)
            print("Función terminada.")
            return resultado
        return wrapper
    return mi_verdadero_decorador

@decorador_con_argumentos("valor_A", "valor_B")
def saludar_personalizado(nombre):
    return f"Saludos cordiales, {nombre}!"

@decorador_con_argumentos(True, 100)
def multiplicar(a, b):
    return a * b

print(saludar_personalizado("Alicia"))
print("-" * 20)
print(f"Multiplicación: {multiplicar(7, 8)}")

Explicación:

  1. decorador_con_argumentos(arg1, arg2): Esta es la función externa que recibe los argumentos arg1 y arg2 para configurar el decorador.
  2. mi_verdadero_decorador(func): Esta es la función que realmente actúa como decorador. Es la que recibe la función a decorar (func).
  3. wrapper(*args, **kwargs): Como antes, esta es la función que envuelve a func y añade la lógica, utilizando arg1 y arg2 que fueron capturados del ámbito superior (closure).

Este patrón te permite crear decoradores muy flexibles y reutilizables.


🔗 Encadenando Decoradores

Puedes aplicar múltiples decoradores a una sola función. Cuando haces esto, los decoradores se aplican en el orden inverso en que se definen, de abajo hacia arriba. Es decir, el decorador más cercano a la función se aplica primero, y su resultado es decorado por el siguiente, y así sucesivamente.

def make_bold(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return "<b>" + func(*args, **kwargs) + "</b>"
    return wrapper

def make_italic(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return "<i>" + func(*args, **kwargs) + "</i>"
    return wrapper

def add_paragraph(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return "<p>" + func(*args, **kwargs) + "</p>"
    return wrapper

@add_paragraph
@make_italic
@make_bold
def get_texto():
    return "Este es un texto importante"

print(get_texto())

El resultado será: <p><i><b>Este es un texto importante</b></i></p>. Se aplica primero make_bold, luego make_italic al resultado de make_bold, y finalmente add_paragraph al resultado de make_italic.

@decorador_3 @decorador_2 @decorador_1 Función Original 1º Aplicación 2º Aplicación 3º Aplicación Encadenamiento de Decoradores (Stacking)

✨ Casos de Uso Comunes de Decoradores

Los decoradores no son solo un truco elegante; son una herramienta esencial para la programación orientada a aspectos (AOP) y la mejora de la modularidad del código. Aquí te presento algunos de los usos más comunes y prácticos.

⏱️ Medición del Tiempo de Ejecución

Un decorador para medir cuánto tiempo tarda una función en ejecutarse puede ser increíblemente útil para la optimización y depuración de rendimiento.

import time
import functools

def medir_tiempo(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        inicio = time.perf_counter() # Mejor para medir rendimiento
        resultado = func(*args, **kwargs)
        fin = time.perf_counter()
        tiempo_ejecucion = fin - inicio
        print(f"La función '{func.__name__}' tardó {tiempo_ejecucion:.4f} segundos.")
        return resultado
    return wrapper

@medir_tiempo
def tarea_lenta(segundos):
    time.sleep(segundos)
    print("¡Tarea lenta completada!")

@medir_tiempo
def calcular_fibonacci(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

tarea_lenta(1.5)
print("-" * 20)
print(f"Fibonacci(20): {calcular_fibonacci(20)}")
print(f"Fibonacci(1000): {calcular_fibonacci(1000)}")

📝 Logging

Añadir logging a funciones específicas puede ser crucial para el monitoreo y la depuración de aplicaciones. Un decorador lo hace sin ensuciar tu lógica de negocio.

import logging
import functools

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

def log_llamada(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        args_repr = [repr(a) for a in args]
        kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
        signature = ", ".join(args_repr + kwargs_repr)
        logging.info(f"Llamando a '{func.__name__}' con argumentos: ({signature})")
        try:
            resultado = func(*args, **kwargs)
            logging.info(f"'{func.__name__}' retornó: {resultado!r}")
            return resultado
        except Exception as e:
            logging.error(f"Excepción en '{func.__name__}': {e}")
            raise
    return wrapper

@log_llamada
def dividir(a, b):
    return a / b

@log_llamada
def multiplicar(x, y):
    return x * y

print(f"División: {dividir(10, 2)}")
print(f"Multiplicación: {multiplicar(a=5, y=4)}")
try:
    dividir(10, 0)
except ZeroDivisionError:
    pass # Esperamos la excepción para ver el log de error

💾 Caché / Memoización

Para funciones que son costosas de computar y que a menudo se llaman con los mismos argumentos, un decorador de caché puede mejorar drásticamente el rendimiento almacenando los resultados de llamadas previas.

📌 Nota: Python ya ofrece @functools.lru_cache para esto, pero construir uno manualmente ayuda a entender el concepto.
import functools

def cache_resultado(func):
    cache = {}
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Convertir args y kwargs a una tupla inmutable para usar como clave de diccionario
        key = args + tuple(sorted(kwargs.items()))
        if key not in cache:
            print(f"Calculando '{func.__name__}' para {key}...")
            cache[key] = func(*args, **kwargs)
        else:
            print(f"Usando caché para '{func.__name__}' para {key}.")
        return cache[key]
    return wrapper

@cache_resultado
def fibonacci_costoso(n):
    if n <= 1:
        return n
    return fibonacci_costoso(n-1) + fibonacci_costoso(n-2)

print(f"Fibonacci(10): {fibonacci_costoso(10)}")
print(f"Fibonacci(5): {fibonacci_costoso(5)}") # Este ya se calculó parcialmente
print(f"Fibonacci(10): {fibonacci_costoso(10)}") # Debería usar caché
print(f"Fibonacci(12): {fibonacci_costoso(12)}")

🛡️ Autenticación y Autorización (Permisos)

En aplicaciones web o APIs, los decoradores son ideales para verificar si un usuario está autenticado o tiene los permisos necesarios para acceder a una función o ruta.

import functools

def requiere_rol(rol_requerido):
    def decorador_real(func):
        @functools.wraps(func)
        def wrapper(usuario, *args, **kwargs):
            if usuario.get('rol') == rol_requerido:
                print(f"Usuario '{usuario['nombre']}' con rol '{usuario['rol']}' autorizado para '{func.__name__}'.")
                return func(usuario, *args, **kwargs)
            else:
                print(f"⚠️ Acceso denegado para '{usuario['nombre']}'. Se requiere rol '{rol_requerido}'.")
                return None # O lanzar una excepción
        return wrapper
    return decorador_real

# Simulamos usuarios
usuario_admin = {'nombre': 'AdminUser', 'rol': 'administrador'}
usuario_normal = {'nombre': 'NormalUser', 'rol': 'usuario'}

@requiere_rol('administrador')
def borrar_datos_sensibles(usuario, id_registro):
    print(f"Eliminando registro {id_registro} por {usuario['nombre']} (admin)...")
    return True

@requiere_rol('usuario')
def ver_perfil(usuario, user_id):
    print(f"Accediendo al perfil {user_id} por {usuario['nombre']} (usuario)...")
    return {'user_id': user_id, 'data': 'some_data'}

# Pruebas
borrar_datos_sensibles(usuario_admin, 123)
borrar_datos_sensibles(usuario_normal, 456) # Acceso denegado

ver_perfil(usuario_normal, 789)
ver_perfil(usuario_admin, 101) # Acceso denegado, requiere rol 'usuario'

📝 Decoradores de Clases

Hasta ahora hemos visto decoradores que actúan sobre funciones. Pero, ¿qué pasa si queremos decorar una clase entera? Python también permite esto.

Un decorador de clases es una función (o una clase) que toma una clase como argumento y devuelve una nueva clase (o la misma clase modificada).

def registra_clase(cls):
    print(f"Registrando clase: {cls.__name__}")
    setattr(cls, '_registrado_por_decorador', True)
    # Podemos añadir métodos, modificar atributos, etc.
    def nuevo_metodo(self):
        return f"Este es un método añadido por el decorador en {self.__class__.__name__}"
    cls.metodo_decorado = nuevo_metodo
    return cls

@registra_clase
class MiClase:
    def __init__(self, nombre):
        self.nombre = nombre

    def get_nombre(self):
        return self.nombre

instancia = MiClase("Ejemplo")
print(f"¿Clase registrada? {hasattr(MiClase, '_registrado_por_decorador')}")
print(f"Nombre de la instancia: {instancia.get_nombre()}")
print(f"Método añadido: {instancia.metodo_decorado()}")

También puedes crear un decorador que sea una clase. Una clase puede actuar como decorador si implementa el método __call__ (convirtiéndola en un callable). El __init__ se llamará una vez (cuando se define la función decorada) y __call__ se llamará cada vez que se ejecute la función decorada.

class ContadorLlamadas:
    def __init__(self, func):
        functools.wraps(func)(self) # Preservar metadata
        self.func = func
        self.num_llamadas = 0

    def __call__(self, *args, **kwargs):
        self.num_llamadas += 1
        print(f"Llamada {self.num_llamadas} a la función '{self.func.__name__}'")
        return self.func(*args, **kwargs)

@ContadorLlamadas
def funcion_a_contar(x, y):
    return x * y

print(f"Resultado 1: {funcion_a_contar(2, 3)}")
print(f"Resultado 2: {funcion_a_contar(4, 5)}")
print(f"Resultado 3: {funcion_a_contar(1, 1)}")
print(f"Número total de llamadas: {funcion_a_contar.num_llamadas}")
print(f"Nombre original de la función: {funcion_a_contar.__name__}")
⚠️ Advertencia: Al usar una clase como decorador, si no utilizas functools.wraps(func)(self) en el __init__, la función decorada perderá su nombre original y docstring.

🔮 Buenas Prácticas y Consideraciones

Para usar decoradores de manera efectiva, ten en cuenta las siguientes recomendaciones:

  • Usa functools.wraps siempre: Esto asegura que la función decorada conserve su __name__, __doc__, __module__, y __annotations__, lo que es vital para la depuración y la introspección del código.
  • Mantén los decoradores pequeños y enfocados: Un decorador debe hacer una cosa y hacerla bien. Evita decoradores que intenten resolver demasiados problemas a la vez.
  • Documenta tus decoradores: Explica claramente qué hace el decorador y cómo se usa en su docstring.
  • Cuidado con la complejidad: El abuso de decoradores muy anidados o complejos puede dificultar la lectura y depuración del código. Úsalos donde aporten claridad y reducción de la repetición.
  • Orden de aplicación: Recuerda que los decoradores se aplican de abajo hacia arriba cuando se encadenan. Ten esto en cuenta si el orden de ejecución de la lógica es importante.

Tabla Comparativa: Pros y Contras de los Decoradores

CaracterísticaProsContras
---------
ModularidadSepara la lógica transversal de la lógica de negocio.Puede ocultar la implementación real de una función, dificultando la comprensión del flujo para nuevos desarrolladores.
ReutilizaciónPermite aplicar la misma funcionalidad a múltiples funciones.Si un decorador es demasiado específico, puede no ser tan reutilizable como se espera.
---------
LegibilidadSintaxis @ clara e intuitiva para indicar funcionalidad extra.Debugging puede ser más complejo debido a las capas de abstracción.
DRY PrincipleReduce la repetición de código.Un uso excesivo o incorrecto puede generar un código más complejo y difícil de mantener.
---------
ExtensibilidadFácil de añadir o quitar funcionalidades sin modificar la función original.El orden de los decoradores encadenados importa, lo que puede llevar a errores sutiles si no se entiende bien.
90% Comprensión

🚀 Conclusión

Los decoradores de Python son una herramienta extraordinariamente potente para mejorar la modularidad, reusabilidad y legibilidad de tu código. Al encapsular lógica transversal, te permiten escribir código más limpio, robusto y fácil de mantener.

Desde añadir funcionalidades de logging y rendimiento hasta implementar sistemas de caché y permisos, los decoradores abren un abanico de posibilidades para la programación elegante en Python. Dominarlos es un paso clave para convertirte en un desarrollador de Python más avanzado y eficiente.

Practica creando tus propios decoradores para diferentes escenarios y pronto descubrirás lo indispensables que pueden ser en tu kit de herramientas de Python.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!