Por que list-comprehension é melhor que o for append em Python
- #Python
No meu último artigo, https://www.dio.me/articles/list-comprehension-o-que-e-e-porque-usar, eu trouxe uma visão geral de como e porque usar o list-comprehension ao invés de for append em Python. Porém não talvez não tenha ficado claro porque um é melhor que o outro. Sendo assim, nesse artigo eu vou trazer com mais detalhes as diferenças dos dois.
Primeiramente vou mostrar pra vocês a diferença de performance em cada caso.
def single_for_append(itter):
x = []
for i in range(itter):
if i%2 == 0:
x.append(i)
return x
def single_list_cmp(itter):
x = [i
for i in range(itter)
if i%2 == 0]
return x
Estas duas funções fazem praticamente a mesma coisa: montar e retornar uma lista com o números pares de 0 até itter-1, porém utilizando métodos diferentes. O tempo de execução para para cada caso, sendo itter = 2*10^8, foi:
single_list_cmp: 12.96s
single_for_append 16.70s
Mais de 5 segundos de diferença.
Porém você pode pensar: Mas as duas maneiras não são apenas maneiras diferentes de escrever as mesma coisa? Porque então existe essa diferença? Vamos analisar o processo que acontece no compilador do Python linha a linha em cada caso. Pra isso vou utilizar o módulo 'dis' do Python mesmo.
from dis import dis
.
.
.
if __name__ == '__main__':
print('='*60)
dis(single_for_append)
print('='*60)
dis(single_list_cmp)
print('='*60)
Resultado da execução:
============================================================
02 0 BUILD_LIST 0
2 STORE_FAST 1 (x)
03 4 SETUP_LOOP 38 (to 44)
6 LOAD_GLOBAL 0 (range)
8 LOAD_FAST 0 (itter)
10 CALL_FUNCTION 1
12 GET_ITER
14 FOR_ITER 26 (to 42)
16 STORE_FAST 2 (i)
04 18 LOAD_FAST 2 (i)
20 LOAD_CONST 1 (2)
22 BINARY_MODULO
24 LOAD_CONST 2 (0)
26 COMPARE_OP 2 (==)
28 POP_JUMP_IF_FALSE 14
05 30 LOAD_FAST 1 (x)
32 LOAD_ATTR 1 (append)
34 LOAD_FAST 2 (i)
36 CALL_FUNCTION 1
38 POP_TOP
40 JUMP_ABSOLUTE 14
>> 42 POP_BLOCK
06 >> 44 LOAD_FAST 1 (x)
46 RETURN_VALUE
============================================================
40 0 LOAD_CONST 1 (<code object <listcomp> at 0x0000024C79A996F0, file "for_append.py", line 40>)
2 LOAD_CONST 2 ('list_cmp.<locals>.<listcomp>')
4 MAKE_FUNCTION 0
41 6 LOAD_GLOBAL 0 (range)
8 LOAD_FAST 0 (itter)
10 CALL_FUNCTION 1
12 GET_ITER
14 CALL_FUNCTION 1
16 STORE_FAST 1 (x)
43 18 LOAD_FAST 1 (x)
20 RETURN_VALUE
============================================================
De cara percebe-se que o segundo método, utilizando list comprehension, executa muito menos instruções que o for append, porém vamos analisar...
No primeiro caso, a contrução e alocação da lista é feita logo na linha 2.
Na linha 3 ele prepara a instrução de iteração já carregando a função range e a variável itter e para cada iteração ele guarda o valor na variável 'i'.
Na linha 4 ele carrega o valor i, carrega o valor 2, realiza a operação MOD, carrega o valor 0, faz a comparação e se a comparação for falsa ele pula para depois do if.
Na linha 5 ele carrega o x, e carrega o método append (esse passo é importante para a próxima análise), depois ele carrega o i e chama a o método. Depois, na instrução 40 ele volta ao for.
Por fim, na linha 6, ele carrega e retorna a variável x.
Percebeu quantas coisas ele teve que ficar carregando? Carrega método, carrega variável, carrega constante, etc. Isso acaba se tornando muito custoso se você levar em consideração que essas ações são feitas repetidamente dentro do for.
Vamos analisar agora o segundo caso (dessa vez vamos por instruções).
Na instrução 0 ele carrega o 'listcomp' que é um objeto do Python, que vai auxiliar a criar a lista. Na 4 ele vai carregar esse objeto pra dentro do escopo da função. E na instrução 6, ele vai fazer a função que vai montar a lista em si.
As instruções 6, 8 e 10 são muito semelhantes as da forma anterior. Nas instruções 12 e 14 ele começa a iteração e chama a função imbutida do listcomp que vai realizar a condição e guardar o valor na lista que está sendo montada. Depois na instrução 16 ele guarda essa lista montada na variável x.
Por fim, nas instruções 18 e 20, ele carrega e retorna a variável x.
Ve-se que muitas operações não são realizadas ou são realizadas uma vez só, assim jogando a favor da performace.
Pensando neste aspecto, o que nós podemos fazer pra melhorar a performance do for append?
Bom, uma instrução que é realizada muitas vezes e é consideravelmente custosa é o LOAD_ATTR, que carrega o método append da lista. Se nós conseguíssemos transformar essa instrução em um LOAD_FAST, isso nos ganharia um bom tempo.
Pra fazer isso, podemos guardar essa função em uma variável.
Isso mesmo! Como no Python, praticamente tudo é considerado um objeto, podemos guardar referências para funções dentro de variáveis. O código fica assim:
def for_fix_append(itter):
x = []
a = x.append
for i in range(itter):
if i%2 == 0:
a(i)
Guardamos a função append da lista 'x' dentro da variável 'a'. Assim, quando fazemos a(i), estamos realizando x.append(i), porém em vez de carregar o método, carregamos logo a referência pro método, que é muito mais rápido e menos custoso. Vamos ver quanto tempo ganhamos com isso:
for_fix_append: 14.61s
Ainda não é tão rápido quanto o list-comprehension, porém já ganhamos 2 segundos em relação ao for append normal.
Então concluimos que usar o list-comprehension é sempre vantajoso, correto? Então...
Vamos pensar agora que você deseja construir duas listas, com os numeros pares e com os impares.
Com o for append fica assim:
# (dl = double list)
def for_append_dl(itter):
x = []
y = []
for i in range(itter):
if i%2 == 0:
x.append(i)
else:
y.append(i)
return x, y
Utilizamos o mesmo for para adicionar os valores nas duas listas. Porém logo percebe-se que não é possível fazer isso com o list-comprehension, forçando-nos a construir duas listas separadas, assim:
def list_cmp_dl(itter):
x = [i for i in range(itter) if i%2 == 0]
y = [i for i in range(itter) if i%2 == 1]
return x, y
Se você entendeu bem como funciona as intruções dentro de um loop, você vai conseguir prever o que vai acontecer agora. Vamos comparar o tempo nas duas funções agora, também com itter = 2*10^8:
list_cmp_dl: 26.39s
for_append_dl: 24.11s
Sem novidades, o list_cmp dobrou o tempo, pois executamos as mesmas intruções duas vezes distintas. Então para esses casos, é melhor utilizar o for append, correto? Pode-se dizer que sim, porém vamos aplicar a técnica do append fixo neste caso tambem:
def for_fix_append_dl(itter):
x, y = [], []
xa = x.append
ya = y.append
for i in range(itter):
if i%2 == 0:
xa(i)
else:
ya(i)
return x, y
for_fix_append_dl 20.14s
20 SEGUNDOS! Temos um novo vencedor!
Conclusão Final: Sempre que você tiver que montar uma lista, e puder utilizar o list-comprehension, use-o. Contudo, se você tiver que montar mais de uma lista utilizando a mesma lógica de iteração, considere utilizar o for append fixo.
Espero que este artigo tenha sido proveitoso pra vocês!
-- COMPARAÇÕES --
==============================
itter: 200_000_000
list_cmp 0:00:12.964117
for_fix_append 0:00:14.613968
for_append 0:00:16.704219
list_cmp_dl 0:00:26.394392
for_fix_append_dl 0:00:20.148160
for_append_dl 0:00:24.118850
==============================
:)