Performance em Python é um tópico crucial para desenvolvedores que desejam escrever código eficiente e escalável. Embora Python seja conhecido por sua simplicidade e legibilidade, ele pode ser lento em certos cenários se não for usado com cuidado. Nesta aula, vamos explorar os principais gargalos de desempenho, como escolher as estruturas de dados certas, evitar armadilhas comuns e desenvolver um mindset que priorize a eficiência sem sacrificar a clareza.

Vamos abordar desde a identificação de problemas de performance até técnicas práticas para otimizar seu código. Você verá que muitas vezes pequenas mudanças na forma como você escreve Python podem gerar grandes ganhos de velocidade. Ao final, você terá um repertório de estratégias para aplicar em seus projetos.

Gargalos comuns

Os gargalos de performance em Python geralmente surgem de operações que são inerentemente lentas ou de padrões de código que forçam o interpretador a fazer trabalho desnecessário. Um dos maiores vilões é o uso excessivo de loops em Python puro, especialmente quando operações poderiam ser vetorizadas com bibliotecas como NumPy. Outro gargalo comum é a criação de muitas cópias de objetos grandes, que aumenta o uso de memória e o tempo de execução.

Além disso, operações de I/O (entrada/saída) costumam ser um gargalo, pois envolvem espera por recursos externos. O acesso a arquivos, rede e banco de dados deve ser feito de forma assíncrona ou com buffer para evitar bloqueios. Também devemos considerar o uso de funções e métodos que são lentos por natureza, como time.sleep() dentro de loops, que podem ser substituídos por abordagens mais eficientes.

Para ilustrar, vejamos um exemplo de código que sofre de gargalo devido a loops aninhados:

import time

def soma_quadrados(n):
    total = 0
    for i in range(n):
        for j in range(n):
            total += i * j
    return total

inicio = time.time()
soma_quadrados(1000)
fim = time.time()
print(f"Tempo: {fim - inicio:.4f} segundos")

Esse código executa um milhão de iterações, o que pode ser lento. Uma alternativa usando compreensão de lista ou NumPy seria muito mais rápida. Identificar esses gargalos é o primeiro passo para otimizar.

Estruturas de dados certas

A escolha da estrutura de dados adequada pode ter um impacto dramático na performance. Python oferece várias estruturas nativas, cada uma com suas características de complexidade. Por exemplo, list é ótima para acesso por índice, mas inserções e remoções no início são O(n). Já deque (da biblioteca collections) oferece operações O(1) para inserções e remoções nas extremidades.

Para buscas rápidas, set e dict são ideais, pois têm complexidade média O(1) para verificação de pertinência e acesso. Se você precisa de um dicionário que mantenha a ordem de inserção, use OrderedDict (ou o dict padrão a partir do Python 3.7). Para filas com prioridade, heapq é a escolha certa.

Vejamos um exemplo comparando listas e conjuntos para verificar se um elemento existe:

import time

dados = list(range(100000))
conjunto = set(dados)

def busca_em_lista():
    return 99999 in dados

def busca_em_conjunto():
    return 99999 in conjunto

inicio = time.time()
for _ in range(1000):
    busca_em_lista()
print(f"Lista: {time.time() - inicio:.4f}s")

inicio = time.time()
for _ in range(1000):
    busca_em_conjunto()
print(f"Conjunto: {time.time() - inicio:.4f}s")

A diferença é notável: a busca em conjunto é quase instantânea, enquanto a lista leva mais tempo. Portanto, sempre avalie qual estrutura melhor atende às suas operações mais frequentes.

Evitando armadilhas

Existem várias armadilhas que podem degradar a performance sem que percebamos. Uma delas é o uso de concatenação de strings em loops, que cria novas strings a cada iteração, resultando em O(n²). Em vez disso, use join() ou listas para acumular partes. Outra armadilha é o uso de try/except em loops para controle de fluxo, o que é mais lento que uma verificação simples.

Também devemos evitar chamadas de função desnecessárias dentro de loops, como len() repetidamente ou acesso a atributos globais. Variáveis locais são mais rápidas que globais, então atribua valores a variáveis locais quando possível. Além disso, cuidado com o uso de eval() e exec(), que são extremamente lentos e devem ser evitados.

Aqui está um exemplo de armadilha com concatenação de strings:

import time

def concat_ruim():
    s = ""
    for i in range(10000):
        s += str(i)
    return s

def concat_boa():
    partes = []
    for i in range(10000):
        partes.append(str(i))
    return "".join(partes)

inicio = time.time()
concat_ruim()
print(f"Concatenação: {time.time() - inicio:.4f}s")

