Performance em Python
LidoEsta aula aborda como melhorar a performance de programas Python, identificando gargalos comuns, escolhendo as estruturas de dados adequadas, evitando armadilhas de implementação e desenvolvendo um mindset orientado a performance, com exemplos práticos e exercícios.
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
timeitou 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
- Documentação oficial do módulo timeit
- Documentação oficial do profiler cProfile
- Estruturas de dados no Python
- Introdução ao NumPy
- Programação funcional em Python
- Python Performance Tips (Real Python)
Exercícios
- 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.
- 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.
- 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 - 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.
- 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).
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)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")join com uma lista de caracteres filtrados:def processar(texto):
return "".join(char.upper() for char in texto if char.isalpha())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)max com key é a mais eficiente e legível. Exemplo:chave_maior = max(dicionario, key=dicionario.get)