Paulo Henrique Rodrigues Pinheiro

Um blog sobre programação para programadores!


Listas, referências e cópias em Python

Um erro bem comum (pelo menos pra mim), lidando com listas em Python

Capa do livro Data Science do Zero

Uma ideia linda mas com resultados adversos

Eu, me sentindo o gênio do Python, resolvo usar uma forma de inicialização de matriz bem bonitinha:


➜  ipython
Python 3.6.2 (default, Aug  5 2017, 23:01:38) 
Type 'copyright', 'credits' or 'license' for more information
IPython 6.1.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: matrix = [['O'] * 3] * 4

In [2]: matrix
Out[2]: [['O', 'O', 'O'], ['O', 'O', 'O'], ['O', 'O', 'O'], ['O', 'O', 'O']]

In [3]: matrix[3][2] = 'A'

In [4]: matrix
Out[4]: [['O', 'O', 'A'], ['O', 'O', 'A'], ['O', 'O', 'A'], ['O', 'O', 'A']]

Perceberam? As quatro linhas da matriz tiveram seu terceiro elemento alterado. Eu descobri isso numa prova que estava fazendo, cansado já, implementando os últimos requisitos, num teste de unidade. E, claro, demorou para eu perceber essa obviedade.

Lendo o comando que cria a matriz, poderia-se traduzi-lo para português, mais ou menos assim:

Crie uma lista de três elementos "O", e armazene quatro cópias dela em outra lista.

Aí que está o problema. O Python, obediente, fez o que mandei. Vamos dar uma inspecionada no resultado:


In [5]: [id(x) for x in matrix]
Out[5]: [140365740409672, 140365740409672, 140365740409672, 140365740409672]

Está aí a prova. Criada uma lista, a referência a ela foi repetida.

Uma forma de resolver? Ah, eu já tinha lido sobre isso, mas claro, ficou perdido na memória. O texto Tuplas mutantes em Python, do Luciano Ramalho, explica bem isso.

Então, o nosso problema é criar uma lista 4 vezes, ou melhor dito, criar quatro listas, o que podemos traduzir em python como:


In [12]: matrix = [['O'] * 3 for x in range(4)]

In [13]: matrix
Out[13]: [['O', 'O', 'O'], ['O', 'O', 'O'], ['O', 'O', 'O'], ['O', 'O', 'O']]

In [14]: [id(x) for x in matrix]
Out[14]: [140365740472520, 140365751981000, 140365740750408, 140365740637064]

In [15]: matrix[3][2] = 'A'

In [16]: matrix
Out[16]: [['O', 'O', 'O'], ['O', 'O', 'O'], ['O', 'O', 'O'], ['O', 'O', 'A']]

Ou, otimizando um pouco:


In [17]: line = ['O'] * 3

In [18]: matrix = [list(line) for x in range(4)]

In [19]: [id(x) for x in matrix]
Out[19]: [140365752100936, 140365752653384, 140365752654664, 140365752655240]

Usei list aqui, porque ele inicializa um novo objeto a cada chamada, garantindo que não lidaremos com referências a um único.

E pra finalizar, será que isso é mesmo uma otimização?

Primeiro, organizemos melhor o código para as experiências seguintes:


In [50]: def pre_line(): return [list(line) for x in range(4)]

In [51]: def in_line(): return [['O'] * 3 for x in range(4)]

In [52]: pre_line()
Out[52]: [['O', 'O', 'O'], ['O', 'O', 'O'], ['O', 'O', 'O'], ['O', 'O', 'O']]

In [53]: in_line()
Out[53]: [['O', 'O', 'O'], ['O', 'O', 'O'], ['O', 'O', 'O'], ['O', 'O', 'O']]

Vamos ver com o módulo timeit:


In [29]: from timeit import timeit

In [30]: times = 1000000

In [54]: timeit(in_line, number=times)
Out[54]: 1.8307351850089617

In [55]: timeit(pre_line, number=times)
Out[55]: 2.4262633040198125

Eu pensei que fosse, mas pelo visto não é. Mas qual a razão? Vejamos.

Primeiro com a otimização:


In [68]: cProfile.run('pre_line()')
         5 function calls in 0.000 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.000    0.000 <ipython-input-66-4ae8eb4f3808>:1(<listcomp>)
        1    0.000    0.000    0.000    0.000 <ipython-input-66-4ae8eb4f3808>:1(pre_line)
        1    0.000    0.000    0.000    0.000 <string>:1(<module>)
        1    0.000    0.000    0.000    0.000 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

Agora sem a otimização:


In [69]: cProfile.run('in_line()')
         5 function calls in 0.000 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.000    0.000 <ipython-input-67-b4163c10d5c0>:1(<listcomp>)
        1    0.000    0.000    0.000    0.000 <ipython-input-67-b4163c10d5c0>:1(in_line)
        1    0.000    0.000    0.000    0.000 <string>:1(<module>)
        1    0.000    0.000    0.000    0.000 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

Putz, nenhuma informação...

Então, vamos ver se o problema é mesmo o uso do list:


In [70]: def run_line(): return list(line)

In [71]: def run_mult(): return ['O'] * 3

In [72]: timeit(run_line, number=times)
Out[72]: 0.4813398620171938

In [73]: timeit(run_mult, number=times)
Out[73]: 0.3437652220018208

De fato, o uso do list é a forma mais lenta. Alguém aí ajuda a encontrar uma resposta?