Decoradores en Python

En la anterior entrada, Closures , defino los closures en Python y muestro ejemplos; en esta entrada, Decoradores en Python, presentaré los decoradores con dos ejemplos.

Según Wikipedia, definimos el patrón Decorator como sigue:

El patrón Decorator responde a la necesidad de añadir dinámicamente funcionalidad a un Objeto. Esto nos permite no tener que crear sucesivas clases que hereden de la primera incorporando la nueva funcionalidad, sino otras que la implementan y se asocian a la primera.

En Python podemos definir clases decoradoras las cuáles añaden funcionalidad a otras clases; pero, cuando hablamos de decoradores en Python, nos referimos a funciones decoradoras que son utilizadas por otras funciones mediante el empleo de anotaciones.

Para comprender el concepto de decorador es necesario entender el concepto de Closures ya que un decorador es un closure al cual se le pasa la referencia de la función sobre la que se ejecuta y, si fuere necesario, parámetros de entrada.

En los siguientes apartados, muestro ejemplos de decoradores: el primero, un decorador básico con un registro de las funciones clientes; y, en el segundo, un decorador al que se le pasan parámetros.

Un decorador está formado por una función que en su interior puede tener la definición de otras funciones. En tiempos de ejecución, al cargar el módulo donde se encuentra la función cliente con la anotación del decorador, se ejecuta el contenido de la primera función del decorador, ejecutándose la función del decorador con la referencia a la función cliente cuando se produce la invocación de la función cliente con el decorador.

Ejemplo básico

Supongamos que queremos cuantificar las funciones decoradoras que son ejecutadas. Para ello, definimos un módulo con una función decoradora y una lista para que almacene las funciones que son ejecutadas. El resultado es el siguiente: módulo my_decorator_ej1.py en el cuál definimos la función decoradora:

#my_decorators_ej1.py
func_registry = []

def my_decorator_ej1(func):

    print(f'Entramos en my_decorator_ej1')

    def inner():
        print('Ejecutamos función (%s)' % func)
        result = func()
        func_registry.append(func)

        return result

    return inner

Supongamos el siguiente módulo cliente que hace uso de la función decoradora definida anteriormente:

#example1.py
from my_decorators_ej1 import my_decorator_ej1, func_registry

@my_decorator_ej1
def function2() -> None:
    print('Entramos en función 2')

@my_decorator_ej1
def function1() -> None:
    print('Entramos en función 1')
    function2()

def run() -> None:
    function1()
    print(f'Funciones ejecutadas...{len(func_registry)}')
    for fun in func_registry:
        print(fun)

Al ejecutar el snippet de código anterior, lo primero que se realizar es la carga del módulo de los decoradores; y, al cargarlos, se escribe por consola el mensaje ‘Entramos en my_decorator_ej1’ de la primera función del decorador; cuando se ejecutan las funciones clientes, se ejecutan las funciones decoradoras.

La salida por consola del ejemplo anterior es la siguiente:

Entramos en my_decorator_ej1
Entramos en my_decorator_ej1
Ejecutamos función (<function function1 at 0x10d40cee0>)
Entramos en función 1
Ejecutamos función (<function function2 at 0x10d40c940>)
Entramos en función 2
Funciones ejecutadas...2
<function function2 at 0x10d40c940>
<function function1 at 0x10d40cee0>

Función decoradora con parámetros

En el siguiente ejemplo vamos a suponer que deseamos mostrar por consola las funciones que son llamadas, los parámetros y su tiempo de ejecución. El patrón del mensaje por consola es un valor por defecto pero este puede ser diferente en función del patrón definido en la anotación del decorador.

La definición del decorador del ejemplo es la siguiente:

#my_decorators_ej1.py
DEFAULT_FMT = '[{end:0.8f}s] {name}({args}) Resultado={result}'
def my_decorator_ej2(fmt=DEFAULT_FMT):
    print(f'Entramos en my_decorator_ej2')

    def decorator(func):

        def print_console(*_args):
            start = time.time()
            _result = func(*_args)
            end = time.time() - start

            name = func.__name__
            result = repr(_result)
            args = ', '.join(repr(arg) for arg in _args)
            print(fmt.format(**locals()))

            return _result

        return print_console

    return decorator

La definición de la clase cliente que usa el módulo de la función decoradora es la siguiente:

from my_decorators_ej1 import my_decorator_ej2

@my_decorator_ej2()
def function1(param: str) -> int:
    print('Ejecuto function1()...')
    return 33

@my_decorator_ej2()
def function2(param: str) -> None:
    print('Ejecuto function2()...')

@my_decorator_ej2('{name}({args}) => {result}')
def function3(param: str) -> int:
    print('Ejecuto function3()...')
    return 66


def run() -> None:
    function1('param1')

    function2('param1')

    function3('param1')

La salida por consola de la ejecución del snippet anterior es la siguiente:

