Encadeamento de Decorators e Decorators Parametrizados

Beginner

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

Introdução

Neste laboratório, você aprenderá sobre decorators (decoradores) em Python, um recurso poderoso que pode modificar o comportamento de funções e métodos. Decorators são comumente usados para tarefas como logging (registro), medição de desempenho, controle de acesso e verificação de tipos.

Você aprenderá como encadear múltiplos decorators, criar decorators que aceitam parâmetros, preservar metadados de funções ao usar decorators e aplicar decorators a diferentes tipos de métodos de classe. Os arquivos com os quais você trabalhará são logcall.py, validate.py e sample.py.

Preservando Metadados de Funções em Decoradores

Em Python, decorators (decoradores) são uma ferramenta poderosa que permite modificar o comportamento de funções. No entanto, ao usar um decorator para envolver uma função, há um pequeno problema. Por padrão, os metadados da função original, como seu nome, docstring (string de documentação) e anotações, são perdidos. Metadados são importantes porque ajudam na introspecção (examinando a estrutura do código) e na geração de documentação. Vamos primeiro verificar esse problema.

Abra seu terminal no WebIDE. Executaremos alguns comandos Python para ver o que acontece quando usamos um decorator. Os seguintes comandos criarão uma função simples add envolvida com um decorator e, em seguida, imprimirão a função e sua 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__)"

Ao executar esses comandos, você verá uma saída semelhante a esta:

<function wrapper at 0x...>
None

Observe que, em vez de mostrar o nome da função como add, ele mostra wrapper. E a docstring, que deveria ser 'Adds two things', é None. Isso pode ser um grande problema quando você está usando ferramentas que dependem desses metadados, como ferramentas de introspecção ou geradores de documentação.

Corrigindo o Problema com functools.wraps

O módulo functools do Python vem para o resgate. Ele fornece um decorator wraps que pode nos ajudar a preservar os metadados da função. Vamos ver como podemos modificar nosso decorator logged para usar wraps.

  1. Primeiro, abra o arquivo logcall.py no WebIDE. Você pode navegar até o diretório do projeto usando o seguinte comando no terminal:
cd ~/project
  1. Agora, atualize o decorator logged em logcall.py com o seguinte código. O decorator @wraps(func) é a chave aqui. Ele copia todos os metadados da função original func para a função wrapper.
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. O decorator @wraps(func) faz um trabalho importante. Ele pega todos os metadados (como o nome, docstring e anotações) da função original func e os anexa à função wrapper. Dessa forma, quando usamos a função decorada, ela terá os metadados corretos.

  2. Vamos testar nosso decorator aprimorado. Execute os seguintes comandos no terminal:

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

Agora você deve ver:

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

Ótimo! O nome da função e a docstring são preservados. Isso significa que nosso decorator agora está funcionando como esperado, e os metadados da função original estão intactos.

Corrigindo o Decorator validate.py

Agora, vamos aplicar a mesma correção ao decorator validated em validate.py. Este decorator é usado para validar os tipos de argumentos de função e o valor de retorno com base nas anotações da função.

  1. Abra validate.py no WebIDE.

  2. Atualize o decorator validated com o decorator @wraps. O seguinte código mostra como fazer isso. O decorator @wraps(func) é adicionado à função wrapper dentro do decorator validated para preservar os metadados.

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. Vamos testar se nosso decorator validated agora preserva metadados. Execute os seguintes comandos no 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__)"

Você deve ver:

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

Agora, ambos os decorators, logged e validated, preservam adequadamente os metadados das funções que decoram. Isso garante que, ao usar esses decorators, as funções ainda terão seus nomes originais, docstrings e anotações, o que é muito útil para a legibilidade e manutenibilidade do código.

Criando Decoradores com Argumentos

