Decorators são um dos recursos mais poderosos e elegantes do Python. Eles permitem modificar ou estender o comportamento de funções ou métodos sem alterar seu código-fonte. Nesta aula, vamos entender o que são decorators, como usar a sintaxe com @, como criar decorators que aceitam argumentos e explorar casos de uso comuns no dia a dia do desenvolvimento.

Um decorator é essencialmente uma função que recebe outra função como argumento e retorna uma nova função que geralmente adiciona alguma funcionalidade. É uma aplicação do padrão de projeto Decorator, mas de forma nativa na linguagem.

O que são

Em Python, funções são objetos de primeira classe: podem ser passadas como argumentos, retornadas de outras funções e atribuídas a variáveis. Um decorator tira proveito disso. Formalmente, um decorator é uma função que recebe uma função e retorna uma função (geralmente substituindo a original por uma versão modificada).

Por exemplo, suponha que queremos medir o tempo de execução de uma função. Podemos criar um decorator que envolve a função original com código de temporização:

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} levou {end - start:.4f} segundos")
        return result
    return wrapper

def minha_funcao():
    time.sleep(1)
    return "Feito!"

minha_funcao = timer(minha_funcao)
print(minha_funcao())

Aqui, timer é um decorator. A linha minha_funcao = timer(minha_funcao) substitui a função original pela função wrapper que adiciona a temporização. A partir de então, chamar minha_funcao() executa a versão decorada.

Sintaxe @

Python oferece um açúcar sintático para aplicar decorators: o símbolo @. Em vez de escrever minha_funcao = decorator(minha_funcao), basta colocar @decorator acima da definição da função. O código acima pode ser reescrito como:

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} levou {end - start:.4f} segundos")
        return result
    return wrapper

@timer
def minha_funcao():
    time.sleep(1)
    return "Feito!"

print(minha_funcao())

O uso de @timer é equivalente a minha_funcao = timer(minha_funcao). A sintaxe @ torna o código mais limpo e legível, especialmente quando múltiplos decorators são aplicados.

É importante notar que decorators podem ser empilhados. Por exemplo:

@decorator1
@decorator2
def funcao():
    pass

É equivalente a funcao = decorator1(decorator2(funcao)).

Decorators com argumentos

Às vezes, queremos que o próprio decorator aceite argumentos para configurar seu comportamento. Por exemplo, um decorator que repete a execução de uma função um número de vezes. Para isso, precisamos de uma função que retorne um decorator. Ou seja, criamos uma função que recebe os argumentos e retorna o decorator propriamente dito.

Exemplo: um decorator repeat que repete a função n vezes:

def repeat(n):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def diga_oi():
    print("Oi!")

diga_oi()

Aqui, repeat(3) retorna o decorator decorator, que então é aplicado a diga_oi. A sintaxe @repeat(3) é equivalente a diga_oi = repeat(3)(diga_oi).

Outra abordagem comum é usar functools.wraps para preservar metadados da função original (como __name__, __doc__). Sempre que criar um decorator, é boa prática decorar o wrapper com @functools.wraps(func).

import functools

def meu_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # código antes
        result = func(*args, **kwargs)
        # código depois
        return result
    return wrapper

Casos de uso

Decorators são amplamente utilizados em Python para diversos fins. Alguns casos comuns:

  • Logging: Registrar chamadas de função, argumentos e resultados.
  • Temporização: Medir tempo de execução (como vimos).
  • Cache: Armazenar resultados de funções caras (ex.: @functools.lru_cache).
  • Controle de acesso: Verificar permissões antes de executar uma função.
  • Validação de argumentos: Garantir que os argumentos atendam a certas condições.
  • Retry: Tentar executar uma função novamente em caso de falha.
  • Registro de métricas: Coletar dados de uso para monitoramento.

Exemplo de decorator para logging:

import functools

