Otimizar código em Python é uma habilidade essencial para escrever programas que executam mais rápido, consomem menos memória e escalam melhor. Embora Python seja uma linguagem de alto nível e nem sempre a mais rápida, existem várias técnicas que permitem melhorar significativamente o desempenho sem sacrificar a legibilidade. Nesta aula, vamos explorar quatro técnicas fundamentais: caching (com lru_cache), operações vetorizadas, geradores e o uso inteligente de built-ins. Cada uma delas ataca um aspecto diferente do desempenho, desde evitar recálculos desnecessários até reduzir o uso de memória.

Antes de mergulharmos nas técnicas, é importante entender que otimização deve ser guiada por medição. A regra de ouro é: não otimize prematuramente. Primeiro, escreva código claro e correto; depois, se necessário, identifique gargalos com um profiler e aplique as técnicas adequadas. As técnicas que veremos são ferramentas no seu cinto de utilidades, mas cada uma tem seu contexto ideal.

Caching (lru_cache)

O caching é uma técnica que armazena resultados de funções caras para reutilizá-los quando a mesma entrada aparecer novamente. Em Python, o decorador functools.lru_cache implementa um cache com política de descarte LRU (Least Recently Used), ou seja, quando o cache atinge seu tamanho máximo, os itens menos recentemente usados são removidos. Isso é extremamente útil para funções recursivas, cálculos matemáticos repetitivos ou chamadas a serviços externos.

Vamos ver um exemplo clássico: a sequência de Fibonacci. Sem caching, a versão recursiva tem complexidade exponencial. Com lru_cache, ela se torna quase tão rápida quanto a versão iterativa, mas mantém a clareza recursiva.

from functools import lru_cache

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

print(fib(50))  # 12586269025

O parâmetro maxsize define o número máximo de entradas no cache. Se você não sabe o tamanho ideal, use maxsize=None para um cache sem limite, mas cuidado com o consumo de memória. Em aplicações reais, um valor como 128 ou 256 é comum. Além disso, os argumentos da função devem ser hashable (imutáveis), pois são usados como chaves do dicionário interno. Para funções que recebem listas, você pode convertê-las em tuplas antes de chamar.

Outra aplicação importante é em funções que fazem consultas a bancos de dados ou APIs. Ao invés de repetir a mesma consulta, o cache evita chamadas desnecessárias. Porém, lembre-se de invalidar o cache quando os dados subjacentes mudarem. Você pode usar cache_clear() para limpar o cache manualmente.

import requests
from functools import lru_cache

