Encadenamiento de decoradores y decoradores parametrizados

PythonPythonBeginner
Practicar Ahora

This tutorial is from open-source community. Access the source code

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

En este laboratorio, aprenderás sobre los decoradores en Python, una función poderosa que puede modificar el comportamiento de funciones y métodos. Los decoradores se utilizan comúnmente para tareas como el registro (logging), la medición de rendimiento, el control de acceso y la comprobación de tipos.

Aprenderás cómo encadenar múltiples decoradores, crear decoradores que acepten parámetros, preservar los metadatos de la función cuando se utilizan decoradores y aplicar decoradores a diferentes tipos de métodos de clase. Los archivos con los que trabajarás son logcall.py, validate.py y sample.py.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL python(("Python")) -.-> python/BasicConceptsGroup(["Basic Concepts"]) python(("Python")) -.-> python/FunctionsGroup(["Functions"]) python(("Python")) -.-> python/ObjectOrientedProgrammingGroup(["Object-Oriented Programming"]) python(("Python")) -.-> python/AdvancedTopicsGroup(["Advanced Topics"]) python/BasicConceptsGroup -.-> python/type_conversion("Type Conversion") python/FunctionsGroup -.-> python/function_definition("Function Definition") python/FunctionsGroup -.-> python/lambda_functions("Lambda Functions") python/FunctionsGroup -.-> python/scope("Scope") python/ObjectOrientedProgrammingGroup -.-> python/classes_objects("Classes and Objects") python/ObjectOrientedProgrammingGroup -.-> python/class_static_methods("Class Methods and Static Methods") python/AdvancedTopicsGroup -.-> python/decorators("Decorators") subgraph Lab Skills python/type_conversion -.-> lab-132515{{"Encadenamiento de decoradores y decoradores parametrizados"}} python/function_definition -.-> lab-132515{{"Encadenamiento de decoradores y decoradores parametrizados"}} python/lambda_functions -.-> lab-132515{{"Encadenamiento de decoradores y decoradores parametrizados"}} python/scope -.-> lab-132515{{"Encadenamiento de decoradores y decoradores parametrizados"}} python/classes_objects -.-> lab-132515{{"Encadenamiento de decoradores y decoradores parametrizados"}} python/class_static_methods -.-> lab-132515{{"Encadenamiento de decoradores y decoradores parametrizados"}} python/decorators -.-> lab-132515{{"Encadenamiento de decoradores y decoradores parametrizados"}} end

Preservación de metadatos de funciones en decoradores

En Python, los decoradores son una herramienta poderosa que te permite modificar el comportamiento de las funciones. Sin embargo, cuando se utiliza un decorador para envolver una función, surge un pequeño problema. Por defecto, los metadatos de la función original, como su nombre, la cadena de documentación (docstring) y las anotaciones, se pierden. Los metadatos son importantes porque ayudan en la introspección (examinar la estructura del código) y en la generación de documentación. Primero, verificaremos este problema.

Abre tu terminal en el WebIDE. Ejecutaremos algunos comandos de Python para ver lo que sucede cuando se utiliza un decorador. Los siguientes comandos crearán una función simple add envuelta con un decorador y luego imprimirán la función y su docstring.

cd ~/project
python3 -c "from logcall import logged; @logged
def add(x,y):
    'Adds two things'
    return x+y
    
print(add)
print(add.__doc__)"

Cuando ejecutes estos comandos, verás una salida similar a esta:

<function wrapper at 0x...>
None

Observa que en lugar de mostrar el nombre de la función como add, muestra wrapper. Y la docstring, que debería ser 'Adds two things', es None. Esto puede ser un gran problema cuando se utilizan herramientas que dependen de estos metadatos, como herramientas de introspección o generadores de documentación.

Solucionando el problema con functools.wraps

El módulo functools de Python viene al rescate. Proporciona un decorador wraps que puede ayudarnos a preservar los metadatos de la función. Veamos cómo podemos modificar nuestro decorador logged para usar wraps.

  1. Primero, abre el archivo logcall.py en el WebIDE. Puedes navegar al directorio del proyecto utilizando el siguiente comando en la terminal:
