O que um decorador realmente faz com sua função

Você quer medir o tempo de cada função que chama uma API externa lenta. Pode colar chamadas a time.perf_counter() em cada uma, ou escrever a medição uma única vez e aplicá-la em todas. Um decorador é a segunda opção: é uma função que recebe outra função, a envolve em um comportamento extra e devolve a versão envolvida com o mesmo nome original.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import time

def timed(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper

@timed
def fetch_price(ticker):
    time.sleep(0.2)  # no lugar de uma chamada de rede
    return 142.50

fetch_price("AAPL")  # fetch_price took 0.201s

@timed acima de def fetch_price é a forma abreviada de fetch_price = timed(fetch_price). O Python chama timed uma única vez, logo após fetch_price ser definida, e reatribui o nome fetch_price ao que timed devolve. É só isso que o @ faz.

Como funcionam os decoradores: funções como objetos e closures

Em Python, funções são objetos. Você pode passar uma como argumento e devolver outra a partir de uma função, do mesmo jeito que faria com uma lista. wrapper também é uma closure: ela captura func do escopo que a envolve e continua usando essa referência a cada execução, bem depois de timed já ter retornado.

Esse é todo o mecanismo. Decoradores com argumentos, decoradores baseados em classe e embutidos como @property são variações da mesma ideia.

Como functools.wraps preserva o nome e a docstring da função

O exemplo de timed tem um bug que você só percebe ao inspecionar o resultado:

1
2
print(fetch_price.__name__)  # wrapper
print(fetch_price.__doc__)   # None

fetch_price perdeu o nome e a docstring, porque é wrapper que é devolvido. Qualquer coisa que leia os metadados da função vê wrapper em seu lugar: help(), depuradores, frameworks web que derivam nomes de rota ou endpoint a partir da função. Corrija isso com functools.wraps:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from functools import wraps

def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper

wraps copia __name__, __doc__, __module__ e __qualname__ (além de __annotations__) do original para o wrapper, e adiciona um atributo __wrapped__ que aponta de volta para ele. Custa uma linha. Coloque @wraps(func) em todo wrapper que você escrever.

Como escrever um decorador com argumentos

Um decorador simples recebe um único argumento: a função. Quando o decorador precisa da própria configuração, como quantas vezes tentar de novo e quanto esperar, você acrescenta uma camada: uma função que recebe a configuração e devolve um decorador.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import time
from functools import wraps

def retry(times=3, delay=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except ConnectionError as e:
                    last_error = e
                    time.sleep(delay)
            raise last_error
        return wrapper
    return decorator

@retry(times=5, delay=2)
def fetch_price(ticker):
    ...

Leia de fora para dentro. retry(times=5, delay=2) executa primeiro e devolve decorator. decorator(fetch_price) executa depois e devolve wrapper. wrapper é o que roda em cada chamada de fetch_price(...).

Três níveis de aninhamento são mais difíceis de ler do que um só. Se times e delay nunca mudam no seu código, fixe os valores e volte para a forma com um único argumento.

Como escrever um decorador baseado em classe

Qualquer objeto com um método __call__ pode funcionar como decorador. Recorra a uma classe quando o decorador precisa manter estado entre chamadas, não só configuração:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class CountCalls:
    def __init__(self, func):
        self.func = func
        self.calls = 0

    def __call__(self, *args, **kwargs):
        self.calls += 1
        return self.func(*args, **kwargs)

@CountCalls
def process_order(order_id):
    ...

process_order(101)
process_order(102)
print(process_order.calls)  # 2

@CountCalls substitui process_order por uma instância de CountCalls, que mantém calls entre as chamadas. Uma closure com um contador nonlocal poderia fazer o mesmo, mas a classe deixa o estado explícito e dá um lugar para colocar um método reset().

Duas ressalvas. wraps não se aplica diretamente aqui: chame functools.update_wrapper(self, func) em __init__ se quiser os metadados. E essa versão quebra em métodos: a instância não tem __get__, então o self do método decorado nunca é passado. Para decorar métodos, um decorador baseado em função é a opção mais simples.

Decoradores embutidos: @staticmethod, @property e @lru_cache

Os decoradores que o Python já traz usam o mesmo mecanismo, por isso deixam de parecer obscuros depois que você escreve um por conta própria:

  • @staticmethod e @classmethod mudam o que um método recebe como primeiro argumento: nada extra, ou a classe em vez da instância.
  • @property transforma uma chamada de método em acesso a atributo, obj.value em vez de obj.value(), mas continua executando código por baixo.
  • @functools.lru_cache(maxsize=128) devolve um resultado em cache quando a função é chamada de novo com os mesmos argumentos. @functools.cache (Python 3.9+) faz o mesmo sem limite de tamanho. Use cache quando o conjunto de entradas possíveis é pequeno e limitado, lru_cache quando não é.
1
2
3
4
5
6
7
from functools import lru_cache

@lru_cache(maxsize=256)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

Sem o cache, fibonacci(35) recalcula os mesmos resultados parciais milhões de vezes. Com ele, cada n é calculado uma única vez.

Em que ordem decoradores empilhados são executados?

Decoradores se aplicam de baixo para cima e executam de cima para baixo:

1
2
3
4
@retry(times=3)
@timed
def fetch_price(ticker):
    ...

timed envolve fetch_price primeiro, e retry envolve o resultado. Na chamada, o wrapper de retry executa primeiro e chama a versão cronometrada a cada tentativa. Assim, timed imprime um número por tentativa e nunca conta a espera entre as tentativas. Troque as duas linhas e timed fica fora de retry, reportando todo o ciclo de retry, esperas incluídas, como um único número.

Quando um decorador é a ferramenta errada?

Decoradores escondem o fluxo de controle. Uma função cujo corpo é uma única linha pode abrir uma transação no banco de dados e verificar uma permissão antes dessa linha executar, e nada no ponto de chamada avisa isso. Essa troca vale a pena para responsabilidades que atravessam muitas funções e devem se comportar de forma idêntica em todo lugar: logging, cronometragem, cache, autenticação. Vira um problema quando o comportamento pertence só a uma função. Escreva isso em linha, onde o próximo leitor vai ver.

Stack traces também ficam mais difíceis de ler. Três decoradores empilhados adicionam três frames extras entre quem chama e seu código. Se um decorador absorve exceções (como retry capturando ConnectionError) ou muda o valor retornado, diga isso na docstring da função decorada, que wraps mantém visível. Quem for ler o próximo traceback precisa dessa dica.

Quando extrair um decorador no seu próprio código?

O sinal é a repetição: as mesmas cinco linhas de preparação ou limpeza no início de uma terceira função. Extraia-as para um decorador antes de escrever a quarta cópia. Se você estiver testando a ideia, faça isso em um ambiente isolado, como descrito em Ambientes Virtuais Python: venv, pip e uv. Ao escrever os testes do decorador, comece contra uma função fictícia mínima. A camada descrita em Teste Unitário vs Teste de Integração vs End-to-End também se aplica aqui: um decorador é praticamente o código mais fácil de testar que existe no nível de unidade.

Pare quando o que você quer injetar for uma dependência, como uma conexão de banco de dados ou um cliente HTTP configurado. Isso não é um comportamento transversal. Passe essa dependência pelo construtor, como mostrado em O Que É Dependency Injection (Com Exemplos).

Artigos relacionados