O módulo functools é uma das joias da biblioteca padrão do Python. Ele fornece funções e decoradores que operam em outras funções, permitindo que você escreva código mais limpo, eficiente e expressivo. Nesta aula, vamos explorar quatro ferramentas essenciais: lru_cache, partial, reduce e wraps. Cada uma resolve problemas comuns de programação, desde otimização de desempenho até a criação de funções especializadas.

Dominar o módulo functools é fundamental para qualquer programador Python que deseja escrever código idiomático e aproveitar ao máximo os recursos da linguagem. Vamos mergulhar em cada ferramenta, entender seu funcionamento interno e aplicá-la em exemplos práticos.

lru_cache

O decorador @lru_cache (Least Recently Used cache) implementa memoização, uma técnica que armazena os resultados de chamadas de função para evitar recálculos desnecessários. Quando uma função decorada é chamada com os mesmos argumentos, o resultado é retornado do cache em vez de executar a função novamente. Isso é particularmente útil para funções recursivas ou computacionalmente intensivas.

Por padrão, o cache não tem limite de tamanho, mas você pode especificar o parâmetro maxsize para limitar o número de entradas. Quando o cache atinge o limite, as entradas menos usadas são descartadas. O decorador também fornece métodos como cache_info() para estatísticas e cache_clear() para limpar o cache.

from functools import lru_cache

@lru_cache(maxsize=128)
def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

print(fib(10))  # 55
print(fib.cache_info())  # CacheInfo(hits=9, misses=11, maxsize=128, currsize=11)

No exemplo acima, a função fib calcula o n-ésimo número de Fibonacci. Sem o cache, a complexidade seria exponencial; com ele, cada chamada é computada apenas uma vez. Isso demonstra como a memoização pode transformar algoritmos ineficientes em soluções viáveis.

partial

A função partial permite fixar um ou mais argumentos de uma função, criando uma nova função com esses valores predefinidos. Isso é útil quando você tem uma função genérica e deseja criar versões especializadas sem reescrever o código. A partial retorna um objeto chamável que se comporta como a função original, mas com os argumentos fixados.

É importante notar que partial funciona com argumentos posicionais e nomeados. Os argumentos fixados são passados primeiro, e os novos argumentos são adicionados no final. Isso pode causar confusão se você não estiver ciente da ordem, mas na prática é muito conveniente.

from functools import partial

def power(base, exp):
    return base ** exp

square = partial(power, exp=2)
cube = partial(power, exp=3)

print(square(5))  # 25
print(cube(5))    # 125

No exemplo, criamos funções square e cube a partir de power, fixando o expoente. Isso evita duplicação de código e torna o código mais legível. partial é amplamente usado em programação funcional e em bibliotecas como multiprocessing para passar funções com argumentos fixos.

reduce

A função reduce aplica uma função de dois argumentos cumulativamente aos itens de um iterável, reduzindo-o a um único valor. Ela é parte do paradigma de programação funcional e é particularmente útil para operações de agregação, como somar, multiplicar ou encontrar o máximo. No Python 3, reduce foi movida para o módulo functools.

A assinatura é reduce(function, iterable[, initializer]). Se um inicializador for fornecido, ele é usado como o primeiro valor na redução; caso contrário, o primeiro item do iterável é usado como valor inicial. Se o iterável estiver vazio e não houver inicializador, um TypeError é levantado.

from functools import reduce

# Soma de uma lista
numbers = [1, 2, 3, 4, 5]
total = reduce(lambda x, y: x + y, numbers)
print(total)  # 15

# Produto com inicializador
product = reduce(lambda x, y: x * y, numbers, 1)
print(product)  # 120

Embora muitas operações de redução possam ser feitas com funções embutidas como sum() ou max(), reduce oferece flexibilidade para operações personalizadas. No entanto, em muitos casos, um loop explícito ou uma compreensão podem ser mais legíveis. Use reduce quando a operação for naturalmente associativa e o código ficar mais claro.

wraps

O decorador @wraps é usado dentro de decoradores para preservar os metadados da função original, como nome, docstring, e anotações. Quando você cria um decorador sem usar @wraps, a função decorada perde essas informações, o que pode causar problemas com introspecção e documentação.

