Decoradores 101

Publicado por Rui Conti em 02/05/2020

Primeiramente, por que, entre todos os assuntos possíveis, falar sobre decoradores? Sim, temos alguns argumentos:

  1. Usamos python em produção na T10
  2. Adotamos o princípio DRY
  3. Decoradores podem ser ótimas ferramentas para se aplicar DRY
  4. Adotamos alguns frameworks que fazem uso extensivo de decoradores
  5. Porque te faz pensar em um nível alto de execução de programas

Decidimos redigir este artigo para apresentar decoradores sob dois pontos de vista, a fim de introduzir, além desse açucar sintático, o entendimento sobre funções de ordem maior em Python:

  1. Da pilha de chamadas
  2. Do tempo

Este artigo é baseado na excelente palestra de Rauven M. Lerner na PyCon de 2019, em Ohio.

Sugerimos que durante essa leitura estejam com um REPL do python aberto (recomendamos o ipython) para seguir interativamente, e fixar ideias que durem mais que os 10min de leitura

Breve apresentação

Em termos simples, um decorador é uma forma de adicionar informações à uma função. É por isso que recebeu este nome, porque decora uma função (na verdade, vamos ver que não apenas funções).

Começamos apresentando, para aqueles que não conhecem, o que é um decorador. Considere uma função simples de adição:

>>> def add(*args):
...    return sum(args)

>>> add(2, 2)
4

Desta forma, uma função decoradora é uma função que recebe e retorna uma função –um objeto do tipo função:

>>> def deco(fun):
...    def fun_scope(*args):
...        print("I can intercept your calls")
...        print(f"And inspect your args: {args}")
...        func.attr = "decorated"
...        return fun(*args)
...    return fun_scope

>>> add = deco(add)
>>> add(2, 2)
I can intercept your calls
And inspect your args: (2, 2)
4
>>> add.attr
decorated

Vemos que, por meio da função decoradora deco, conseguimos subir um nível na execução de chamadas e ganhar controle sobre o que acontece quando add é chamada.

Concluímos, então, que deco está uma ordem acima de add.

Um pouco de história

Ainda não apresentamos o decorador, de fato. Não tem nada de novo no exemplo acima se já entendemos o conceito de funções de ordem maior. Python sempre conseguiu implementar essas chamadas porque as funções, em Python, são cidadãos de primeira classe.

No entanto, o que nem sempre existiu foi a sintaxe abaixo:

@deco
def add(*args):
    return sum(args)

