Decoradores en Python: Qué son, cómo crear uno y ejemplos

Publicado el 23 septiembre 2024 por Daniel Rodríguez @analyticslane

Python es un lenguaje de programación que destaca por su simplicidad, flexibilidad y con el que es fácil escribir código limpio. Siendo los decoradores una de las características del lenguaje que más ayudan a esto. Los decoradores permiten extender el comportamiento de las funciones y métodos de una manera elegante, facilitando la reutilización del código. En esta entrada, se explorará qué son los decoradores en Python, cómo se pueden crear decoradores personalizados y las ventajas de su uso, incluyendo ejemplos con los que ilustrar su utilidad.

¿Qué son los decoradores en Python?

En Python, un decorador es una función que recibe otra función como argumento, modifica su comportamiento y devuelve una nueva función. Permitiendo de este modo inyectar código antes o después de llamar a la función original. Los decoradores se utilizan habitualmente para realizar tareas de autenticación, validación, modificación del flujo de control, depuración o administración de recursos, entre otras.

Sintaxis básica de un decorador

La sintaxis básica para aplicar un decorador a una función es utilizando el símbolo @ seguido del nombre del decorador justo antes de la definición de la función. En el siguiente código se muestra un ejemplo básico:

def mi_decorador(func):
    def nueva_funcion():
        print("Algo se ejecuta antes de la función principal")
        func()
        print("Algo se ejecuta después de la función principal")
    return nueva_funcion


@mi_decorador
def funcion_principal():
    print("Esta es la función principal")

    
funcion_principal()

En este caso, al llamar a funcion_principal() se mostrará por pantalla tanto los mensajes del decorador como los de la función. El resultado de ejecutar el código será:

Algo se ejecuta antes de la función principal
Esta es la función principal
Algo se ejecuta después de la función principal

Básicamente lo que se ha implementado en este ejemplo es:

  1. Definición del decorador: mi_decorador es una función que toma una función como argumento (func). En su código, se define y retorna una nueva función (nueva_funcion) basada en la función que se recibe como argumento.
  2. Aplicación del decorador: La línea @mi_decorador antes de la definición de funcion_principal indica que el decorador se debe aplicar a la función. Esto hace que en el código funcion_principal sea realmente nueva_funcion.
  3. Ejecución: Ahora, cuando se llama a funcion_principal(), en realidad se está llamando a nueva_funcion. La cual primero imprime el primer mensaje, llama a la función original func() y finalmente imprime otro mensaje.

Decoradores anidados

Los decoradores se pueden anidar, esto es, se puede aplicar más de un decorador a la misma función. Aplicándose los decoradores de manera envolvente, es decir, el último decorador aplicado será el primero en ejecutarse. Esto es lo que se muestra en el siguiente código de ejemplo.

def decorador_1(func):
    def nueva_funcion_1():
        print("Decorador 1 antes")
        func()
        print("Decorador 1 después")
    return nueva_funcion_1


def decorador_2(func):
    def nueva_funcion_2():
        print("Decorador 2 antes")
        func()
        print("Decorador 2 después")
    return nueva_funcion_2


@decorador_1
@decorador_2
def funcion_principal():
    print("Función principal")

    
funcion_principal()

El resultado será:

Decorador 1 antes
Decorador 2 antes
Función principal
Decorador 2 después
Decorador 1 después

Como se puede ver en la salida, primero se aplica decorador_2 , el último decorador, y sobre la función que devuelve este el decorador_1.

Ventajas de usar decoradores

El uso de decoradores en Python ofrece múltiples ventajas, especialmente cuando se desea escribir código limpio y reutilizable. A continuación, se detallan algunas de las principales ventajas:

  1. Reutilización del código: Los decoradores permiten encapsular funcionalidades comunes que pueden ser reutilizadas en múltiples funciones. Esto evita la repetición de código y hace que el mantenimiento sea más sencillo.
  2. Separación de responsabilidades: Al utilizar decoradores, se puede mantener el código de la función principal limpio y enfocado en su tarea específica. Lo que hace el código más limpio y fácil de mantener. El código relacionado con otras responsabilidades, como la validación de entradas o la gestión de recursos, se pueden manejarse externamente a través de decoradores.
  3. Modificación de comportamiento: Los decoradores permiten modificar el comportamiento de una función sin cambiar su código fuente. Esto es útil a la hora de añadir funcionalidades como una caché, autenticación o el registro de eventos.
  4. Legibilidad: La sintaxis de los decoradores es clara y explícita. Cuando se escribe @decorador encima de una función, se sabe que se está aplicando una transformación o añadido a esa función, lo cual mejora la legibilidad del código.
  5. Manejo de tareas comunes: Los decoradores pueden realizar tareas comunes de manera centralizada, como medir el tiempo de ejecución, manejo de excepciones o la autenticación de usuarios, asegurando que todas las funciones decoradas compartan estas características.

Casos de uso de los decoradores en Python

Una vez que se comprende que son los decoradores y algunas de sus ventajas, se pueden ver algunos casos prácticos de estos a modo de ejemplo.

Caso 1: Decoradores para medir el tiempo de ejecución

Uno de los usos más habituales de los decoradores es medir el tiempo de ejecución de una función. Algo especialmente útil durante la optimización de código, donde es necesario identificar qué partes del código están tardando más tiempo en ejecutarse.

