Entendiendo por completo los decoradores

TL;DR

Uno de los temas en python que nos ayudan a mejorar nuestra forma de realizar código y nos llevan a un nuevo nivel son los decoradores. Los decoradores son funciones que envuelven otras funciones, agregando comportamientos nuevos a dichas funciones y nos ayuda a reutilizar nuestro código de una manera muy limpia.

Hola Developer! en este tutorial aprenderemos que es un decorador, como podemos utilizarlo y como podemos crear nuestros propios decoradores para poder reutilizar de manera mas optima nuestro código.

Definición: Un decorador es una función que tiene como parámetro una segunda función y que envuelve a esta para agregar comportamiento a la función decorada. El decorador puede cambiar el comportamiento antes del la ejecución de la función decorada y/o después de la misma.

Pero, como así que una función que recibe una función como parámetro, ¿acaso esto es posible?, la respuesta es ¡Si!, entendamos como esto puede ser posible.

¿Que son las funciones en python?

Primero debemos entender como trabajar con funciones en python. A diferencia de otros lenguajes una función en python es un “tipo” de datos (si han trabajado con javascript sucede exactamente lo mismo). Por esta razón podemos guardar una función dentro de una variable por un tipo mas que una estructura, y esto es lo que facilita que una función pueda ser utilizada como parámetro de una segunda función, de hecho, a esta forma de programación se le llama programación funcional.

Conociendo esto, tenemos una base para nuestra propia definición, en python las funciones son un tipo de dato primario (tipo/objeto), que recibe parámetros y con retorna un valor dependiendo de ellos.

Veamos un ejemplo:

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

>>> sum(1, 2)
3

Ahora probemos que una función puede ser asignable a una variable, intentemos asignar sum a otra variable y la ejecutaremos.

>>> test = sum
>>> test(1,2) == sum(1,2)
True

Perfecto, ahora considerando esto, hagamos una función que reciba como parametro una función y la ejecute.

text = "hi i'm a python developer"

def capitalize(text):
return text[0].upper()+text[1:]

def excerpt(text):
return text[0:15]+'...'

def greet(text, pipe=None):
if pipe is not None:
text = pipe(text)
print(text)

>>> greet(text)
"hi i'm a python developer"
>>> greet(text, capitalize)
"Hi i'm a python developer"
>>> greet(text, excerpt)
"hi i'm a python..."

Con esto, vemos que `pipe` puede ser cualquier función que pasemos por parámetro y ejecutaremos esta función dentro de nuestra función `greet` y esto cambiará el texto pasado por parámetros, este ejemplo a veces es utilizados en algunos frameworks para dar formato a las variables antes de mostrarlas por pantalla.

Funciones internas

En python, podemos crear funciones dentro de otras funciones, haciendo que estas segundas funciones existan dentro de la ejecución de la función principal, ¿difícil de entender no? veamos un ejemplo:

>>> def test():
...     def test_inside():
...         print("test inside")
...     test_inside()
...
>>> test()
test inside
>>> test_inside
Traceback (most recent call last):
  File "", line 1, in 
NameError: name 'test_inside' is not defined
>>>

Como podemos ver, podemos crear una función dentro de esta y la creación de esta segunda función solo vive dentro de la ejecución de la primera, si intentamos luego acceder a esta segunda función por fuera de la primera, no existirá en el hilo de ejecución.

Ahora si, decoremos una función

Perfecto ya conocemos 2 conceptos importantes, una función es como un “objeto” o “tipo” la cual puede ser guardada dentro de una variable, segundo, una función puede crear otra dentro de si misma, que existe solamente dentro de su hilo de ejecución, lo ultimo que debemos entender, es que al igual que podemos guardarla en una variable es que podemos retornar una función dentro de otra función, hagamos un ejemplo:

>>> def test():
...     def inner(t):
...         print("Hi {}!".format(t))
...     return inner
...
>>> greet = test()
>>> greet("Luis")
Hi Luis!
>>>

Como pudimos ver, nuestra clase test al ser llamada devuelve una segunda función, la función interna, que esta a su vez puede ser ejecutada por fuera de nuestra declaración de la primera función, esto gracias a que retornamos la función interna desde la función externa.

Ahora crearemos nuestro primer decorador, un decorador sencillo que va a imprimir cuanto tiempo tomó la ejecución de la función que fue decorada, la estructura de un decorador es una función externa que recibe como parámetro una función, luego esta define una función interna la cual ejecuta dentro de esta función interna la función que se pasó por parámetro, extraño no, veamos el siguiente ejemplo:

>>> import time
>>> def timeit(func):
...     def wrapper():
...         start = time.time()
...         func()
...         end = time.time()
...         print(str(end - start) + "s")
...     return wrapper
...
>>> def say_hi():
...     print("Hi!")
...
>>> time_say_hi = timeit(say_hi)
>>> time_say_hi()
Hi!
3.81469726562e-05s

Ok, vamos paso a paso, primero definimos una función llamada timeit que funciona como función externa, esta crea una función interna llamada wrapper que hace el papel de función interna, dentro de esta función podemos ejecutar código antes y despues de ejecutar la función principal que fue pasada por parámetro, (sí, podemos utilizar variables de la función externa dentro de la función interna), por último la función externa devuelve la función interna wrapper.

Para ejecutar nuestro decorador primero creamos una función llamada say_hi esta función solo imprime un texto, para decorarla primero ejecutamos la función timeit y pasamos la función say_hi como parámetro, luego como sabemos la función timeit retorna una función, que al ejecutar esta llamará a say_hi y tomara su tiempo de ejecución, en nuestro caso 3.8^-5 segundos, (mucho menos de un segundo, es solo un print). pero para nuestro caso es suficiente con este ejemplo, ahora aprenderemos la forma “syntactic sugar” de ejecutar un decorador.

Si tuviéramos que ejecutar este decorador en una sola linea sería:

>>> timeit(say_hi)()

igualmente ejecutaría, pero es un poco “fea” esta forma de ejecución, para eso python coloca dentro de su sintaxis una forma mas estilizada con el caracter arroba (@).

>>> @timeit
... def say_hi():
...     print("Hi!")
...
>>> say_hi()
Hi!
1.4066696167e-05s

Perfecto!, es mucho mejor, sabemos exactamente cuál es la función que estamos ejecutando ya que estamos ejecutando esta función por su nombre, y no por una función retornada por otra.

Entendiendo los decoradores con parámetros

Ok!, hagámos un segundo ejemplo, que pasaría si la función decorada recibe algún argumento, cambiemos un poco la función say_hi


@timeit
def say_hi(name):
    print("Hi {0}".format(name))

Ok, si intentamos ejecutar esta nueva función nos encontrarémos con un error en tiempo de ejecución:


Traceback (most recent call last):
  File "", line 1, in 
TypeError: wrapper() takes 0 positional arguments but 1 was given

Esto sucede por que nuestra función interna wrapper no esta recibiendo ningún argumento, hagamos un cambio para que pueda aceptar argumentos:


def timeit(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        func(*args, **kwargs)
        print(time.time() - start)
    return wrapper

Perfecto si ahora hacemos la ejecución nuevamente, podemos ver que ya no tenemos ningún error:


@timeit
def say_hi(name):
    print("Hi {0}".format(name))
>>> say_hi("Luis")
Hi Luis
7.486343383789062e-05

Ningún error ahora, nuestro decorador sigue funcionando perfecto, pero ahora viene una duda, que pasaría si nuestra función retornara un valor en vez de pintarlo en pantalla, tenemos que revisar esto:

¿Que pasa si nuestra función a decorar retorna un valor?

Cambiemos un poco nuestra función de manera que nuestra función say_hi no imprima nada, sino que retorne el valor de la cadena con nuestro nombre:


@timeit
def say_hi(name):
    return "Hi {0}".format(name)
>>> print(say_hi("Luis"))
2.574920654296875e-05
None

Que extraño, se supone que nuestra ejecución debió retornar una cadena con nuestro nombre, sin embargo, el resultado retornado fue None, si, es culpa de nuestro decorador, si recordamos en la función interna solo ejecutamos nuestra función decorada pero nunca retornamos nada, vamos a cambiar este comportamiento:


def timeit(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(time.time() - start)
        return result
    return wrapper

¡Listo!, ahora guardamos el resultado y luego retornamos este resultado dentro de nuestra función interna, con esto nos aseguramos que si la función decorada retorna algo, podamos tomar ese resultado en la ejecución principal de nuestra función decorada, veamos si funciona:


>>> print(say_hi("Luis"))
7.152557373046875e-06
Hi Luis

En esta salida debemos ver 2 cosas importantes, la primera ya podemos decorar funciones que retornen un valor, en nuestra salida vemos Hi Luis, que es el resultado de nuestra función, la segunda vemos que la ejecución cambio la forma de impresión, ahora se imprime primero el tiempo que tomó en ejecutar la función y luego si se realiza la impresión puesto que el nuevo print esta por fuera de las funciones.

Perfecto! hemos terminado con nuestro decorador básico, espero hayan entendido el concepto principal, en un proximo post veremos un uso mas avanzado de los decoradores y su creación. Hasta luego!!