A motivação principal para sua implementação era ter uma forma mais limpa de se aplicar os métodos staticmethod() e classmethod() na declaração de métodos estáticos em classes. A sintaxe foi proposta na PEP 318 e foi aprovada e implementada na versão 2.4. A PEP é bem descritiva e mostra as diversas opiniões (desde o nome “decoradores” à escolha do caractere “@") sobre este incremento na linguagem.

““Chamáveis””

(Boa) parte da resistência em redigir um artigo técnico vêm da falta de criatividade em criar nomes não-toscos para termos que, originalmente, fazem muito mais sentido em inglês. Portanto, convenciona-se (no escopo deste artigo) que o termo chamáveis é a tradução literal de callables.

Bom, o que é um chamável em python? É qualquer objeto que implemente a função __call__().

Como validamos isso? Vamos fazer uso do método built-in hasattr que nos conta se um determinado objeto possui um determinado atributo:

>>> hasattr(add, "__call__")
True

E uma classe? (Vamos fazer uso de outro método built-in, especificamente, para verificar chamáveis)

>>> class Mock(object):
...    pass

>>> callable(Mock)
True

Nos interessa saber o que é um chamável porque conseguimos decorar –e controlar o comportamento– de qualquer chamável.

Decoradores simples

Quando uma função é definida,

>>> def add(*args):
...    return sum(args)

Ao passar pelo keyword def, acontecem (essencialmente) duas coisas:

  1. Cria o nome add no namespace atual
    1. Cria-se um escopo para add
  2. Associa-se ao nome add um objeto do tipo “função definida pelo usuário” (user-defined function)

Este objeto-função é este aqui:

>>> type(add)
<class 'function'>

Portanto, entendemos que o nome add e o objeto de função associado à add são coisas distintas: Um é o nome e o outro é o objeto. É “dentro” do objeto de função que está, por exemplo, o código da função:

>>> import inspect
>>> inspect.getsource(add)
'def add(*args):\n    return sum(args)\n'

Análise dos chamáveis de um decorador

Quando usamos um decorador, devemos entender que

@deco
def add(*args):
    return sum(args)

é semânticamente idêntico à fazer isto:

add = deco(add)

Sugestão do autor: sempre que for flagrado um decorador, realizar esta tradução

E que, independente da forma como escrevemos, estamos lidando com três chamáveis:

  1. A função decoradora:
    deco
    
  2. A função que está sendo decorada:
    add
    
  3. A função (valor) retornada pela expressão:
    deco(add)
    

Vale a pena ressaltar porque o último chamável é implícito. Ele é, na mesma linha, retornado e associado, novamente, ao nome add.

Estes três chamáveis ficam implícitos quando analisamos a definição de deco:

def deco(fun):
    somevar = list()
    def wrapper(*args):
        print("I can intercept your calls")
        print(f"And inspect your args: {args}")
        func.attr = "decorated"
        return fun(*args)
    return wrapper

Análise de execução temporal

Para fins didáticos, faz sentido separar os decoradores em dois momentos, do ponto de vista de quando o interpretador passa por dois trechos:

Trecho da função decoradora

Quando o interpretador passa pelo trecho:

@deco
def add():
   ...

Ele segue para a definição de deco:

# Só são executados os trechos marcados
def deco(fun):
    somevar = list()
    def wrapper(*args):
        print("I can intercept your calls")
        print(f"And inspect your args: {args}")
        func.attr = "decorated"
        return fun(*args)
    return wrapper

Que associa, em seu namespace, o parâmetro fun ao objeto função associado ao nome add. Este trecho é executado apenas na declaração da função decorada.

Trecho da função decorada

Quando, depois de executar o bloco acima, o interpretador passa por qualquer trecho que chama add:

add(2, 3)

Ele segue para a definição interna do decorador, ou seja, do embrulho da função:

# Só são executados os trechos marcados
def deco(fun):
    somevar = list()
    def wrapper(*args):
        print("I can intercept your calls")
        print(f"And inspect your args: {args}")
        func.attr = "decorated"
        return fun(*args)
    return wrapper

Que ilustra com clareza o motivo de deco estar a uma ordem acima de add (ou qualquer outra função que deco decore).

Para entender como a função interna, wrapper, tem acesso à função objeto armazenada no nome func, é válido conhecer a ordem de resolução de escopos do Python: LEGB — Local Enclosed Global Built-in

E esse *args aí, hein? É assunto para outro post. Neste contexto, são formas de definir argumentos arbitrários para funções para que o decorador possa ser usado em qualquer função

Uma função que controla outras funções

O grande “takeaway” dos decoradores é este: a capacidade de escrever funções que controlam o comportamento de outras funções.

Imagine que seja atribuído a você fazer várias requisições à uma API externa para enriquecimento de uma base de dados. Consigo imaginar alguns exemplos em que os decoradores seriam úteis:

  1. Encapsular uma de-serialização padrão da resposta
  2. Metrificar o tempo que cada requisição leva
  3. Validar se o cabeçalho de requisição está com as entradas necessárias
  4. Definir um limite de consumo de um determinado endpoint
  5. ad infinitum…

Vamos implementar, a título de ilustração, como seria um decorador que controla o limite de consumo de um determinado endpoint:

LIMIT = 5

def limit_usage(func):
    counter = 0
    def wrapper(*args, **kwargs):
        nonlocal counter  # explicita a referência à outro escopo não-local
        url = kwargs["url"]
        if counter >= LIMIT:
            raise RuntimeException(f"Exceeded calls to {url}")
            return None
        counter += 1
        return func(*args, **kwargs)
    return wrapper

Assim, na implementação do consumo de uma informação específica:

@limit_usage
def query_property(url, id):
    ...
    return result

Permite que a função query_property seja executada no máximo 5 vezes.

Decoradores com argumentos

Imagine que queremos definir um decorador dinâmico. Isto é, que receba argumentos de controle definidos pelo usuário.

Adotando o exemplo anterior, desejamos que o limitador funcione por um parâmetro max_calls de vezes, em vez de um valor constante.

@limit_usage(15)
def query_property(url, id):
    ...
    return result

Que significa executar:

query_property = limit_usage(15)(query_property)

O que, podemos ver, aumenta mais um nível de ordem de execução. Em outras palavras, ao incluir um argumento no decorador, aumenta-se também mais um chamável.

Quando tentamos implementar a função do decorador fica mais evidente os quatro chamáveis:

# São ilustrados os chamáveis
def limit_usage(max_calls):  # 1
    def outer_wrapper(func):
        counter = 0
        def inner_wrapper(*args, **kwargs):
            nonlocal counter  # explicita a referência à escopo não-local
            url = kwargs["url"]
            if counter >= max_calls:
                raise RuntimeException(f"Exceeded calls to {url}")
                return None
            counter += 1
            result = func(*args, **kwargs)  # 2
            return result
        return inner_wrapper  # 3
    return outer_wrapper  # 4

Ou seja, os chamáveis são:

  1. A função decoradora: limit_usage
  2. O resultado da expressão limit_usage(15): outer_wrapper
  3. O resultado da expressão limit_usage(15) aplicada à função query_property: inner_wrapper
  4. O resultado da função query_property func

Do ponto de vista temporal, conseguimos separar em três momentos: um para cada ordem de execução:

  1. Quando o interpretador aplica o argumento do decorador na função decoradora:

    # Só são executados os trechos marcados
    def limit_usage(max_calls):
        def outer_wrapper(func):
            counter = 0
            def inner_wrapper(*args, **kwargs):
                nonlocal counter
                url = kwargs["url"]
                if counter >= max_calls:
                    raise RuntimeException(f"Exceeded calls to {url}")
                    return None
                counter += 1
                result = func(*args, **kwargs)
                return result
            return inner_wrapper
        return outer_wrapper
    

    Ao final desta execução, é como se voltássemos ao decorador simples, em que o valor de max_calls era uma constante. A diferença, agora, é que o max_calls se torna uma constante dentro do escopo de query_property.

  2. Quando a função decoradora se associa à função decorada:

    # Só são executados os trechos marcados
    def limit_usage(max_calls):
        def outer_wrapper(func):
            counter = 0
            def inner_wrapper(*args, **kwargs):
                nonlocal counter
                url = kwargs["url"]
                if counter >= max_calls:
                    raise RuntimeException(f"Exceeded calls to {url}")
                    return None
                counter += 1
                result = func(*args, **kwargs)
                return result
            return inner_wrapper
        return outer_wrapper
    

    Nesta execução já temos o mesmo comportamento observado no decorador simples. Sem novidades.

  3. Quando o interpretador executa a função decorada:

     # Só são executados os trechos marcados
     def limit_usage(max_calls):
         def outer_wrapper(func):
             counter = 0
             def inner_wrapper(*args, **kwargs):
                 nonlocal counter
                 url = kwargs["url"]
                 if counter >= max_calls:
                     raise RuntimeException(f"Exceeded calls to {url}")
                     return None
                 counter += 1
                 result = func(*args, **kwargs)
                 return result
             return inner_wrapper
         return outer_wrapper
    

    Este é o momento que é executado toda as vezes que func() é chamada

Indo um pouco longe (que também é perto)

Os familiarizados com cálculo com certeza se depararam com a regra da cadeia para resolver derivadas. E, quando sobrava a resolução pela regra da cadeia, –em consenso com os colegas de turma– era baba, fácil de resolver. A ideia em trazer a analogia é para tentar simplificar a compreensão de funções compostas.

O que estamos fazendo aqui é basicamente a mesma coisa: associando funções compostas. E, ao adicionar argumentos à um decorador, seria equivalente à aumentar o grau de função.

Portanto podemos dizer que:

$$(f\circ g)’ = (f’\circ g) \cdot g'$$

é o mesmo, em Python:

@f(y)
def g(x):
    ...

g = f(y)(g)

Fechando tudo

Decoradores e funções de ordem maior, em geral, é um ferramental (disponível em várias linguagens) que possibilita um grau maior de liberdade e de flexibilidade ao programador.

Presentes em vários frameworks, o entendimento de seu funcionamento permite que o programador seja capaz de fazer mais com menos. Com o que já existe. Alguns exemplos:

  1. Parametrização de testes no pytest:
    @pytest.mark.parametrize(
        "test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)]
    )
    def test_eval(test_input, expected):
        assert eval(test_input) == expected
    
  2. Autenticação de endpoints no django:
    @permission_required(["GET", "POST"])
    def my_view(request):
        ...
    
  3. Definição de endpoints no aiohttp:
    @routes.get('/')
    async def hello(request):
        return web.Response(text="Hello, world")
    

Espero que este tutorial tenha, de alguma forma, clareado dúvidas e favorecido o entendimento sobre o assunto 😄

Com carinho,

Rui