Ninfeias, de Claude MonetVoltarClaude Monet, Ninfeias, 1906

Compreensão de listas vs. expressões geradoras: uma análise comparativa

Uma comparação de tempo e memória entre compreensões de listas e expressões geradoras em Python, com testes de até 100 milhões de elementos.

Wenderson Melo3 min de leitura

Uma das características do Python que acho mais interessantes são as compreensões de listas (listcomps). Bem entendidas, são uma ferramenta e tanto para construir sequências. Outro recurso da linguagem, às vezes deixado de lado, são as expressões geradoras (genexps), que também constroem sequências.

Neste artigo, quero comparar a performance dessas duas formas sintáticas. Antes, uma revisão rápida.

Compreensão de lista

É uma forma de construir sequências que, ao final, gera uma lista. Ela sempre parte de objetos iteráveis.

Por exemplo, em vez de escrever uma lista assim:

combs = []
for x in [1,2,3]:
    for y in [3,1,4]:
        if x != y:
            combs.append((x, y))

Pode-se escrever assim:

combs = [(x, y) for x in [1,2,3] for y in [3,1,4] if x != y]

O resultado é o mesmo: [(1, 3), (1, 4), (2, 3), (2, 1), (2, 4), (3, 1), (3, 4)].

Uma listcomp é elegante, mas só até certo ponto, porque o preço vai para a conta da legibilidade do código. Isso é assunto para outro momento.

Expressões geradoras

Elas também constroem sequências a partir de objetos iteráveis. Na sintaxe, a única diferença para as listcomps é o uso de parênteses no lugar de colchetes. O funcionamento, porém, é bem diferente: as genexps geram os elementos sob demanda (lazy evaluation), um de cada vez, e com isso economizam memória. Uma listcomp gera todos os elementos de uma só vez e os armazena na memória.

A lista da seção anterior pode ser construída com uma expressão geradora assim:

combs = list(((x, y) for x in [1,2,3] for y in [3,1,4] if x != y))

Notou a diferença? Nesse caso, precisei usar a função list() para ter o resultado como lista. Sem ela, eu teria apenas a representação do objeto, como mostra a imagem abaixo:

Terminal com a representação de um objeto generator
Representação do objeto combs

Para ter uma tupla, bastaria usar tuple(), e assim por diante.

Como veremos adiante, essas diferenças têm impacto significativo na manipulação de sequências muito grandes, e o uso correto pode trazer ganhos de otimização.

Para saber mais, recomendo a documentação, que é muito rica em detalhes (listcomps e genexps). Agora, vamos à análise comparativa.

Análise comparativa

O script abaixo compara o tempo de execução na construção de uma tupla com os quadrados de um range de 1 milhão:

import time

# Usando compreensão de lista
start_time = time.time()
a = tuple([x**2 for x in range(1000000)])
end_time = time.time()

print("Tempo usando compreensão de lista:", end_time - start_time)

# Usando expressão geradora
start_time = time.time()
b = tuple(x**2 for x in range(1000000))
end_time = time.time()

print("Tempo usando expressão geradora:", end_time - start_time)

print('------------------------------------------')

Depois de executar o script várias vezes, temos o seguinte resultado (tempo em segundos):

Seis execuções do script com os tempos de cada abordagem
Exemplo 1: teste com range de 1 milhão.

Em 5 dos 6 testes, a expressão geradora foi ligeiramente mais lenta. Ao refazer o teste com um range de 100 milhões, temos os seguintes resultados:

Seis execuções do script com range de 100 milhões
Exemplo 2: teste com range de 100 milhões.

A tendência se mantém: as genexps parecem mais lentas. Você deve se perguntar por quê, já que uma expressão geradora aloca bem menos memória que uma listcomp.

A explicação está na estrutura do script. No primeiro caso, a tupla é criada diretamente a partir de uma lista pronta. No segundo, os elementos são gerados e adicionados um de cada vez, o que exige mais tempo de processamento.

Agora, veja outro exemplo, em que somo todos os inteiros de um range de 100 milhões:

import time

# Usando compreensão de lista
start_time = time.time()
a = sum([x for x in range(100000000)])
end_time = time.time()
print("Tempo usando compreensão de lista:", end_time - start_time)

# Usando expressão geradora
start_time = time.time()
b = sum(x for x in range(100000000))

end_time = time.time()
print("Tempo usando expressão geradora:", end_time - start_time)

print('------------------------------------------')

Depois de executar o script 6 vezes:

Seis execuções do script de soma com range de 100 milhões
Exemplo 3: teste com range de 100 milhões.

Aqui, apesar da diferença mínima, a expressão geradora teve melhor desempenho em todos os testes. O próprio script explica o motivo.

Na listcomp, primeiro se cria a lista completa do range e depois a função sum() percorre essa lista para somar, o que gera um overhead de processamento. Na genexp, os valores gerados sob demanda vão direto para a sum(), sem precisar criar uma estrutura na memória para guardar todos os números do range(). Isso garante uma certa otimização.

Repare que as situações e os resultados esperados em cada script são diferentes: em um, o que me importa é ter a tupla; no outro, é ter o resultado final da soma.

Até aqui vimos o tempo. Em relação à memória, convido você a fazer o teste na sua máquina e confirmar que a expressão geradora é muito mais eficiente. Veja abaixo um exemplo tirado de uma discussão no Stack Overflow:

Comparação do uso de memória entre lista e gerador
Comparação do uso de memória.

Quando usar cada uma

Para cada tarefa, é importante escolher a ferramenta certa. Se você só precisa percorrer uma sequência e aplicar uma operação em que importa apenas o resultado final, considere uma expressão geradora e economize tempo. Se precisa armazenar a sequência, a compreensão de lista é uma ótima solução. Se o problema principal é memória, prefira a expressão geradora.

Cada ferramenta tem características que a tornam melhor em determinadas situações, e usá-las corretamente leva a um trabalho melhor.


Publicado originalmente no Medium em 2 de março de 2024.

Foto de Wenderson MeloWenderson Melo

Cientista da computação. Escrevo sobre o que gosto e sobre o que ando estudando.