def log(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Chamando {func.__name__} com args={args} kwargs={kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} retornou {result}")
        return result
    return wrapper

@log
def soma(a, b):
    return a + b

soma(2, 3)

Saída:

Chamando soma com args=(2, 3) kwargs={}
soma retornou 5

Outro exemplo: decorator para controle de acesso simulado:

import functools

def requer_autenticacao(func):
    @functools.wraps(func)
    def wrapper(usuario, *args, **kwargs):
        if not usuario.get("autenticado"):
            raise PermissionError("Usuário não autenticado")
        return func(usuario, *args, **kwargs)
    return wrapper

@requer_autenticacao
def dados_sensiveis(usuario):
    return "Dados secretos"

# Teste
usuario_valido = {"nome": "João", "autenticado": True}
print(dados_sensiveis(usuario_valido))  # Funciona
usuario_invalido = {"nome": "Invasor", "autenticado": False}
# print(dados_sensiveis(usuario_invalido))  # Levanta PermissionError

Boas práticas

Ao criar decorators, lembre-se de:

  • Usar functools.wraps para preservar metadados da função original.
  • Usar *args e **kwargs para tornar o decorator genérico.
  • Documentar o decorator e seu propósito.
  • Evitar decorators muito complexos; se necessário, divida em funções menores.
  • Lembrar que decorators são aplicados em tempo de definição, não em tempo de chamada.

Referências

Exercícios

  1. Crie um decorator chamado maiusculas que converte o resultado de uma função em letras maiúsculas. Aplique-o a uma função que retorna uma string qualquer.

    ✓ Resposta:
    import functools
    
    def maiusculas(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return result.upper()
        return wrapper
    
    @maiusculas
    def saudacao():
        return "olá, mundo!"
    
    print(saudacao())  # "OLÁ, MUNDO!"
  2. Crie um decorator com argumento prefixo que adiciona um prefixo ao resultado de uma função. Exemplo: @prefixo("Resultado: ") aplicado a uma função que retorna um número.

    ✓ Resposta:
    import functools
    
    def prefixo(pre):
        def decorator(func):
            @functools.wraps(func)
            def wrapper(*args, **kwargs):
                result = func(*args, **kwargs)
                return f"{pre}{result}"
            return wrapper
        return decorator
    
    @prefixo("Resultado: ")
    def obter_numero():
        return 42
    
    print(obter_numero())  # "Resultado: 42"
  3. Implemente um decorator contador que conta quantas vezes uma função foi chamada. Dica: use um atributo no wrapper.

    ✓ Resposta:
    import functools
    
    def contador(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            wrapper.chamadas += 1
            print(f"Chamada número {wrapper.chamadas}")
            return func(*args, **kwargs)
        wrapper.chamadas = 0
        return wrapper
    
    @contador
    def diga_oi():
        print("Oi!")
    
    diga_oi()  # Chamada número 1
    diga_oi()  # Chamada número 2
  4. Crie um decorator que valide se o primeiro argumento de uma função é um inteiro positivo. Se não for, levante uma exceção ValueError.

    ✓ Resposta:
    import functools
    
    def valida_positivo(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            if args and (not isinstance(args[0], int) or args[0] <= 0):
                raise ValueError("O primeiro argumento deve ser um inteiro positivo")
            return func(*args, **kwargs)
        return wrapper
    
    @valida_positivo
    def raiz_quadrada(n):
        return n ** 0.5
    
    print(raiz_quadrada(9))  # 3.0
    # print(raiz_quadrada(-1))  # ValueError
  5. Use o decorator @lru_cache do módulo functools para memoizar uma função que calcula o n-ésimo número de Fibonacci recursivamente. Compare o desempenho com e sem o decorator chamando fib(35).

    ✓ Resposta:
    import functools
    import time
    
    @functools.lru_cache(maxsize=None)
    def fib(n):
        if n < 2:
            return n
        return fib(n-1) + fib(n-2)
    
    start = time.time()
    print(fib(35))  # 9227465
    print(f"Tempo com cache: {time.time() - start:.4f}s")
    
    # Sem cache (remova o decorator para testar)
    # start = time.time()
    # print(fib(35))
    # print(f"Tempo sem cache: {time.time() - start:.4f}s")