import time


def medir_tiempo(func):
    def wrapper(*args, **kwargs):
        inicio = time.time()  # Capturar el tiempo de inicio
        resultado = func(*args, **kwargs)  # Ejecutar la función original
        fin = time.time()  # Capturar el tiempo al finalizar
        print(f"Tiempo de ejecución: {fin - inicio:.4f} segundos")
        return resultado
    return wrapper


@medir_tiempo
def funcion_lenta():
    time.sleep(2)  # Simular una función que tarda 2 segundos en ejecutarse
    print("Función lenta ejecutada")

    
funcion_lenta()

En el decorador medir_tiempo la función wrapper toma cualquier número de argumentos (*args, **kwargs), lo que permite decorar funciones con diferentes firmas. Dentro del decorador se usa time.time() para obtener el tiempo antes y después de la ejecución de la función. La diferencia entre ambos valores, el tiempo transcurrido, se imprime en segundos con cuatro decimales.

El resultado al ejecutar funcion_lenta() será algo como:

Función lenta ejecutada
Tiempo de ejecución: 2.0055 segundos

Caso 2: Decoradores para crear un registro

Otro caso de uso especialmente útil de los decoradores es la creación de un registro de las llamadas a una función. Lo que permite realizar una auditoría o depuración de los programas. A continuación, se muestra un ejemplo de este caso.

def registrar_accion(func):
    def wrapper(*args, **kwargs):
        print(f"Llamando a {func.__name__} con {args} y {kwargs}")
        resultado = func(*args, **kwargs)
        print(f"{func.__name__} terminó de ejecutarse")
        return resultado
    return wrapper


@registrar_accion
def sumar(a, b):
    return a + b


@registrar_accion
def saludar(nombre):
    print(f"Hola, {nombre}!")

    
suma = sumar(3, 5)
saludar("Juan")

El decorador registrar_accion envuelve la función original e imprime un mensaje antes y después de su ejecución, mostrando el nombre de la función y sus argumentos. Unos registros extremadamente útiles para monitorear y depurar aplicaciones.

La salida del código será:

Llamando a sumar con (3, 5) y {}
sumar terminó de ejecutarse
Llamando a saludar con ('Juan',) y {}
Hola, Juan!
saludar terminó de ejecutarse

Caso 3: Decoradores para el controlar el acceso

En aplicaciones más complejas, a menudo es necesario controlar el acceso a ciertas funciones o métodos, especialmente en aplicaciones web o sistemas con múltiples usuarios. Verificar si un usuario tiene los permisos adecuados antes de permitir la ejecución de una función es algo que se puede hacer fácilmente con decoradores.

def verificar_acceso(func):
    def wrapper(usuario, *args, **kwargs):
        if usuario != "admin":
            print("Acceso denegado")
            return
        return func(usuario, *args, **kwargs)
    return wrapper


@verificar_acceso
def cambiar_configuracion(usuario, nueva_configuracion):
    print(f"Configuración cambiada a {nueva_configuracion}")

    
cambiar_configuracion("admin", "Modo oscuro")
cambiar_configuracion("invitado", "Modo claro")

Este decorador verificar_acceso comprueba si el usuario tiene permisos para ejecutar la función. Si el usuario no es "admin", el acceso es denegado y la función no se ejecuta. Este tipo de decorador es fundamental en aplicaciones que requieren control de acceso y permisos.

La salida del código será:

Configuración cambiada a Modo oscuro
Acceso denegado

Indicando que solo la primera llamada se ha ejecutado, mientras que en la segunda se ha denegado el acceso.

Caso 4: Decoradores para reintentar operaciones

Los decoradores también pueden recibir parámetros, lo que permite modificar su comportamiento. Permitiendo modificar el comportamiento del decorador en cada caso. Algo que puede ser interesante, por ejemplo, cuando se desea reintentar la ejecución de una función cuando esta falla debido a una excepción, permitiendo cambiar el número de reintentos. En el siguiente ejemplo, se puede ver la implementación de un decorador parametrizado para controlar el número de reintentos:

def reintentar(veces):
    def decorador(func):
        def wrapper(*args, **kwargs):
            for _ in range(veces):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Error: {e}. Reintentando...")
            print(f"Operación fallida después de {veces} intentos")
        return wrapper
    return decorador


@reintentar(3)
def division(a, b):
    return a / b


print(division(10, 2))
print(division(10, 0))

El decorador reintentar toma un argumento veces, que determina cuántas veces debe reintentar la ejecución de la función decorada en caso de fallo. Si la función lanza una excepción, se reintentará el número de veces especificado.

La salida del código será:

5.0
Error: division by zero. Reintentando...
Error: division by zero. Reintentando...
Error: division by zero. Reintentando...
Operación fallida después de 3 intentos
None

Conclusiones

Los decoradores en Python son una herramienta extremadamente útil para escribir código limpio, eficiente y mantenible. Al permitir modificar o extender el comportamiento de las funciones de manera elegante, los decoradores facilitan la reutilización del código y la separación de responsabilidades. La capacidad de crear decoradores personalizados permite adaptar las soluciones a las necesidades específicas de cada proyecto, haciendo que el código sea más flexible y robusto.

Imagen de Mariya en Pixabay