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.
| |
@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:
| |
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:
| |
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.
| |
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:
| |
@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:
@staticmethode@classmethodmudam o que um método recebe como primeiro argumento: nada extra, ou a classe em vez da instância.@propertytransforma uma chamada de método em acesso a atributo,obj.valueem vez deobj.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. Usecachequando o conjunto de entradas possíveis é pequeno e limitado,lru_cachequando não é.
| |
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:
| |
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).