@lru_cache(maxsize=32)
def get_user_data(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()

# Primeira chamada faz a requisição; segunda usa o cache
user1 = get_user_data(123)
user2 = get_user_data(123)  # vem do cache

Operações vetorizadas (visão geral)

Python é uma linguagem interpretada, e loops em Python podem ser lentos quando processam grandes volumes de dados. Uma alternativa é usar operações vetorizadas, que delegam o processamento a bibliotecas escritas em C (como NumPy) ou a funções internas que operam em arrays inteiros de uma vez. Isso reduz o overhead de interpretação e aproveita otimizações de baixo nível.

O exemplo mais comum é substituir loops por operações com arrays NumPy. Considere o cálculo da soma dos quadrados de uma lista de números. Em Python puro, um loop é necessário; com NumPy, uma única operação vetorizada resolve.

import numpy as np

def soma_quadrados_python(numeros):
    total = 0
    for x in numeros:
        total += x*x
    return total

def soma_quadrados_numpy(numeros):
    arr = np.array(numeros)
    return np.sum(arr**2)

numeros = list(range(1, 1000001))
print(soma_quadrados_python(numeros))
print(soma_quadrados_numpy(numeros))

Em termos de desempenho, a versão NumPy será muitas vezes mais rápida, especialmente para milhões de elementos. Além de NumPy, pandas oferece operações vetorizadas em DataFrames, e mesmo funções built-in como map() e filter() podem ser mais eficientes que loops explícitos em alguns casos, embora muitas vezes a compreensão de listas seja igualmente rápida e mais legível.

É importante notar que operações vetorizadas não são uma bala de prata. Elas podem consumir mais memória porque criam arrays intermediários. Para problemas que não se encaixam bem em arrays (como processamento de strings complexas), outras técnicas são mais adequadas. Além disso, a curva de aprendizado pode ser íngreme para quem não está familiarizado com a sintaxe vetorial.

Geradores

Geradores são funções que produzem uma sequência de valores sob demanda, em vez de calcular todos de uma vez. Isso é feito com a palavra-chave yield. Geradores são fundamentais para economizar memória quando você lida com sequências infinitas ou grandes conjuntos de dados que não cabem na memória.

Por exemplo, para gerar números primos, você pode criar um gerador que produz infinitamente, sem estourar a memória. Cada chamada a next() retorna o próximo primo.

def gerador_primos():
    primos = []
    n = 2
    while True:
        for p in primos:
            if n % p == 0:
                break
        else:
            primos.append(n)
            yield n
        n += 1

# Exemplo: pegar os 10 primeiros primos
primos = gerador_primos()
primeiros_10 = [next(primos) for _ in range(10)]
print(primeiros_10)  # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

Outra vantagem dos geradores é que eles permitem processamento pipeline: você pode encadear geradores para transformar dados sem criar listas intermediárias. Isso é comum em processamento de arquivos grandes, como ler linhas de um log e filtrar aquelas que contêm um erro.

def ler_linhas(nome_arquivo):
    with open(nome_arquivo) as f:
        for linha in f:
            yield linha.rstrip()

def filtrar_erros(linhas):
    for linha in linhas:
        if 'ERROR' in linha:
            yield linha

# Uso
linhas = ler_linhas('app.log')
erros = filtrar_erros(linhas)
for erro in erros:
    print(erro)

Note que ler_linhas é um gerador que lê o arquivo linha por linha, sem carregar o arquivo inteiro na memória. O filtro também é um gerador, e o processamento é preguiçoso: cada linha é lida, filtrada e impressa uma a uma.

Uma expressão geradora ((x for x in ...)) é uma forma concisa de criar geradores. Ela é útil quando você precisa passar um iterável para uma função como sum() ou max(), mas não quer criar uma lista completa.

soma_quadrados = sum(x*x for x in range(1000000))
print(soma_quadrados)

Built-ins

Python oferece uma vasta coleção de funções embutidas (built-ins) que são implementadas em C e, portanto, muito mais rápidas que implementações equivalentes em Python puro. Usá-las não só acelera a execução, mas também torna o código mais legível e idiomático.

Por exemplo, para encontrar o maior elemento de uma lista, use max() em vez de um loop manual. Para somar elementos, use sum(). Para ordenar, use sorted(). Essas funções são otimizadas e frequentemente mais rápidas que qualquer implementação caseira.

numeros = [3, 1, 4, 1, 5, 9, 2, 6]
maior = max(numeros)
menor = min(numeros)
soma = sum(numeros)
ordenados = sorted(numeros)
print(maior, menor, soma, ordenados)

Além das funções, os tipos de dados built-in como list, dict, set e tuple têm métodos altamente otimizados. Por exemplo, dict.get() é mais eficiente que verificar a chave com in e depois acessar. collections.Counter é perfeito para contar elementos. itertools fornece ferramentas para loops eficientes.

Outra dica é usar map() e filter() para aplicar funções a sequências. Embora compreensões de lista sejam mais legíveis, map pode ser mais rápido em alguns casos, mas a diferença geralmente é pequena. O importante é conhecer as ferramentas e escolher a mais adequada.

# Exemplo com map e filter
nomes = ['ana', 'bruno', 'carla']
comprimentos = list(map(len, nomes))
pares = list(filter(lambda x: x % 2 == 0, range(10)))
print(comprimentos)  # [3, 5, 5]
print(pares)  # [0, 2, 4, 6, 8]

Uma boa prática é explorar a biblioteca padrão: math, statistics, collections, itertools e functools fornecem funções que você não precisa reinventar. Por exemplo, math.factorial() é muito mais rápido que uma implementação recursiva simples.

Boas práticas e observações finais

Nenhuma técnica deve ser aplicada cegamente. Antes de otimizar, meça o desempenho com timeit ou um profiler. Muitas vezes, a otimização mais eficaz é escolher um algoritmo melhor, como usar um dicionário em vez de uma lista para buscas frequentes. O caching é útil quando a função é chamada repetidamente com os mesmos argumentos, mas cuidado com o consumo de memória. Operações vetorizadas são ideais para cálculos numéricos em grandes arrays, mas podem ser overkill para problemas pequenos. Geradores são perfeitos para streams de dados, mas não são adequados quando você precisa acessar elementos aleatoriamente várias vezes. E os built-ins são sempre uma boa escolha, pois são rápidos e testados.

Lembre-se: código otimizado não deve sacrificar a legibilidade. Prefira escrever código claro e, se necessário, otimize os pontos críticos. Documente suas escolhas de otimização para que outros desenvolvedores entendam o porquê.

Exercícios

  1. Exercício 1: Implemente uma função recursiva para calcular o fatorial de um número sem caching e depois com lru_cache. Compare o desempenho para valores grandes (ex.: 500) usando timeit.
  2. Exercício 2: Dada uma lista de números, calcule a soma dos quadrados usando um loop tradicional e usando NumPy. Meça o tempo para uma lista de 1 milhão de elementos. Explique a diferença.
  3. Exercício 3: Crie um gerador que produza os números da sequência de Collatz para um valor inicial dado. Use-o para imprimir a sequência para o número 27.
  4. Exercício 4: Escreva uma função que receba uma lista de palavras e retorne as três palavras mais frequentes. Use collections.Counter e o método most_common().
  5. Exercício 5: Otimize o seguinte código usando built-ins e compreensões: resultado = []; for i in range(10):; if i % 2 == 0:; resultado.append(i*2). Reescreva de forma mais concisa e eficiente.

✓ Resposta:
from functools import lru_cache
import timeit

def fatorial_sem_cache(n):
    if n <= 1:
        return 1
    return n * fatorial_sem_cache(n-1)

@lru_cache(maxsize=None)
def fatorial_com_cache(n):
    if n <= 1:
        return 1
    return n * fatorial_com_cache(n-1)

# Medir tempo para 500 (sem cache pode estourar recursão, mas para 100 é ok)
time_sem = timeit.timeit('fatorial_sem_cache(100)', globals=globals(), number=1000)
time_com = timeit.timeit('fatorial_com_cache(100)', globals=globals(), number=1000)
print(f"Sem cache: {time_sem:.5f} s")
print(f"Com cache: {time_com:.5f} s")
# A versão com cache é mais rápida em chamadas repetidas, mas para uma única chamada a diferença é pequena.

✓ Resposta:
import timeit
import numpy as np

def soma_quadrados_loop(numeros):
    total = 0
    for x in numeros:
        total += x*x
    return total

def soma_quadrados_numpy(numeros):
    arr = np.array(numeros)
    return np.sum(arr**2)

numeros = list(range(1, 1000001))
time_loop = timeit.timeit('soma_quadrados_loop(numeros)', globals=globals(), number=10)
time_numpy = timeit.timeit('soma_quadrados_numpy(numeros)', globals=globals(), number=10)
print(f"Loop: {time_loop:.3f} s")
print(f"NumPy: {time_numpy:.3f} s")
# A versão NumPy é muito mais rápida porque a operação é feita em C, sem overhead de interpretação.

✓ Resposta:
def collatz(n):
    while n != 1:
        yield n
        if n % 2 == 0:
            n //= 2
        else:
            n = 3*n + 1
    yield 1

for valor in collatz(27):
    print(valor, end=' ')
# Saída: 27 82 41 124 62 31 94 47 142 71 214 107 322 161 484 242 121 364 182 91 274 137 412 206 103 310 155 466 233 700 350 175 526 263 790 395 1186 593 1780 890 445 1336 668 334 167 502 251 754 377 1132 566 283 850 425 1276 638 319 958 479 1438 719 2158 1079 3238 1619 4858 2429 7288 3644 1822 911 2734 1367 4102 2051 6154 3077 9232 4616 2308 1154 577 1732 866 433 1300 650 325 976 488 244 122 61 184 92 46 23 70 35 106 53 160 80 40 20 10 5 16 8 4 2 1

✓ Resposta:
from collections import Counter

def palavras_frequentes(lista_palavras):
    contador = Counter(lista_palavras)
    return contador.most_common(3)

palavras = ['casa', 'carro', 'casa', 'moto', 'casa', 'carro', 'bike']
print(palavras_frequentes(palavras))
# [('casa', 3), ('carro', 2), ('moto', 1)]

✓ Resposta:
# Código original
resultado = []
for i in range(10):
    if i % 2 == 0:
        resultado.append(i*2)

# Otimizado com compreensão de lista
resultado = [i*2 for i in range(10) if i % 2 == 0]
print(resultado)  # [0, 4, 8, 12, 16]

Referências