Skip to content

    Chapter 35, Intermediate

    Closures and decorators

    A decorator is a function that takes a function and returns another, with extra behavior. To write a decorator without errors, it is necessary to understand closures and `functools.wraps`.

    Code for this chapter: intermediario/cap35_decoradores.py

    Closures

    A function defined inside another "remembers" the variables of the outer function, even after it has finished. This is called closure. The keyword nonlocal allows you to modify the variable of the outer function:

    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())
    
    Output
    1 2 3
    1
    

    Each call of criar_contador creates a total itself. It is a lightweight way to store state without writing a class.

    The simplest decorator, and the mistake almost everyone makes

    A decorator replaces the function with a wrapper. If the wrapper does not accept the arguments of the original function, the decorated function stops working:

    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)
    
    Output
    decorador_ruim.<locals>.wrapper() takes 0 positional arguments but 2 were given
    

    The correct form has three precautions: the wrapper accepts *args and **kwargs, returns the result of the original function and uses functools.wraps to preserve the name and documentation:

    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__)
    
    Output
    multiplicar(3, 4) -> 12
    multiplicar Multiplica dois números.
    

    Without the wraps, multiplicar.__name__ it would be "wrapper", which hinders debugging, logging, and documentation tools.

    Decorators with parameters

    To receive arguments (@repetir(3)), the decorator becomes a factory of decorators: a function that returns the real decorator. There are three levels of function:

    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á")
    
    Output
    olá
    olá
    olá
    

    A useful example: try again

    Network calls fail from time to time. A retry decorator separates this policy from the business code:

    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())
    
    Output
    tentativa 1 falhou
    tentativa 2 falhou
    dados
    

    In production, I would add an exponential backoff between attempts (backoff) and a timeout. The architecture chapter shows a version that injects the wait function to be able to test without actually waiting.

    Ready cache

    The standard library already includes useful decorators. The functools.cache caches the result of each call and transforms an exponential recursion into 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)
    
    Output
    23416728348467685
    True
    

    Cache only for pure functions

    The cache assumes that the same input always gives the same output. Using it on functions that read the clock, the database, or the network returns stale answers. And it keeps the arguments forever, so don't use it with unlimited inputs.

    Exercise 1

    Count the calls of a function

    Write the decorator contar_chamadas that keeps the attribute chamadas in the decorated function, without losing the original name.