Title: Decoradores de python: una herramienta poderosa y desconocida

Date: 2019-11-22
Content:

Los decoradores en Python son una gran herramienta que hacen que el código sea más fácil de mantener y que desgraciadamente no se le suele dar tanta importancia. Son la manera en la cual puedes reutilizar un mismo código para alterar el funcionamiento de funciones cuando este se vuelve muy repetitivo.

Si lo quieres ver de esta forma, los decoradores son a las funciones lo que las interfaces son a los objetos. O si vienes de un ambiente mas rubyista, los decoradores son algo parecido a los bloques.

Los decoradores son básicamente la "trifecta" de las funciones ya que:

  1. Son una función
  2. Reciben una función
  3. Retornan una función

Los habrás visto y/o usado seguramente si haz trabajado alguna vez con el framework web Flask ya que es la manera recomendada que tiene este framework para crear rutas para tu servicio web.

@app.route("/")
def index():
    pass

Sintaxis de una decorador

Indicador de "decoración"

@decorador(params)
^
╚════ Caractér para indicar que decoraremos una función

La manera mas sencilla para detectar que una función está siendo "decorada" es por que justo arriba de su cabecera se encuentra algo parecido a una llamada a una función pero que comienza con @.

Nombre del decorador

@decorador(params)
 ^ ^ ^ ^ ^
 ╚═╩═╩═╩═╩══ Nombre del decorador

Esto es bastante simple y no varía en lo absoluto con una llamada típica a una función... para saber cual decorador estamos usando, se usará su nombre.

Argumentos del decorador

@decorador(params)
           ^ ^ ^
           ╚═╩═╩══ Parámetros para modificar el decorador (opcional)

Tampoco esto varia demasiado con las funciones de toda la vida. Si el decorador acepta parámetros, se le pasan colocándolos dentro de unos paréntesis.

Cabecera de la función a decorar

@decorador(params)
def index():
^ ^ ^ ^ ^ ^
╚═╩═╩═╩═╩═╩══ Función a decorar

Inmediatamente debajo del decorador se debe colocar la función que será decorada.

Decoradores sin parámetros: Funcionamiento interno

Como mencioné al inicio del artículo, los decoradores no son otra cosa más que simples funciones.

def decorador(f):
    def wrap(*args, **kwargs):
        return f(*args, **kwargs)
    return wrap

@decorador
def funcion(x):
    return x**2

Esta es el decorador mas simple que se puede escribir... YYYYY no hace absolutamente nada, pero nos puede dar una idea de como están compuestos.

Estructura del decorador

Nombre del decorador

def decorador(f):
    ^ ^ ^ ^ ^
    ╚═╩═╩═╩═╩══ Nombre del decorador

Es el nombre que recibirá el decorador y que luego utilizaremos con la sintaxis de @nombre.

Función a decorar

def decorador(f):
              ^
              ║
              ╚═╦═══ Función a decorar siendo
@decorador      ║    capturada por el decorador
def funcion(x):<╝

Este parámetro que está recibiendo nuestra función/decorador es una referencia a la función que posteriormente se colocará debajo del decorador.

Función interna del decorador

def decorador(f):
    def wrap(*args, **kwargs):
        ^ ^
        ╚═╩════ Nombre provisional para uso interno del decorador

Esta será la función que retornaremos, wrap es solo un nombre provisional que usaremos unicamente dentro del decorador y que jamás se podrá ver al momento de utilizar el decorador.

Argumentos que fueron mandados a la función decorada

def decorador(f):
    def wrap(*args, **kwargs):
             ^ ^ ^ ^ ^ ^ ^ ^
             ╚═╩═╩═╩═╩═╩═╩═╩══ Nuevos argumentos que aceptará nuestra función

Como nosotros al momento de crear el decorador desconocemos la cantidad real de argumentos que aceptará la función decorada, simplemente debemos capturar absolutamente todos para luego "desenvolvernos" al momento de llamar a la función a decorar.

llamada a la función decorada

def decorador(f):
    def wrap(*args, **kwargs):
        return f(*args, **kwargs)
        ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
        ╚═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═══ Llamada a la función decorada desenvolviendo
                                        los parámetros capturados en el wrap

Recordemos que en f se encuentra una referencia a la función a la cual queremos decorar, eso significa que si mandamos llamar a la función f realmente estaremos llamando a la función que colocamos debajo de nuestro decorador, y como esto es un decorador simple, no hacemos otra cosa mas que retornar los que la función decorada nos retorne.

Retorno de la nueva función generada

def decorador(f):
    def wrap(*args, **kwargs):
        return f(*args, **kwargs)
    return wrap
    ^ ^ ^ ^ ^ ^
    ╚═╩═╩═╩═╩═╩══ Se retorna una referencia a nuestra función provisional

Aquí está el truco de todo esto ya que, como dije al principio, nuestro decorador está retornando una función, o mas bien, una referencia a nuestra función... así que cuando estamos llamando a una función decorada, realmente estamos mandando llamar a un impostor, a una función intermediaría entre nosotros y nuestra hermosa función.

Decorador listo

def decorador(f):
    def wrap(*args, **kwargs):
        return f(*args, **kwargs)
    return wrap

@decorador
def funcion(x):
    return x**2

y boilá, nuestro primer decorador está listo para ser coronar a nuestras funciones y otorgarle cualidades especiales... ...o en este caso, ninguna cualidad.

Decoradores con argumentos: Funcionamiento interno