Entramos en my_decorator_ej2
Entramos en my_decorator_ej2
Entramos en my_decorator_ej2
Ejecuto function1()...
[0.00003290s] function1('param1') Resultado=33
Ejecuto function2()...
[0.00002384s] function2('param1') Resultado=None
Ejecuto function3()...
function3('param1') => 66

De la ejecución anterior los primeros mensajes que se muestran en la consola son los correspondientes a los mensajes que se escriben en la primera función del decorador; los siguientes mensajes, son los correspondientes a la ejecución de cada función cliente en función del patrón definido en la anotación del decorador.

Closures

Los closures es un concepto de programación que se encuentra en diferentes lenguajes de programación, como pueden ser: Java, Groovy, Scala, Python,… En la entrada de hoy, Closures, definiré el concepto de closures y mostraré unos ejemplos en lenguaje Python.

Un Closure es aquella función que hereda el contexto de otra función con lo cual permite heredar las variables utilizadas en la primera. No hay que confundir un closures con el concepto de función anónima.

Las funciones closures pueden ser definidas en una clase o bien en funciones pertenecientes a un módulo. El ejemplo típico que mostraré es el cálculo de una media. En los siguientes apartados, mostraré ejemplos con diferentes escenarios.

Closures en una clase

En el presente ejemplo, definiré una clase Averager que define la función __call__ en la cual se define la funcionalidad para el cálculo de la media a partir de los valores que se le pasan por parámetro; y, el almacén de los valores insertados, será una lista que será atributo de la clase. El snippet del código de ejemplo es el siguiente:

class Averager():
    def __init__(self):
        self.series = []

    def __call__(self, new_value):
        self.series.append(new_value)
        total = sum(self.series)
        return total/len(self.series)
        
def __ejemplo1() -> None:
    print(f'-*- Ejemplo de Clouser: cálculo de la media utilizando una clase con método call -*-')
    avg = Averager()
    print(f'avg(10)={avg(10)}')
    print(f'avg(11)={avg(11)}')
    print(f'avg(12)={avg(12)}')

La salida por consola del snippet mostrado es el siguiente:

    -*- Ejemplo de Clouser: cálculo de la media utilizando una clase con método call -*-
    avg(10)=10.0
    avg(11)=10.5
    avg(12)=11.0 

Closures en una función

El cálculo de un closure se puede definir en una función de un módulo con estructura parecida a la de una clase; la diferencia reside en dónde se encuentra el almacén de valores, los cuáles estarán en una función cuyos valores serán heredados por otra función ubicada en un nivel superior, es decir, una función se define dentro de otra función. El snippet del código de ejemplo es el siguiente:

def make_averager():
    series = []

    def averager(new_value):
        series.append(new_value)
        total = sum(series)
        return total/len(series)

    return averager

def __ejemplo2() -> None:
    print(f'-*- Ejemplo de Clouser: cálculo de la media utilizando un clouser  -*-')
    avg = make_averager()
    print(f'avg(10)={avg(10)}')
    print(f'avg(11)={avg(11)}')
    print(f'avg(12)={avg(12)}')

La salida por consola del snippet mostrado es el siguiente:

-*- Ejemplo de Clouser: cálculo de la media utilizando un clouser  -*-
avg(10)=10.0
avg(11)=10.5
avg(12)=11.0  

Para el cálculo de la media se utiliza las funciones sum y len; y, además, se utiliza una lista con los valores que son insertados en el conjunto de cálculo.

Closures en una función usando el operador nonlocal

El ejemplo anterior puede ser definido de una forma más eficiente, si se va realizando la suma y el conteo de elementos de una forma directa conforme se van añadiendo los elementos. Para realizar este enfoque, hay que definir dos variables count y total en la función de primer nivel; y, en la función de segundo nivel, utilizar el operador nonlocal para identificar que no son variables locales y son variables de la función del nivel superior. El cálculo de la media se realiza con los valores de estas variables eliminando el uso de funciones y el almacén de valores. El snippet del código es el siguiente:

def make_averager2() -> None:
    count = 0
    total = 0

    def averager(new_value):
        nonlocal count, total
        count += 1
        total += new_value
        return total/count

    return averager


def __ejemplo3() -> None:
    print(f'-*- Ejemplo de Couser: utilizando una función con la clausula nonlocal -*-')
    avg = make_averager2()
    print(f'avg(10)={avg(10)}')
    print(f'avg(11)={avg(11)}')
    print(f'avg(12)={avg(12)}')

La salida por consola del snippet mostrado es el siguiente:

-*- Ejemplo de Couser: utilizando una función con la clausula nonlocal -*-
avg(10)=10.0
avg(11)=10.5
avg(12)=11.0 

Como se puede comprobar en los ejemplos, un closure es una definición de un función la cual contiene otra función; y, en está última, se pueden utilizar el contexto de ejecución de la primera.

Este concepto de closures es la base de los decoradores en Python los cuales son aquellas funciones que se ejecutan al invocar a una función.