Decoradores

Decoradores



Los decoradores son herramientas muy útiles y, una vez entendidos correctamente, fáciles de implementar. Así que nos hemos propuesto ir paso por paso en el conocimiento de ellos, para entender de qué se trata y cómo emplearlos.

Creando un decorador

Pues bien, ¿qué es un decorador? Antes de responder esta pregunta, recordemos cómo se ve uno para entender sobre qué estamos hablando.

@decorator
def func():
    pass

Aquí observamos un decorador de nombre decorator siendo aplicado sobre una función func(). De esto se infiere que los decoradores son algo que se aplica a las funciones. ¡Correcto! Es así. De hecho, los decoradores son en sí mismos funciones, que toman como argumento una función y retornan otra función. Aún más, el código anterior cumple el mismo objetivo que el siguiente.

def func():
    pass
func = decorator(func)

Ahora la definición tiene un poco más de sentido. En este código ─que, reitero, opera igual que el primero─ nuestro decorator es una función a la que le pasamos como argumento otra función (func()) y el cual retorna una nueva función que es asignada a func. Claro, aún no hemos definido el código para que ello suceda efectivamente. Puede que tampoco vislumbremos aun qué tipo de finalidad podría tener un decorador.

Como sea, la razón por la que Python incluye a partir de su versión 2.4 la sintaxis del arroba es, principalmente, porque los decoradores aplicados tal como lo hicimos en nuestro segundo código pueden quedar muy lejos de la definición de la función si su cuerpo es muy extenso. En cambio, la nueva sintaxis permite ver claramente qué decoradores están operando sobre una función.

Vayamos a un caso concreto. Tomemos como ejemplo la siguiente función.

def add(a, b):
    return a + b

Supongamos, ahora, que queremos imprimir un mensaje en pantalla cada vez que esta función sea invocada. Para ello bien podríamos usar un decorador.

@debug
def add(a, b):
    return a + b

Ahora bien, llega el momento de escribir el código de nuestro decorador. Recordemos que debe ser una función que tome como argumento a otra función.

def debug(f):
    pass

Pero, además, debía retornar una nueva función. Así que haremos exactamente eso.

def debug(f):
    def new_function():
        pass
    return new_function

¡Todo válido! Recuerda que Python nos permite definir unas funciones dentro de otras. Encontes, volviendo a la aplicación de nuestro decorador, dijimos que el código sería equivalente al siguiente.

def add(a, b):
    return a + b
add = debug(add)

De modo que para que todas las llamadas a add() en nuestro programa sigan funcionando, el decorador debe retornar una nueva función con la misma estructura de argumentos y funcionamiento que la original (esto es, tomar dos objetos como argumentos y retornar la suma que resulta de ellos). Sin embargo, nuestra new_function() no tiene ningún argumento; arreglemos eso.

def debug(f):
    def new_function(a, b):
        pass
    return new_function

Y, por último, no hace falta que repliquemos el código de add() en new_function(), dado que ya la tenemos provista como un argumento. Simplemente podemos hacer:

def debug(f):
    def new_function(a, b):
        return f(a, b)
    return new_function

¡Perfecto! El decorador ya podrá ser aplicado a nuestra función sin errores. Ahora faltaría que imprima un mensaje en pantalla, como habíamos planeado.

def debug(f):
    def new_function(a, b):
        print("Function add() called!")
        return f(a, b)
    return new_function


@debug
def add(a, b):
    return a + b


print(add(7, 5))

¡Felicitaciones! Ese fue nuestro primer experimento con decoradores.

Sigamos un poco más. Consideremos esta otra función que retorna el opuesto de un número n.

def neg(n):
    return n * -1

Dado que nuestro decorador fue creado específicamente para la función add(), que requería dos argumentos, fallará al ser aplicado en este caso.

@debug
def neg(n):
    return n * -1

# TypeError!
print(neg(5))

El error es particularmente el siguiente.

TypeError: new_function() missing 1 required positional argument: 'b'