Até agora, temos usado o decorator @logged, que sempre imprime uma mensagem fixa. Mas e se você quiser personalizar o formato da mensagem? Nesta seção, aprenderemos como criar um novo decorator que pode aceitar argumentos, dando a você mais flexibilidade na forma como você usa decorators.

Entendendo Decoradores Parametrizados

Um decorator parametrizado é um tipo especial de função. Em vez de modificar diretamente outra função, ele retorna um decorator. A estrutura geral de um decorator parametrizado se parece com isto:

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

Quando você usa @decorator_with_args(value1, value2) em seu código, o Python primeiro chama decorator_with_args(value1, value2). Essa chamada retorna o decorator real, que é então aplicado à função que segue a sintaxe @. Esse processo de duas etapas é fundamental para o funcionamento dos decorators parametrizados.

Criando o Decorator logformat

Vamos criar um decorator @logformat(fmt) que recebe uma string de formato como argumento. Isso nos permitirá personalizar a mensagem de logging.

  1. Abra logcall.py no WebIDE e adicione o novo decorator. O código abaixo mostra como definir tanto o decorator logged existente quanto o novo decorator 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

No decorator logformat, a função externa logformat recebe uma string de formato fmt como argumento. Em seguida, ele retorna a função decorator, que é o decorator real que modifica a função de destino.

  1. Agora, vamos testar nosso novo decorator modificando sample.py. O código a seguir mostra como usar os decorators logged e logformat em diferentes funções:
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

Aqui, as funções add e sub usam o decorator logged, enquanto a função mul usa o decorator logformat com uma string de formato personalizada.

  1. Execute o sample.py atualizado para ver os resultados. Abra seu terminal e execute o seguinte comando:
cd ~/project
python3 -c "import sample; print(sample.add(2, 3)); print(sample.mul(2, 3))"

Você deve ver uma saída semelhante a:

Calling add
5
sample.py:mul
6

Essa saída mostra que o decorator logged imprime o nome da função como esperado, e o decorator logformat usa a string de formato personalizada para imprimir o nome do arquivo e o nome da função.

Redefinindo o Decorator logged Usando logformat

Agora que temos um decorator logformat mais flexível, podemos redefinir nosso decorator logged original usando-o. Isso nos ajudará a reutilizar o código e manter um formato de logging consistente.

  1. Atualize logcall.py com o seguinte 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)

Aqui, usamos uma função lambda para definir o decorator logged em termos do decorator logformat. A função lambda recebe uma função func e aplica o decorator logformat com uma string de formato específica.

  1. Teste se o decorator logged redefinido ainda funciona. Abra seu terminal e execute o seguinte comando:
cd ~/project
python3 -c "from logcall import logged; @logged
def greet(name):
    return f'Hello, {name}'
    
print(greet('World'))"

Você deve ver:

Calling greet
Hello, World

Isso mostra que o decorator logged redefinido funciona como esperado, e reutilizamos com sucesso o decorator logformat para obter um formato de logging consistente.

Aplicando Decoradores a Métodos de Classe

Agora, vamos explorar como os decorators interagem com métodos de classe. Isso pode ser um pouco complicado porque o Python tem diferentes tipos de métodos: métodos de instância, métodos de classe, métodos estáticos e propriedades. Decorators são funções que recebem outra função e estendem o comportamento da última função sem modificá-la explicitamente. Ao aplicar decorators a métodos de classe, precisamos prestar atenção em como eles funcionam com esses diferentes tipos de métodos.

Entendendo o Desafio

Vamos ver o que acontece quando aplicamos nosso decorator @logged a diferentes tipos de métodos. O decorator @logged provavelmente é usado para registrar informações sobre as chamadas de método.

  1. Crie um novo arquivo methods.py no WebIDE. Este arquivo conterá nossa classe com diferentes tipos de métodos decorados com o decorator @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"