cd ~/project
  1. Ahora, actualiza el decorador logged en logcall.py con el siguiente código. El decorador @wraps(func) es la clave aquí. Copia todos los metadatos de la función original func a la función envolvente.
from functools import wraps

def logged(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper
  1. El decorador @wraps(func) realiza un trabajo importante. Toma todos los metadatos (como el nombre, la docstring y las anotaciones) de la función original func y los adjunta a la función wrapper. De esta manera, cuando usamos la función decorada, tendrá los metadatos correctos.

  2. Probemos nuestro decorador mejorado. Ejecuta los siguientes comandos en la terminal:

python3 -c "from logcall import logged; @logged
def add(x,y):
    'Adds two things'
    return x+y
    
print(add)
print(add.__doc__)"

Ahora deberías ver:

<function add at 0x...>
Adds two things

¡Genial! El nombre de la función y la docstring se han preservado. Esto significa que nuestro decorador ahora está funcionando como se esperaba y los metadatos de la función original están intactos.

Solucionando el decorador validate.py

Ahora, apliquemos la misma solución al decorador validated en validate.py. Este decorador se utiliza para validar los tipos de los argumentos de la función y el valor de retorno en función de las anotaciones de la función.

  1. Abre validate.py en el WebIDE.

  2. Actualiza el decorador validated con el decorador @wraps. El siguiente código muestra cómo hacerlo. El decorador @wraps(func) se agrega a la función wrapper dentro del decorador validated para preservar los metadatos.

from functools import wraps

class Integer:
    @classmethod
    def __instancecheck__(cls, x):
        return isinstance(x, int)

def validated(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        ## Get function annotations
        annotations = func.__annotations__
        ## Check arguments against annotations
        for arg_name, arg_value in zip(func.__code__.co_varnames, args):
            if arg_name in annotations and not isinstance(arg_value, annotations[arg_name]):
                raise TypeError(f'Expected {arg_name} to be {annotations[arg_name].__name__}')

        ## Run the function and get the result
        result = func(*args, **kwargs)

        ## Check the return value
        if 'return' in annotations and not isinstance(result, annotations['return']):
            raise TypeError(f'Expected return value to be {annotations["return"].__name__}')

        return result
    return wrapper
  1. Probemos que nuestro decorador validated ahora preserva los metadatos. Ejecuta los siguientes comandos en la terminal:
python3 -c "from validate import validated, Integer; @validated
def multiply(x: Integer, y: Integer) -> Integer:
    'Multiplies two integers'
    return x * y
    
print(multiply)
print(multiply.__doc__)"

Deberías ver:

<function multiply at 0......>
Multiplies two integers

Ahora, ambos decoradores, logged y validated, preservan adecuadamente los metadatos de las funciones que decoran. Esto asegura que cuando se utilizan estos decoradores, las funciones seguirán teniendo sus nombres, docstrings y anotaciones originales, lo cual es muy útil para la legibilidad y el mantenimiento del código.

✨ Revisar Solución y Practicar

Creación de decoradores con argumentos

Hasta ahora, hemos estado utilizando el decorador @logged, que siempre imprime un mensaje fijo. Pero, ¿qué pasa si quieres personalizar el formato del mensaje? En esta sección, aprenderemos cómo crear un nuevo decorador que pueda aceptar argumentos, lo que te dará más flexibilidad en cómo utilizas los decoradores.

Comprendiendo los decoradores parametrizados

Un decorador parametrizado es un tipo especial de función. En lugar de modificar directamente otra función, devuelve un decorador. La estructura general de un decorador parametrizado es la siguiente:

def decorator_with_args(arg1, arg2, ...):
    def actual_decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            ## Use arg1, arg2, ... here
            ## Call the original function
            return func(*args, **kwargs)
        return wrapper
    return actual_decorator

Cuando se utiliza @decorator_with_args(value1, value2) en tu código, Python primero llama a decorator_with_args(value1, value2). Esta llamada devuelve el decorador real, que luego se aplica a la función que sigue la sintaxis @. Este proceso de dos pasos es clave en cómo funcionan los decoradores parametrizados.

Creando el decorador logformat

Vamos a crear un decorador @logformat(fmt) que tome una cadena de formato como argumento. Esto nos permitirá personalizar el mensaje de registro.

  1. Abre logcall.py en el WebIDE y agrega el nuevo decorador. El código siguiente muestra cómo definir tanto el decorador logged existente como el nuevo decorador logformat:
from functools import wraps

def logged(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

def logformat(fmt):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(fmt.format(func=func))
            return func(*args, **kwargs)
        return wrapper
    return decorator

En el decorador logformat, la función exterior logformat toma una cadena de formato fmt como argumento. Luego devuelve la función decorator, que es el decorador real que modifica la función objetivo.

  1. Ahora, probemos nuestro nuevo decorador modificando sample.py. El siguiente código muestra cómo usar tanto el decorador logged como el decorador logformat en diferentes funciones:
from logcall import logged, logformat

@logged
def add(x, y):
    "Adds two numbers"
    return x + y

@logged
def sub(x, y):
    "Subtracts y from x"
    return x - y

@logformat('{func.__code__.co_filename}:{func.__name__}')
def mul(x, y):
    "Multiplies two numbers"
    return x * y

Aquí, las funciones add y sub utilizan el decorador logged, mientras que la función mul utiliza el decorador logformat con una cadena de formato personalizada.

  1. Ejecuta el archivo sample.py actualizado para ver los resultados. Abre tu terminal y ejecuta el siguiente comando:
cd ~/project
python3 -c "import sample; print(sample.add(2, 3)); print(sample.mul(2, 3))"

Deberías ver una salida similar a la siguiente:

Calling add
5
sample.py:mul
6

Esta salida muestra que el decorador logged imprime el nombre de la función como se esperaba, y el decorador logformat utiliza la cadena de formato personalizada para imprimir el nombre del archivo y el nombre de la función.

Redefiniendo el decorador logged utilizando logformat

Ahora que tenemos un decorador logformat más flexible, podemos redefinir nuestro decorador logged original utilizando este. Esto nos ayudará a reutilizar código y mantener un formato de registro consistente.

  1. Actualiza logcall.py con el siguiente código:
from functools import wraps

def logformat(fmt):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(fmt.format(func=func))
            return func(*args, **kwargs)
        return wrapper
    return decorator

## Define logged using logformat
logged = lambda func: logformat("Calling {func.__name__}")(func)

Aquí, utilizamos una función lambda para definir el decorador logged en términos del decorador logformat. La función lambda toma una función func y aplica el decorador logformat con una cadena de formato específica.

  1. Prueba que el decorador logged redefinido sigue funcionando. Abre tu terminal y ejecuta el siguiente comando:
cd ~/project
python3 -c "from logcall import logged; @logged
def greet(name):
    return f'Hello, {name}'
    
print(greet('World'))"

Deberías ver:

Calling greet
Hello, World

Esto muestra que el decorador logged redefinido funciona como se esperaba, y hemos reutilizado con éxito el decorador logformat para lograr un formato de registro consistente.

✨ Revisar Solución y Practicar

Aplicación de decoradores a métodos de clase

Ahora, vamos a explorar cómo los decoradores interactúan con los métodos de clase. Esto puede ser un poco complicado porque Python tiene diferentes tipos de métodos: métodos de instancia, métodos de clase, métodos estáticos y propiedades. Los decoradores son funciones que toman otra función y extienden el comportamiento de esta última sin modificarla explícitamente. Cuando se aplican decoradores a métodos de clase, debemos prestar atención a cómo funcionan con estos diferentes tipos de métodos.

Comprendiendo el desafío

Veamos qué sucede cuando aplicamos nuestro decorador @logged a diferentes tipos de métodos. El decorador @logged probablemente se utiliza para registrar información sobre las llamadas a los métodos.

  1. Crea un nuevo archivo methods.py en el WebIDE. Este archivo contendrá nuestra clase con diferentes tipos de métodos decorados con el decorador @logged.
from logcall import logged

class Spam:
    @logged
    def instance_method(self):
        print("Instance method called")
        return "instance result"

    @logged
    @classmethod
    def class_method(cls):
        print("Class method called")
        return "class result"

    @logged
    @staticmethod
    def static_method():
        print("Static method called")
        return "static result"

    @logged
    @property
    def property_method(self):
        print("Property method called")
        return "property result"

En este código, tenemos una clase Spam con cuatro tipos diferentes de métodos. Cada método está decorado con el decorador @logged, y algunos también están decorados con otros decoradores incorporados como @classmethod, @staticmethod y @property.

  1. Probemos cómo funciona. Ejecutaremos un comando de Python en la terminal para llamar a estos métodos y ver la salida.
cd ~/project
python3 -c "from methods import Spam; s = Spam(); print(s.instance_method()); print(Spam.class_method()); print(Spam.static_method()); print(s.property_method)"

Cuando ejecutes este comando, es posible que notes algunos problemas:

  • El decorador @property puede no funcionar correctamente con nuestro decorador @logged. El decorador @property se utiliza para definir un método como una propiedad, y tiene una forma específica de funcionar. Cuando se combina con el decorador @logged, pueden haber conflictos.
  • El orden de los decoradores es importante para @classmethod y @staticmethod. El orden en el que se aplican los decoradores puede cambiar el comportamiento del método.

El orden de los decoradores

Cuando se aplican múltiples decoradores, se aplican de abajo hacia arriba. Esto significa que el decorador más cercano a la definición del método se aplica primero, y luego los que están por encima se aplican en secuencia. Por ejemplo:

@decorator1
@decorator2
def func():
    pass

Esto es equivalente a:

func = decorator1(decorator2(func))

En este ejemplo, decorator2 se aplica a func primero, y luego decorator1 se aplica al resultado de decorator2(func).

Corrigiendo el orden de los decoradores

Vamos a actualizar nuestro archivo methods.py para corregir el orden de los decoradores. Al cambiar el orden de los decoradores, podemos asegurarnos de que cada método funcione como se espera.

from logcall import logged

class Spam:
    @logged
    def instance_method(self):
        print("Instance method called")
        return "instance result"

    @classmethod
    @logged
    def class_method(cls):
        print("Class method called")
        return "class result"

    @staticmethod
    @logged
    def static_method():
        print("Static method called")
        return "static result"

    @property
    @logged
    def property_method(self):
        print("Property method called")
        return "property result"

En esta versión actualizada:

  • Para instance_method, el orden no importa. Los métodos de instancia se llaman en una instancia de la clase, y el decorador @logged se puede aplicar en cualquier orden sin afectar su funcionalidad básica.
  • Para class_method, aplicamos @classmethod después de @logged. El decorador @classmethod cambia la forma en que se llama al método, y aplicarlo después de @logged asegura que el registro funcione correctamente.
  • Para static_method, aplicamos @staticmethod después de @logged. Similar al @classmethod, el decorador @staticmethod tiene su propio comportamiento, y el orden con el decorador @logged debe ser correcto.
  • Para property_method, aplicamos @property después de @logged. Esto asegura que se mantenga el comportamiento de la propiedad mientras también se obtiene la funcionalidad de registro.
  1. Probemos el código actualizado. Ejecutaremos el mismo comando que antes para ver si se solucionan los problemas.
cd ~/project
python3 -c "from methods import Spam; s = Spam(); print(s.instance_method()); print(Spam.class_method()); print(Spam.static_method()); print(s.property_method)"

Ahora deberías ver un registro adecuado para todos los tipos de métodos:

Calling instance_method
Instance method called
instance result
Calling class_method
Class method called
class result
Calling static_method
Static method called
static result
Calling property_method
Property method called
property result

Mejores prácticas para decoradores de métodos

Cuando trabajes con decoradores de métodos, sigue estas mejores prácticas:

  1. Aplica los decoradores que transforman métodos (@classmethod, @staticmethod, @property) después de tus decoradores personalizados. Esto asegura que los decoradores personalizados puedan realizar su registro u otras operaciones primero, y luego los decoradores incorporados puedan transformar el método como se pretendía.
  2. Ten en cuenta que la ejecución del decorador se produce en el momento de la definición de la clase, no en el momento de la llamada al método. Esto significa que cualquier código de configuración o inicialización en el decorador se ejecutará cuando se defina la clase, no cuando se llame al método.
  3. Para casos más complejos, es posible que debas crear decoradores especializados para diferentes tipos de métodos. Diferentes tipos de métodos tienen diferentes comportamientos, y un decorador único no puede funcionar en todas las situaciones.
✨ Revisar Solución y Practicar

Creación de un decorador de aplicación de tipos con argumentos

En los pasos anteriores, aprendimos sobre el decorador @validated. Este decorador se utiliza para aplicar las anotaciones de tipo en las funciones de Python. Las anotaciones de tipo son una forma de especificar los tipos esperados de los argumentos de una función y de sus valores de retorno. Ahora, vamos a ir un paso más allá. Crearemos un decorador más flexible que pueda aceptar especificaciones de tipo como argumentos. Esto significa que podemos definir los tipos que queremos para cada argumento y el valor de retorno de una manera más explícita.

Comprendiendo el objetivo

Nuestro objetivo es crear un decorador @enforce(). Este decorador nos permitirá especificar restricciones de tipo utilizando argumentos de palabra clave. Aquí tienes un ejemplo de cómo funcionará:

@enforce(x=Integer, y=Integer, return_=Integer)
def add(x, y):
    return x + y

En este ejemplo, estamos utilizando el decorador @enforce para especificar que los argumentos x e y de la función add deben ser de tipo Integer, y el valor de retorno también debe ser de tipo Integer. Este decorador se comportará de manera similar al decorador @validated anterior, pero nos da más control sobre las especificaciones de tipo.

Creando el decorador enforce

  1. Primero, abre el archivo validate.py en el WebIDE. Agregaremos nuestro nuevo decorador a este archivo. Aquí está el código que agregaremos:
from functools import wraps

class Integer:
    @classmethod
    def __instancecheck__(cls, x):
        return isinstance(x, int)

def validated(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        ## Get function annotations
        annotations = func.__annotations__
        ## Check arguments against annotations
        for arg_name, arg_value in zip(func.__code__.co_varnames, args):
            if arg_name in annotations and not isinstance(arg_value, annotations[arg_name]):
                raise TypeError(f'Expected {arg_name} to be {annotations[arg_name].__name__}')

        ## Run the function and get the result
        result = func(*args, **kwargs)

        ## Check the return value
        if 'return' in annotations and not isinstance(result, annotations['return']):
            raise TypeError(f'Expected return value to be {annotations["return"].__name__}')

        return result
    return wrapper

def enforce(**type_specs):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            ## Check argument types
            for arg_name, arg_value in zip(func.__code__.co_varnames, args):
                if arg_name in type_specs and not isinstance(arg_value, type_specs[arg_name]):
                    raise TypeError(f'Expected {arg_name} to be {type_specs[arg_name].__name__}')

            ## Run the function and get the result
            result = func(*args, **kwargs)

            ## Check the return value
            if 'return_' in type_specs and not isinstance(result, type_specs['return_']):
                raise TypeError(f'Expected return value to be {type_specs["return_"].__name__}')

            return result
        return wrapper
    return decorator

Analicemos lo que hace este código. La clase Integer se utiliza para definir un tipo personalizado. El decorador validated comprueba los tipos de los argumentos de la función y el valor de retorno en función de las anotaciones de tipo de la función. El decorador enforce es el nuevo que estamos creando. Toma argumentos de palabra clave que especifican los tipos para cada argumento y el valor de retorno. Dentro de la función wrapper del decorador enforce, comprobamos si los tipos de los argumentos y el valor de retorno coinciden con los tipos especificados. Si no, lanzamos una excepción TypeError.

  1. Ahora, probemos nuestro nuevo decorador @enforce. Ejecutaremos algunos casos de prueba para ver si funciona como se espera. Aquí está el código para ejecutar las pruebas:
cd ~/project
python3 -c "from validate import enforce, Integer

@enforce(x=Integer, y=Integer, return_=Integer)
def add(x, y):
    return x + y

## This should work
print(add(2, 3))

## This should raise a TypeError
try:
    print(add('2', 3))
except TypeError as e:
    print(f'Error: {e}')

## This should raise a TypeError
try:
    @enforce(x=Integer, y=Integer, return_=Integer)
    def bad_add(x, y):
        return str(x + y)
    print(bad_add(2, 3))
except TypeError as e:
    print(f'Error: {e}')"

En este código de prueba, primero definimos una función add con el decorador @enforce. Luego llamamos a la función add con argumentos válidos, lo que debería funcionar sin errores. A continuación, llamamos a la función add con un argumento no válido, lo que debería lanzar una excepción TypeError. Finalmente, definimos una función bad_add que devuelve un valor del tipo incorrecto, lo que también debería lanzar una excepción TypeError.

Cuando ejecutes este código de prueba, deberías ver una salida similar a la siguiente:

5
Error: Expected x to be Integer
Error: Expected return value to be Integer

Esta salida muestra que nuestro decorador @enforce está funcionando correctamente. Lanza una excepción TypeError cuando los tipos de los argumentos o el valor de retorno no coinciden con los tipos especificados.

Comparando los dos enfoques

Tanto el decorador @validated como el decorador @enforce logran el mismo objetivo de aplicar restricciones de tipo, pero lo hacen de diferentes maneras.

  1. El decorador @validated utiliza las anotaciones de tipo integradas de Python. Aquí tienes un ejemplo:

    @validated
    def add(x: Integer, y: Integer) -> Integer:
        return x + y

    Con este enfoque, especificamos los tipos directamente en la definición de la función utilizando anotaciones de tipo. Esta es una característica integrada de Python y proporciona mejor soporte en Entornos de Desarrollo Integrados (IDEs). Los IDEs pueden utilizar estas anotaciones de tipo para proporcionar finalización de código, comprobación de tipos y otras características útiles.

  2. El decorador @enforce, por otro lado, utiliza argumentos de palabra clave para especificar los tipos. Aquí tienes un ejemplo:

    @enforce(x=Integer, y=Integer, return_=Integer)
    def add(x, y):
        return x + y

    Este enfoque es más explícito porque estamos pasando directamente las especificaciones de tipo como argumentos al decorador. Puede ser útil cuando se trabaja con bibliotecas que se basan en otros sistemas de anotación.

Cada enfoque tiene sus propias ventajas. Las anotaciones de tipo son una parte nativa de Python y ofrecen mejor soporte en los IDE, mientras que el enfoque @enforce nos da más flexibilidad y explicación. Puedes elegir el enfoque que mejor se adapte a tus necesidades según el proyecto en el que estés trabajando.

✨ Revisar Solución y Practicar

Resumen

En este laboratorio, has aprendido cómo crear y utilizar decoradores de manera efectiva. Has aprendido a preservar los metadatos de las funciones con functools.wraps, a crear decoradores que aceptan parámetros, a manejar múltiples decoradores y a entender su orden de aplicación. También has aprendido a aplicar decoradores a diferentes métodos de clase y a crear un decorador de aplicación de tipos que toma argumentos.

Estos patrones de decoradores se utilizan comúnmente en frameworks de Python como Flask, Django y pytest. Dominar los decoradores te permitirá escribir código más mantenible y reutilizable. Para profundizar en tu aprendizaje, puedes explorar los gestores de contexto (context managers), los decoradores basados en clases, el uso de decoradores para el almacenamiento en caché (caching) y la comprobación avanzada de tipos con decoradores.