Pero si creíste que el viaje por este maravilloso mundo de decoradores, funciones retornando funciones e impostores había acabo, pues estás equivocado ya que ahora subiremos al siguiente nivel, hacer que nuestro decorador admita * Tambores dramáticos * parámetros. Los decoradores que reciben parámetros no son muy distintos a los decoradores que NO reciben parámetros, solo que con una pequeña diferencia. Si los decoradores que NO reciben parámetros son una función que retorna una función, los decoradores que SI reciben parámetros son funciones que retornan una función que retorna una función * emoji impactado *. Pero esto, por que es así? por que nosotros al estar pasándole parámetros a una función realmente estamos llamando a una función y como a nosotros lo que nos interesa es otra función, pues la función a la que llamamos nos debe retornar otra... Yo se que te estoy confundiendo mas de lo que te estoy ayudando, así que mejor vamos a ver un ejemplo.

def decorador(*args, **kwargs):
    def decorator(f):
        def wrap(*args, **kwargs):
            return f(*args, **kwargs)
        return wrap
    return decorator

Aquí notamos algo familiar, no? def decorator que recibe una función en su parámetro, un función cuyo nombres es wrap que es retornado, pues si. En pocas palabras, podríamos decir que un decorador que SI recibe parámetros es un generador de decoradores que NO reciben parámetros. Genial, no? vamos a explicarlo un poco.

Cabecera del decorador recibiendo parámetros

def decorador(*args, **kwargs):
    ^ ^ ^ ^ ^
    ╚═╩═╩═╩═╩═══ Nombre de nuestro decorador

Comenzamos nuevo, este será el nombre que usaremos en la sintaxis de @decorador con la diferencia de que ahora nuestro decorador no recibe una función en sus parámetros, sino que recibe sus propios parámetros. Aquí es donde el decorador que flask que vimos al principio tiene declarados los argumentos como la ruta o los métodos. Pueden ser recibimos como argumentos envueltos con *args, **kwargs o podemos recibirlo uno a uno como una función cualquiera, por ejemplo:

def decorador(arg1, args2="default value", arg3=None)

y cuanta fantasía se nos vaya ocurriendo durante el proceso de programación. La otra parte del decorador es exactamente la misma que con los decoradores sin parámetros.

Como se puede apreciar, los decoradores no son mas que un juego entre recibir funciones y retornar funciones y esto se puede explotar hasta donde nuestra imaginación nos de. Pero la pregunta que quizá alguno de ustedes tengan es "¿Y eso que utilidad tiene?".

Utilidad

Como pudimos ver en los 2 bloques anteriores, nosotros al crear un decorador tenemos total acceso tanto a las funciones, como a los argumentos que estas están recibiendo, lo cual puede dar juego a hacer toda clase de validaciones y registros.

Ejemplos

A lo largo de mi vida como programador en Python he escrito bastantes decoradores y quiero compartir (y explicar) con ustedes un par de ellos.

Decorador para manejo de excepciones

def try_catch(value=...):
    def decorator(f):
        def wrap(*args, **kwargs):
            try:
                return f(*args, **kwargs)
            except BaseException as e:
                if value is ...:
                    return e
                else:
                    return value
        return wrap
    return decorator

@try_catch(0)
def pow(num, exp):
    return num ** exp

> pow(10, 2)
100

> pow(5, 5)
3125

> pow("foo", 10)
0

@try_catch()
def pow(num, exp):
    return num ** exp

> pow("foo", 10)
TypeError("unsupported operand type(s) for ** or pow(): 'str' and 'int'")

Como se puede apreciar, este es un decorador que si recibe parámetros, para ser correcto, recibe un solo parámetro y es opcional y el característica que le otorga a las funciones es la de evitar que arrojen una excepción y si lo hace, retornar el valor que se le pasó al decorador como parámetro y si no se le pasa ningún parámetro, retornará (mas no lanzará) la excepción que arrojó la función.

Decorador que revisa que todos los parámetros sean números

def only_numbers(f):
    def wrap(*args, **kwargs):
        for arg in args:
            if not isinstance(arg, (int, float)):
                raise ValueError(f"{arg} is not an int or a float, is a {type(arg).__name__}")
        return f(*args, **kwargs)
    return wrap

@only_numbers 
def suma(*args): 
    return sum(args)

> suma(1, 2, 3, 4, 5)
15

> suma(1, 2, 3, 4, "foo")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
...
ValueError: a is not a int or a float, is a str


Este otro decorador no recibe parámetros y lo que hace es verificar que todos los parámetros que esté recibiendo nuestra función sean tipo int o tipo float, de lo contrarío arrojará una excepción advirtiendo que uno de los argumentos incumple esta regla.

Bonus: Apilamiento de decoradores

Una ultima cosa antes de terminar y como dato que hará que les vuele cabeza.
Los decoradores también se pueden apilar al momento de ser utilizados decorando una función dando como resultado una herramienta verdaderamente poderosa para la reutilización de código. Los decoradores que puse de ejemplo no fueron al azar, pues los escogí exactamente por que se pueden apilar ya que uno es capaz de capturar excepciones y otro arroja una excepción, dando como resultado algo como:

@try_catch(-1) 
@only_numbers 
def suma(*args): 
    return sum(args)

> suma(1, 2, 3)
6

> suma(1, 2, "foo")
-1

Muchas gracias por leer el primer post de mi blog, espero que les haya gustado. en mi twitter @kirbylife pueden hacerme llegar las opiniones con respecto al post, que les pareció, en que puedo mejorar o si tengo algún error, tanto en el código como en la redacción.