Neste código, temos uma classe Spam com quatro tipos diferentes de métodos. Cada método é decorado com o decorator @logged, e alguns também são decorados com outros decorators embutidos como @classmethod, @staticmethod e @property.

  1. Vamos testar como funciona. Executaremos um comando Python no terminal para chamar esses métodos e ver a saída.
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)"

Ao executar este comando, você pode notar alguns problemas:

  • O decorator @property pode não funcionar corretamente com nosso decorator @logged. O decorator @property é usado para definir um método como uma propriedade, e ele tem uma maneira específica de funcionar. Quando combinado com o decorator @logged, pode haver conflitos.
  • A ordem dos decorators importa para @classmethod e @staticmethod. A ordem em que os decorators são aplicados pode alterar o comportamento do método.

A Ordem dos Decoradores

Ao aplicar vários decorators, eles são aplicados de baixo para cima. Isso significa que o decorator mais próximo da definição do método é aplicado primeiro, e então os acima dele são aplicados em sequência. Por exemplo:

@decorator1
@decorator2
def func():
    pass

Isso é equivalente a:

func = decorator1(decorator2(func))

Neste exemplo, decorator2 é aplicado a func primeiro, e então decorator1 é aplicado ao resultado de decorator2(func).

Corrigindo a Ordem do Decorator

Vamos atualizar nosso arquivo methods.py para corrigir a ordem do decorator. Ao alterar a ordem dos decorators, podemos garantir que cada método funcione como esperado.

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"

Nesta versão atualizada:

  • Para instance_method, a ordem não importa. Métodos de instância são chamados em uma instância da classe, e o decorator @logged pode ser aplicado em qualquer ordem sem afetar sua funcionalidade básica.
  • Para class_method, aplicamos @classmethod após @logged. O decorator @classmethod altera a maneira como o método é chamado, e aplicá-lo após @logged garante que o logging funcione corretamente.
  • Para static_method, aplicamos @staticmethod após @logged. Semelhante ao @classmethod, o decorator @staticmethod tem seu próprio comportamento, e a ordem com o decorator @logged precisa estar correta.
  • Para property_method, aplicamos @property após @logged. Isso garante que o comportamento da propriedade seja mantido, além de obter a funcionalidade de logging.
  1. Vamos testar o código atualizado. Executaremos o mesmo comando que antes para ver se os problemas foram corrigidos.
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)"

Você deve agora ver o logging adequado para todos os 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

Melhores Práticas para Decoradores de Métodos

Ao trabalhar com decorators de métodos, siga estas melhores práticas:

  1. Aplique decorators de transformação de método (@classmethod, @staticmethod, @property) após seus decorators personalizados. Isso garante que os decorators personalizados possam executar seu logging ou outras operações primeiro, e então os decorators embutidos podem transformar o método conforme o pretendido.
  2. Esteja ciente de que a execução do decorator ocorre no momento da definição da classe, não no momento da chamada do método. Isso significa que qualquer código de configuração ou inicialização no decorator será executado quando a classe for definida, não quando o método for chamado.
  3. Para casos mais complexos, você pode precisar criar decorators especializados para diferentes tipos de métodos. Diferentes tipos de métodos têm comportamentos diferentes, e um decorator único pode não funcionar em todas as situações.

Criando um Decorator de Aplicação de Tipos com Argumentos

Nos passos anteriores, aprendemos sobre o decorator @validated. Este decorator é usado para impor anotações de tipo em funções Python. Anotações de tipo são uma maneira de especificar os tipos esperados de argumentos de função e valores de retorno. Agora, vamos dar um passo adiante. Criaremos um decorator mais flexível que pode aceitar especificações de tipo como argumentos. Isso significa que podemos definir os tipos que queremos para cada argumento e o valor de retorno de uma forma mais explícita.

Entendendo o Objetivo

Nosso objetivo é criar um decorator @enforce(). Este decorator nos permitirá especificar restrições de tipo usando argumentos de palavra-chave. Aqui está um exemplo de como ele funcionará:

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