De modo que la solución consistiría en ajustar la estructura de new_function() para recibir un único argumento (tal como neg()) ─que haría que ahora falle el decorador al ser aplicado en add()─ o bien incluir una notación más genérica para aceptar todo tipo de argumentos posicionales y por nombre.

def debug(f):
    def new_function(*args, **kwargs):
        print(f"Function {f.__name__}() called!")
        return f(*args, **kwargs)
    return new_function

(Nota: ¡no confundir las «f» de las líneas tres y cuatro! La primera indica que queremos darle formato a una cadena; la segunda hace referencia al argumento f).

Si no reconoces esta sintaxis incluida en los argumentos, véase Argumentos en funciones (*args y **kwargs). Además, cambiamos el mensaje para que siempre indique el nombre de la función correspondiente (f.__name__).

Ahora sí, nuestro decorador está listo para ser aplicado a cualquier tipo de función.

Decoradores con argumentos

Aún más interesante resulta ser que los decoradores también pueden tener argumentos. Supongamos que, siguiendo con nuestro ejemplo, queremos que debug incluya una opción para interrumpir la ejecución del programa y lanzar el depurador cuando se efectúa la llamada a una función. La sintaxis podría rezar así.

@debug(breakpoint=True)
def func():
    pass

¿Cómo podríamos implementar esto? Tomémonos el trabajo de traducir el código a su equivalente sin la sintaxis del arroba, como habíamos visto.

def func():
    pass
func = debug(breakpoint=True)(func)

Aquí queda mas claro que ahora el argumento de la función debug() no podrá ser f, sino más bien breakpoint y que ésta debe retornar nuestro decorador anterior, al que le será pasado la función func(). Sería algo más o menos así:

def debug(breakpoint=False):
    def debug_decorator(f):
        def new_function(*args, **kwargs):
            print(f"Function {f.__name__}() called!")
            return f(*args, **kwargs)
        return new_function
    return debug_decorator

Como se observa, creamos una nueva función debug() que será la encargada de recibir los argumentos y, dentro de ella, ubicamos nuestro decorador, ahora con el nombre de debug_decorator.

Ahora bien, por último, incluimos el código para iniciar el depurador cuando corresponda (para una guía sobre cómo usar el módulo pdb véase Usando el depurador).

import pdb

def debug(breakpoint=False):
    def debug_decorator(f):
        def new_function(*args, **kwargs):
            print(f"Function {f.__name__}() called!")
            if breakpoint:
                pdb.set_trace()
            return f(*args, **kwargs)
        return new_function
    return debug_decorator

Y hagamos algunas pruebas:

@debug(breakpoint=True)
def add(a, b):
    return a + b

@debug()  # ¡Paréntesis necesarios!
def neg(n):
    return n * -1

print(neg(5))
print(add(7, 5))  # Esta llamada inicia el depurador.

¡Excelente! ¿Qué te parece?

Otras consideraciones

Un problema común en el uso de decoradores es que, dado que “encapsulan” a la función en la que se aplican, veremos que se pierde la posibilidad de acceder a los atributos de la función original: por ejemplo, el nombre y la documentación. Supongamos el siguiente código.

def debug(f):
    def new_function(*args, **kwargs):
        print(f"Function {f.__name__}() called!")
        return f(*args, **kwargs)
    return new_function

@debug
def neg(n):
    "Retorna el inverso de n."
    return n * -1

print(neg.__name__)  # new_function
help(neg)

Vemos que los atributos __name__ y __doc__ (al que accede help()) retornan los valores de new_function(). Para evitar esto, podemos emplear el decorador estándar functools.wraps(), que se encarga de copiar todos los atributos de la función original a la nueva.

from functools import wraps

def debug(f):
    @wraps(f)
    def new_function(*args, **kwargs):
        print(f"Function {f.__name__}() called!")
        return f(*args, **kwargs)
    return new_function

¡Tan sencillo como eso!



2 comentarios.

Deja un comentario