Pular para o conteúdo

    Capítulo 35, Intermediário

    Closures e decoradores

    Um decorador é uma função que recebe uma função e devolve outra, com comportamento extra. Para escrever um decorador sem erro é preciso entender closures e `functools.wraps`.

    Código deste capítulo: intermediario/cap35_decoradores.py

    Closures

    Uma função definida dentro de outra "lembra" das variáveis da função externa, mesmo depois de ela ter terminado. Isso se chama closure. A palavra nonlocal permite alterar a variável da função externa:

    intermediario/cap35_decoradores.pylinhas 10 a 24
    def criar_contador():
        total = 0
    
        def contar():
            nonlocal total
            total += 1
            return total
    
        return contar
    
    
    c = criar_contador()
    print(c(), c(), c())
    outro = criar_contador()
    print(outro())
    
    Saída
    1 2 3
    1
    

    Cada chamada de criar_contador cria um total próprio. É um jeito leve de guardar estado sem escrever uma classe.

    O decorador mais simples, e o erro que quase todo mundo comete

    Um decorador troca a função por um wrapper. Se o wrapper não aceitar os argumentos da função original, a função decorada deixa de funcionar:

    intermediario/cap35_decoradores.pylinhas 29 a 43
    def decorador_ruim(funcao):
        def wrapper():
            return funcao()
        return wrapper
    
    
    @decorador_ruim
    def soma(a, b):
        return a + b
    
    
    try:
        soma(2, 3)
    except TypeError as erro:
        print(erro)
    
    Saída
    decorador_ruim.<locals>.wrapper() takes 0 positional arguments but 2 were given
    

    A forma correta tem três cuidados: o wrapper aceita *args e **kwargs, devolve o resultado da função original e usa functools.wraps para preservar o nome e a documentação:

    intermediario/cap35_decoradores.pylinhas 45 a 64
    import functools
    
    
    def registrar(funcao):
        @functools.wraps(funcao)
        def wrapper(*args, **kwargs):
            resultado = funcao(*args, **kwargs)
            print(f"{funcao.__name__}{args} -> {resultado}")
            return resultado
        return wrapper
    
    
    @registrar
    def multiplicar(a, b):
        """Multiplica dois números."""
        return a * b
    
    
    multiplicar(3, 4)
    print(multiplicar.__name__, multiplicar.__doc__)
    
    Saída
    multiplicar(3, 4) -> 12
    multiplicar Multiplica dois números.
    

    Sem o wraps, multiplicar.__name__ seria "wrapper", o que atrapalha depuração, logs e ferramentas de documentação.

    Decoradores com parâmetros

    Para receber argumentos (@repetir(3)), o decorador vira uma fábrica de decoradores: uma função que devolve o decorador de verdade. São três níveis de função:

    intermediario/cap35_decoradores.pylinhas 69 a 86
    def repetir(vezes):
        def decorador(funcao):
            @functools.wraps(funcao)
            def wrapper(*args, **kwargs):
                resultado = None
                for _ in range(vezes):
                    resultado = funcao(*args, **kwargs)
                return resultado
            return wrapper
        return decorador
    
    
    @repetir(3)
    def avisar(texto):
        print(texto)
    
    
    avisar("olá")
    
    Saída
    olá
    olá
    olá
    

    Um exemplo útil: tentar de novo

    Chamadas de rede falham de vez em quando. Um decorador de tentativas separa essa política do código de negócio:

    intermediario/cap35_decoradores.pylinhas 91 a 118
    def tentar_novamente(tentativas):
        def decorador(funcao):
            @functools.wraps(funcao)
            def wrapper(*args, **kwargs):
                ultimo_erro = None
                for numero in range(1, tentativas + 1):
                    try:
                        return funcao(*args, **kwargs)
                    except ConnectionError as erro:
                        ultimo_erro = erro
                        print(f"tentativa {numero} falhou")
                raise ultimo_erro
            return wrapper
        return decorador
    
    
    chamadas = {"total": 0}
    
    
    @tentar_novamente(3)
    def buscar():
        chamadas["total"] += 1
        if chamadas["total"] < 3:
            raise ConnectionError("rede fora")
        return "dados"
    
    
    print(buscar())
    
    Saída
    tentativa 1 falhou
    tentativa 2 falhou
    dados
    

    Em produção, eu acrescentaria uma espera crescente entre as tentativas (backoff) e um limite de tempo. O capítulo de arquitetura mostra uma versão que injeta a função de espera para poder testar sem esperar de verdade.

    Cache pronto

    A biblioteca padrão já traz decoradores úteis. O functools.cache guarda o resultado de cada chamada e transforma uma recursão exponencial em linear:

    intermediario/cap35_decoradores.pylinhas 123 a 132
    from functools import cache
    
    
    @cache
    def fib(n):
        return n if n < 2 else fib(n - 1) + fib(n - 2)
    
    
    print(fib(80))
    print(fib.cache_info().hits > 0)
    
    Saída
    23416728348467685
    True
    

    Cache só para funções puras

    O cache assume que a mesma entrada sempre dá a mesma saída. Usá-lo em funções que leem o relógio, o banco ou a rede devolve respostas velhas. E ele guarda os argumentos para sempre, então não o use com entradas ilimitadas.

    Exercício 1

    Contar as chamadas de uma função

    Escreva o decorador contar_chamadas que mantenha o atributo chamadas na função decorada, sem perder o nome original.