Neste exemplo, estamos usando o decorator @enforce para especificar que os argumentos x e y da função add devem ser do tipo Integer, e o valor de retorno também deve ser do tipo Integer. Este decorator se comportará de maneira semelhante ao nosso decorator @validated anterior, mas nos dá mais controle sobre as especificações de tipo.

Criando o Decorator enforce

  1. Primeiro, abra o arquivo validate.py no WebIDE. Adicionaremos nosso novo decorator a este arquivo. Aqui está o código que adicionaremos:
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

Vamos detalhar o que este código faz. A classe Integer é usada para definir um tipo personalizado. O decorator validated verifica os tipos de argumentos de função e o valor de retorno com base nas anotações de tipo da função. O decorator enforce é o novo que estamos criando. Ele recebe argumentos de palavra-chave que especificam os tipos para cada argumento e o valor de retorno. Dentro da função wrapper do decorator enforce, verificamos se os tipos dos argumentos e o valor de retorno correspondem aos tipos especificados. Caso contrário, lançamos um TypeError.

  1. Agora, vamos testar nosso novo decorator @enforce. Executaremos alguns casos de teste para ver se ele funciona como esperado. Aqui está o código para executar os testes:
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}')"

Neste código de teste, primeiro definimos uma função add com o decorator @enforce. Em seguida, chamamos a função add com argumentos válidos, que devem funcionar sem erros. Em seguida, chamamos a função add com um argumento inválido, que deve lançar um TypeError. Finalmente, definimos uma função bad_add que retorna um valor do tipo errado, que também deve lançar um TypeError.

Ao executar este código de teste, você deve ver uma saída semelhante à seguinte:

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

Esta saída mostra que nosso decorator @enforce está funcionando corretamente. Ele lança um TypeError quando os tipos dos argumentos ou o valor de retorno não correspondem aos tipos especificados.

Comparando as Duas Abordagens

Tanto o decorator @validated quanto o @enforce atingem o mesmo objetivo de impor restrições de tipo, mas o fazem de maneiras diferentes.

  1. O decorator @validated usa as anotações de tipo embutidas do Python. Aqui está um exemplo:

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

    Com esta abordagem, especificamos os tipos diretamente na definição da função usando anotações de tipo. Este é um recurso embutido do Python, e ele fornece melhor suporte em Ambientes de Desenvolvimento Integrados (IDEs). Os IDEs podem usar essas anotações de tipo para fornecer preenchimento de código, verificação de tipo e outros recursos úteis.

  2. O decorator @enforce, por outro lado, usa argumentos de palavra-chave para especificar os tipos. Aqui está um exemplo:

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

    Esta abordagem é mais explícita porque estamos passando diretamente as especificações de tipo como argumentos para o decorator. Pode ser útil ao trabalhar com bibliotecas que dependem de outros sistemas de anotação.

Cada abordagem tem suas próprias vantagens. As anotações de tipo são uma parte nativa do Python e oferecem melhor suporte de IDE, enquanto a abordagem @enforce nos dá mais flexibilidade e explicitude. Você pode escolher a abordagem que melhor se adapta às suas necessidades, dependendo do projeto em que está trabalhando.

Resumo

Neste laboratório, você aprendeu a criar e usar decorators de forma eficaz. Você aprendeu a preservar metadados de função com functools.wraps, criar decorators que aceitam parâmetros, lidar com múltiplos decorators e entender sua ordem de aplicação. Você também aprendeu a aplicar decorators a diferentes métodos de classe e a criar um decorator de aplicação de tipos que recebe argumentos.

Esses padrões de decorator são comumente usados em frameworks Python como Flask, Django e pytest. Dominar decorators permitirá que você escreva um código mais sustentável e reutilizável. Para aprofundar seu aprendizado, você pode explorar gerenciadores de contexto, decorators baseados em classe, usando decorators para caching e verificação de tipo avançada com decorators.