O @wraps é aplicado ao wrapper interno e copia os atributos relevantes da função original para o wrapper. Ele também atualiza o __wrapped__, permitindo que ferramentas como inspect acessem a função original. Isso é considerado uma boa prática ao criar decoradores.

from functools import wraps

def meu_decorador(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Antes da chamada")
        resultado = func(*args, **kwargs)
        print("Depois da chamada")
        return resultado
    return wrapper

@meu_decorador
def saudacao(nome):
    """Retorna uma saudação."""
    return f"Olá, {nome}!"

print(saudacao.__name__)  # saudacao
print(saudacao.__doc__)   # Retorna uma saudação.
print(saudacao.__wrapped__)  # <function saudacao at 0x...>

Sem @wraps, saudacao.__name__ seria wrapper e a docstring seria perdida. Usar @wraps garante que a função decorada mantenha sua identidade, o que é crucial para depuração e para bibliotecas que dependem de introspecção.

Boas práticas e observações finais

Ao usar o módulo functools, lembre-se de que lru_cache não é adequado para funções com efeitos colaterais ou que dependem de estado externo, pois os resultados são armazenados em cache. Sempre defina maxsize para evitar uso excessivo de memória em aplicações de longa duração.

Use partial para criar APIs mais limpas e evitar repetição de argumentos. Para reduce, prefira funções embutidas quando existirem, mas não hesite em usá-la para operações personalizadas. E nunca se esqueça de usar @wraps em seus decoradores — é uma marca de profissionalismo.

Referências

Exercícios

  1. Implemente uma função fatorial(n) que calcule o fatorial de um número inteiro não negativo usando recursão. Aplique @lru_cache e calcule fatorial(10). Em seguida, chame fatorial.cache_info() e imprima o resultado.

    ✓ Resposta:
    from functools import lru_cache
    
    @lru_cache(maxsize=None)
    def fatorial(n):
        if n <= 1:
            return 1
        return n * fatorial(n-1)
    
    print(fatorial(10))
    print(fatorial.cache_info())
    
  2. Crie uma função multiplicar(a, b) que retorna a * b. Use partial para criar uma função dobro que multiplica por 2 e uma função triplo que multiplica por 3. Teste-as com o argumento 7.

    ✓ Resposta:
    from functools import partial
    
    def multiplicar(a, b):
        return a * b
    
    dobro = partial(multiplicar, 2)
    triplo = partial(multiplicar, 3)
    
    print(dobro(7))  # 14
    print(triplo(7)) # 21
    
  3. Dada a lista valores = [5, 8, 2, 9, 1], use reduce para encontrar o maior valor na lista. Imprima o resultado.

    ✓ Resposta:
    from functools import reduce
    
    valores = [5, 8, 2, 9, 1]
    maior = reduce(lambda x, y: x if x > y else y, valores)
    print(maior)  # 9
    
  4. Crie um decorador log_chamadas que imprime "Chamando <nome_da_funcao>" antes de executar a função e "Função <nome> terminada" depois. Use @wraps para preservar os metadados. Teste com uma função simples e verifique que __name__ é preservado.

    ✓ Resposta:
    from functools import wraps
    
    def log_chamadas(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(f"Chamando {func.__name__}")
            resultado = func(*args, **kwargs)
            print(f"Função {func.__name__} terminada")
            return resultado
        return wrapper
    
    @log_chamadas
    def soma(a, b):
        """Soma dois números."""
        return a + b
    
    print(soma(3, 4))
    print(soma.__name__)  # soma
    
  5. Escreva uma função contagem_regressiva(n) que imprime números de n até 1 usando recursão. Aplique @lru_cache e chame com n=5. Explique por que o cache não é apropriado neste caso.

    ✓ Resposta:
    from functools import lru_cache
    
    @lru_cache(maxsize=None)
    def contagem_regressiva(n):
        if n < 1:
            return
        print(n)
        contagem_regressiva(n-1)
    
    contagem_regressiva(5)
    

    O cache não é apropriado porque a função tem efeitos colaterais (imprime na tela) e não retorna um valor que dependa apenas dos argumentos. Se a função fosse chamada novamente com o mesmo n, o cache retornaria o valor None sem executar a impressão, o que não é o comportamento desejado.