Decoradores com parâmetros e empilhamento

1. Revisão rápida: Decoradores simples

Decoradores são um dos recursos mais elegantes do Python. Um decorador é essencialmente uma função que recebe outra função como argumento e retorna uma nova função, geralmente estendendo seu comportamento. A sintaxe @decorador é açúcar sintático para funcao = decorador(funcao).

Vamos começar com um exemplo clássico: um decorador @timer que mede o tempo de execução de uma função:

import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        inicio = time.time()
        resultado = func(*args, **kwargs)
        fim = time.time()
        print(f"{func.__name__} executou em {fim - inicio:.4f}s")
        return resultado
    return wrapper

@timer
def calcular_soma(n):
    return sum(range(n))

calcular_soma(1000000)  # calcular_soma executou em 0.0472s

2. Decoradores com parâmetros: conceito e motivação

Decoradores simples são ótimos, mas frequentemente precisamos de configuração dinâmica. Por exemplo, um decorador @retry que deve aceitar o número de tentativas e o delay entre elas. Para isso, precisamos de uma estrutura de três níveis:

  1. Função de fábrica (recebe os parâmetros do decorador)
  2. Decorador intermediário (recebe a função original)
  3. Wrapper interno (recebe os argumentos da função original)
def retry(max_attempts=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for tentativa in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if tentativa == max_attempts - 1:
                        raise
                    print(f"Tentativa {tentativa + 1} falhou: {e}. Tentando novamente em {delay}s...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=2)
def conectar_servidor():
    # Simula uma conexão que falha nas primeiras tentativas
    raise ConnectionError("Servidor indisponível")

3. Implementação passo a passo de um decorador com parâmetros

Vamos construir um decorador @log que aceita um nível de log e formatação personalizada:

import logging
from datetime import datetime

def log(level="INFO", format_string="{timestamp} [{level}] {func_name}: {message}"):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            timestamp = datetime.now().isoformat()
            try:
                resultado = func(*args, **kwargs)
                mensagem = f"Executou com sucesso. Resultado: {resultado}"
                log_message = format_string.format(
                    timestamp=timestamp,
                    level=level,
                    func_name=func.__name__,
                    message=mensagem
                )
                print(log_message)
                return resultado
            except Exception as e:
                mensagem = f"Falhou com erro: {e}"
                log_message = format_string.format(
                    timestamp=timestamp,
                    level="ERROR",
                    func_name=func.__name__,
                    message=mensagem
                )
                print(log_message)
                raise
        return wrapper
    return decorator

@log(level="DEBUG", format_string="[{level}] {func_name}: {message}")
def dividir(a, b):
    return a / b

dividir(10, 2)  # [DEBUG] dividir: Executou com sucesso. Resultado: 5.0
dividir(10, 0)  # [ERROR] dividir: Falhou com erro: division by zero

4. Decoradores com parâmetros opcionais

Às vezes queremos que um decorador funcione tanto com parênteses vazios quanto sem parênteses. A técnica envolve detectar se o primeiro argumento é uma função:

def validate(schema=None):
    # Se schema é uma função, significa que foi usado sem parênteses: @validate
    if callable(schema):
        func = schema
        schema = None
        return validate_with_schema(func, schema)
    # Caso contrário, foi usado com parênteses: @validate(schema=...)
    return lambda func: validate_with_schema(func, schema)

def validate_with_schema(func, schema):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if schema:
            for arg in args:
                if not isinstance(arg, schema):
                    raise TypeError(f"Argumento {arg} não é do tipo {schema.__name__}")
        return func(*args, **kwargs)
    return wrapper

@validate  # Sem parênteses
def processar_nome(nome):
    return f"Olá, {nome}"

@validate(schema=str)  # Com parênteses
def processar_email(email):
    return f"Email: {email}"

print(processar_nome("João"))  # Funciona
processar_email(123)  # TypeError: Argumento 123 não é do tipo str

5. Empilhamento de decoradores: ordem e execução

Quando empilhamos decoradores, a ordem de aplicação é de baixo para cima, mas a ordem de execução é de cima para baixo. Vamos analisar:

def auth(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("[AUTH] Verificando autenticação...")
        return func(*args, **kwargs)
    return wrapper

def log(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[LOG] Executando {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        inicio = time.time()
        resultado = func(*args, **kwargs)
        print(f"[TIMER] {func.__name__} levou {time.time() - inicio:.2f}s")
        return resultado
    return wrapper

@auth
@log
@timer
def acessar_dados(usuario):
    print(f"[DADOS] Acessando dados para {usuario}")
    time.sleep(0.5)
    return {"usuario": usuario, "dados": "confidenciais"}

acessar_dados("admin")
# Saída:
# [AUTH] Verificando autenticação...
# [LOG] Executando acessar_dados
# [TIMER] acessar_dados levou 0.50s
# [DADOS] Acessando dados para admin

6. Combinação de decoradores com parâmetros e empilhamento

Vamos criar um exemplo mais complexo combinando decoradores com parâmetros:

def cache(ttl=60):
    cache_store = {}
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            key = (args, tuple(kwargs.items()))
            if key in cache_store:
                timestamp, resultado = cache_store[key]
                if time.time() - timestamp < ttl:
                    print(f"[CACHE] Retornando valor em cache para {func.__name__}")
                    return resultado
            resultado = func(*args, **kwargs)
            cache_store[key] = (time.time(), resultado)
            return resultado
        return wrapper
    return decorator

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for tentativa in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if tentativa == max_attempts - 1:
                        raise
                    print(f"[RETRY] Tentativa {tentativa + 1} falhou")
                    time.sleep(delay)
        return wrapper
    return decorator

@cache(ttl=30)
@retry(max_attempts=3, delay=2)
def buscar_api(endpoint):
    print(f"[API] Buscando {endpoint}...")
    # Simula uma API instável
    if time.time() % 3 < 1:
        raise ConnectionError("Timeout")
    return {"status": "ok", "dados": endpoint}

# Primeira chamada: tenta até 3 vezes, depois cacheia
resultado1 = buscar_api("/users")
# Segunda chamada: usa cache
resultado2 = buscar_api("/users")

7. Casos de uso avançados e armadilhas comuns

Decoradores em métodos de classe

def log_method(func):
    @functools.wraps(func)
    def wrapper(self, *args, **kwargs):
        print(f"[LOG] {self.__class__.__name__}.{func.__name__} chamado")
        return func(self, *args, **kwargs)
    return wrapper

class Servico:
    @log_method
    def processar(self, dado):
        return f"Processado: {dado}"

    @classmethod
    @log_method
    def metodo_classe(cls):
        return "Método de classe executado"

servico = Servico()
servico.processar("teste")
Servico.metodo_classe()

Debugging de decoradores empilhados

Use functools.wraps sempre para preservar metadados. Para depuração, você pode usar:

def debug_decorators(func):
    """Decorator para inspecionar a pilha de decoradores"""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[DEBUG] Executando: {func.__name__}")
        print(f"[DEBUG] Módulo: {func.__module__}")
        print(f"[DEBUG] Docstring: {func.__doc__}")
        return func(*args, **kwargs)
    return wrapper

8. Boas práticas e conclusão

Decoradores com parâmetros e empilhamento são ferramentas poderosas, mas exigem cuidado:

  1. Sempre use functools.wraps para preservar metadados da função original
  2. Mantenha decoradores puros - evite efeitos colaterais inesperados
  3. Documente claramente o comportamento esperado de cada decorador
  4. Evite empilhamento excessivo - mais de 3-4 decoradores podem comprometer a legibilidade
  5. Considere a ordem - lembre-se que a execução começa pelo decorador mais externo

Decoradores bem projetados podem transformar seu código, adicionando funcionalidades como logging, cache, autenticação e tratamento de erros de forma limpa e reutilizável. A chave está em entender profundamente o fluxo de execução e manter a simplicidade sempre que possível.

Referências