inicio = time.time()
concat_boa()
print(f"Join: {time.time() - inicio:.4f}s")

O método join é muito mais eficiente. Essas pequenas mudanças podem fazer uma grande diferença em grandes volumes de dados.

Mindset

Desenvolver um mindset orientado a performance significa pensar em eficiência desde o início do projeto, mas sem cair em otimização prematura. O lema é: "Primeiro faça funcionar, depois faça rápido". É importante medir o desempenho com ferramentas como timeit ou profilers (cProfile) para identificar os pontos quentes antes de otimizar.

Além disso, é essencial conhecer os recursos da linguagem, como geradores, que economizam memória, e o uso de bibliotecas nativas (C) para operações pesadas. Por exemplo, em vez de escrever loops para manipular listas, use funções internas como map, filter e sum, que são implementadas em C.

Outro aspecto do mindset é a busca por soluções algorítmicas melhores. Às vezes, a otimização mais eficaz é mudar o algoritmo, como usar busca binária em vez de busca linear. Isso requer um bom entendimento de complexidade de tempo e espaço.

Finalmente, adote práticas como caching de resultados (usando functools.lru_cache) para evitar recálculos, e paralelismo com multiprocessing ou asyncio quando apropriado. Lembre-se de que a performance não é apenas sobre velocidade, mas também sobre uso eficiente de recursos.

Boas práticas e observações finais

Para fechar, aqui estão algumas boas práticas que você deve incorporar ao seu código:

  • Sempre meça antes de otimizar. Use timeit ou profilers para encontrar os gargalos reais.
  • Prefira funções e bibliotecas nativas a implementações manuais em Python puro.
  • Utilize estruturas de dados apropriadas para cada operação.
  • Evite loops desnecessários e use compreensões de lista quando for mais legível e rápido.
  • Considere o uso de bibliotecas como NumPy para cálculos numéricos intensivos.
  • Mantenha o código limpo e documentado; a otimização não deve sacrificar a clareza.

Lembre-se de que a otimização é um processo contínuo. À medida que seu projeto cresce, novos gargalos podem surgir. Portanto, mantenha uma mentalidade de análise e melhoria contínua.

Referências

Exercícios

  1. Escreva uma função que receba uma lista de números e retorne a soma dos quadrados dos números ímpares, usando apenas recursos nativos do Python (sem bibliotecas externas). Otimize a função para ser o mais rápida possível.
  2. ✓ Resposta: Uma solução eficiente é usar uma compreensão de lista com sum e if para filtrar ímpares, evitando um loop explícito. Exemplo:
    def soma_quadrados_impares(numeros):
        return sum(x*x for x in numeros if x % 2 != 0)
  3. Dada uma lista de 10.000 números aleatórios, compare o tempo de busca de um elemento usando uma lista e um conjunto. Escreva um script que meça os tempos e explique a diferença.
  4. ✓ Resposta: Use timeit para medir. A busca em conjunto é O(1) enquanto a lista é O(n), portanto o conjunto será muito mais rápido. Exemplo:
    import random, timeit
    
    dados = [random.randint(0, 100000) for _ in range(10000)]
    conjunto = set(dados)
    alvo = dados[5000]
    
    tempo_lista = timeit.timeit(lambda: alvo in dados, number=10000)
    tempo_conjunto = timeit.timeit(lambda: alvo in conjunto, number=10000)
    print(f"Lista: {tempo_lista:.4f}s, Conjunto: {tempo_conjunto:.4f}s")
  5. Refatore o código abaixo para melhorar a performance, explicando as mudanças:
    def processar(texto):
        resultado = ""
        for char in texto:
            if char.isalpha():
                resultado += char.upper()
        return resultado
  6. ✓ Resposta: A concatenação de strings em loop é ineficiente. Use join com uma lista de caracteres filtrados:
    def processar(texto):
        return "".join(char.upper() for char in texto if char.isalpha())
  7. Escreva uma função que calcule o fatorial de um número usando recursão, mas com memoização para evitar recálculos. Compare o desempenho com a versão sem memoização para números grandes.
  8. ✓ Resposta: Use functools.lru_cache para memoizar. Exemplo:
    from functools import lru_cache
    
    @lru_cache(maxsize=None)
    def fatorial(n):
        if n <= 1:
            return 1
        return n * fatorial(n-1)
  9. Dado um dicionário com milhares de entradas, escreva um código que encontre a chave com o maior valor, mas de forma eficiente. Compare com o uso de max(dict, key=dict.get).
  10. ✓ Resposta: A função max com key é a mais eficiente e legível. Exemplo:
    chave_maior = max(dicionario, key=dicionario.get)