Por que o Python faz apenas uma cópia do elemento individual ao iterar uma lista?


31

Acabei de perceber que em Python, se alguém escreve

for i in a:
    i += 1

Os elementos da lista original anão serão afetados, pois a variável iacaba sendo apenas uma cópia do elemento original a.

Para modificar o elemento original,

for index, i in enumerate(a):
    a[index] += 1

seria necessário.

Fiquei realmente surpreso com esse comportamento. Isso parece ser muito contra-intuitivo, aparentemente diferente de outros idiomas e resultou em erros no meu código que eu tive que depurar por um longo tempo hoje.

Eu já li o Python Tutorial antes. Só para ter certeza, eu verifiquei o livro novamente agora, e ele nem sequer menciona esse comportamento.

Qual é o raciocínio por trás desse design? Espera-se que seja uma prática padrão em muitos idiomas, para que o tutorial acredite que os leitores devem obtê-la naturalmente? Em quais outros idiomas está presente o mesmo comportamento na iteração, ao qual devo prestar atenção no futuro?


19
Isso é verdade apenas se ifor imutável ou se você estiver executando uma operação sem mutação. Com uma lista aninhada for i in a: a.append(1)teria um comportamento diferente; Python não copia as listas aninhadas. No entanto, números inteiros são imutáveis ​​e a adição retorna um novo objeto, ele não altera o antigo.
precisa saber é o seguinte

10
Não é nenhuma surpresa. Não consigo pensar em uma linguagem que não seja exatamente a mesma para uma matriz de tipos básicos, como número inteiro. Por exemplo, tente em javascript a=[1,2,3];a.forEach(i => i+=1);alert(a). Mesmo em C #
edc65

7
Você esperaria i = i + 1afetar a?
Deltab

7
Observe que esse comportamento não é diferente em outros idiomas. C, Javascript, Java etc. se comportam dessa maneira.
slebetman

11
@jonrsharpe para listas "+ =" altera a lista antiga, enquanto "+" cria uma nova
Vasily Alexeev

Respostas:


68

Eu já respondi a uma pergunta semelhante recentemente e é muito importante perceber que +=pode ter significados diferentes:

  • Se o tipo de dados implementa adição no local (ou seja, possui uma __iadd__função funcionando corretamente ), os dados a que ise refere são atualizados (não importa se estão em uma lista ou em outro lugar).

  • Se o tipo de dados não implementa um __iadd__método para o qual a i += xinstrução é apenas sintática i = i + x, um novo valor é criado e atribuído ao nome da variável i.

  • Se o tipo de dados é implementado, __iadd__mas faz algo estranho. É possível que seja atualizado ... ou não - isso depende do que é implementado lá.

Python, números inteiros, flutuadores e seqüências de caracteres não são implementados, __iadd__portanto eles não serão atualizados no local. No entanto, outros tipos de dados como numpy.arrayou os listimplementam e se comportam como você esperava. Portanto, não é uma questão de cópia ou não cópia ao iterar (normalmente não faz cópias por lists e tuples - mas isso também depende da implementação dos contêineres __iter__e __getitem__método!) - é mais uma questão de tipo de dados você armazenou no seu a.


2
Esta é a explicação correta para o comportamento descrito na pergunta.
pabouk

19

Esclarecimento - terminologia

Python não faz distinção entre os conceitos de referência e ponteiro . Eles geralmente usam apenas o termo referência , mas se você comparar com linguagens como C ++ que têm essa distinção - é muito mais próximo de um ponteiro .

Como o solicitante vem claramente do background do C ++, e como essa distinção - necessária para a explicação - não existe no Python, decidi usar a terminologia do C ++, que é:

  • Valor : dados reais que ficam na memória. void foo(int x);é uma assinatura de uma função que recebe um número inteiro por valor .
  • Ponteiro : Um endereço de memória tratado como valor. Pode ser adiado para acessar a memória para a qual aponta. void foo(int* x);é uma assinatura de uma função que recebe um número inteiro por ponteiro .
  • Referência : Açúcar em torno de ponteiros. Há um ponteiro nos bastidores, mas você só pode acessar o valor diferido e não pode alterar o endereço para o qual aponta. void foo(int& x);é uma assinatura de uma função que recebe um número inteiro por referência .

O que você quer dizer com "diferente de outros idiomas"? A maioria dos idiomas que eu conheço que suporta cada loops está copiando o elemento, a menos que seja especificamente instruído de outra forma.

Especificamente para Python (embora muitos desses motivos possam se aplicar a outras linguagens com conceitos arquitetônicos ou filosóficos semelhantes):

  1. Esse comportamento pode causar bugs para pessoas que não o conhecem, mas o comportamento alternativo pode causar bugs, mesmo para quem está ciente . Quando você atribui uma variável ( i), geralmente não para e considera todas as outras variáveis ​​que seriam alteradas por causa dela ( a). Limitar o escopo em que você está trabalhando é um fator importante na prevenção do código espaguete e, portanto, a iteração por cópia geralmente é o padrão, mesmo em idiomas que suportam a iteração por referência.

  2. As variáveis ​​Python são sempre um único ponteiro, por isso é barato iterar por cópia - mais barato que iterar por referência, o que exigiria um adiamento extra sempre que você acessar o valor.

  3. Python não tem o conceito de variáveis ​​de referência como - por exemplo - C ++. Ou seja, todas as variáveis ​​no Python são na verdade referências, mas no sentido de que são ponteiros - não uma referência constante nos bastidores, como type& nameargumentos em C ++ . Como esse conceito não existe no Python, implementando a iteração por referência - sem falar em torná-lo o padrão! - exigirá adicionar mais complexidade ao bytecode.

  4. A fordeclaração do Python funciona não apenas em matrizes, mas em um conceito mais geral de geradores. Nos bastidores, o Python chama itersuas matrizes para obter um objeto que - quando você o chama next- retorna o próximo elemento ou raisesa StopIteration. Existem várias maneiras de implementar geradores em Python, e teria sido muito mais difícil implementá-los para iteração por referência.


Obrigado pela resposta. Parece que meu entendimento sobre os iteradores ainda não é sólido o suficiente. Os iteradores na referência C ++ não são padrão? Se você derereferenciar o iterador, sempre poderá alterar imediatamente o valor do elemento do contêiner original?
Xji

4
Python faz iterado por referência (bem, por valor, mas o valor é uma referência). Tentar isso com uma lista de objetos mutáveis ​​demonstrará rapidamente que nenhuma cópia ocorre.
precisa saber é o seguinte

Iteradores em C ++ são realmente objetos que podem ser adiados para acessar o valor na matriz. Para modificar o elemento original, você usa *it = ...- mas esse tipo de sintaxe já indica que você está modificando algo em outro lugar - o que torna a razão nº 1 menos um problema. Os motivos 2 e 3 também não se aplicam, porque em C ++ a cópia é cara e existe o conceito de variáveis ​​de referência. Quanto ao motivo 4 - a capacidade de retornar uma referência permite uma implementação simples para todos os casos.
Idan Arye

11
@jonrsharpe Sim, é chamado por referência, mas em qualquer linguagem que tenha uma distinção entre ponteiros e referências, esse tipo de iteração será uma iteração por ponteiro (e como ponteiros são valores - iteração por valor). Vou adicionar um esclarecimento.
Idan Arye

20
Seu primeiro parágrafo sugere que o Python, como essas outras linguagens, copia o elemento em um loop for. Não faz. Não limita o escopo das alterações feitas nesse elemento. O OP só vê esse comportamento porque seus elementos são imutáveis; sem sequer mencionar essa distinção, sua resposta é, na melhor das hipóteses, incompleta e, na pior, enganosa.
precisa saber é o seguinte

11

Nenhuma das respostas aqui fornece código para você trabalhar para ilustrar realmente por que isso acontece no Python. E é divertido olhar para uma abordagem mais profunda, então aqui vai.

A principal razão pela qual isso não funciona como o esperado é porque no Python, quando você escreve:

i += 1

não está fazendo o que você pensa que está fazendo. Inteiros são imutáveis. Isso pode ser visto quando você olha para o que o objeto realmente é no Python:

a = 0
print('ID of the first integer:', id(a))
a += 1
print('ID of the first integer +=1:', id(a))

A função id representa um valor único e constante para um objeto em sua vida útil. Conceitualmente, ele mapeia livremente para um endereço de memória em C / C ++. Executando o código acima:

ID of the first integer: 140444342529056
ID of the first integer +=1: 140444342529088

Isso significa que o primeiro anão é mais o mesmo que o segundo a, porque seus IDs são diferentes. Efetivamente, eles estão em diferentes locais da memória.

Com um objeto, no entanto, as coisas funcionam de maneira diferente. Substituí o +=operador aqui:

class CustomInt:
  def __iadd__(self, other):
    # Override += 1 for this class
    self.value = self.value + other.value
    return self

  def __init__(self, v):
    self.value = v

ints = []
for i in range(5):
  int = CustomInt(i)
  print('ID={}, value={}'.format(id(int), i))
  ints.append(int)


for i in ints:
  i += CustomInt(i.value)

print("######")
for i in ints:
  print('ID={}, value={}'.format(id(i), i.value))

A execução disso resulta na seguinte saída:

ID=140444284275400, value=0
ID=140444284275120, value=1
ID=140444284275064, value=2
ID=140444284310752, value=3
ID=140444284310864, value=4
######
ID=140444284275400, value=0
ID=140444284275120, value=2
ID=140444284275064, value=4
ID=140444284310752, value=6
ID=140444284310864, value=8

Observe que o atributo id nesse caso é realmente o mesmo para as duas iterações, mesmo que o valor do objeto seja diferente (você também pode encontrar o idvalor int que o objeto mantém, o que mudaria conforme a mutação - porque números inteiros são imutáveis).

Compare isso com quando você executa o mesmo exercício com um objeto imutável:

ints_primitives = []
for i in range(5):
  int = i
  ints_primitives.append(int)
  print('ID={}, value={}'.format(id(int), i))

print("######")
for i in ints_primitives:
  i += 1
  print('ID={}, value={}'.format(id(int), i))


print("######")
for i in ints_primitives:
  print('ID={}, value={}'.format(id(i), i))

Isso gera:

ID=140023258889248, value=0
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4
######
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4
ID=140023258889408, value=5
######
ID=140023258889248, value=0
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4

Algumas coisas aqui para notar. Primeiro, no loop com o +=, você não está mais adicionando ao objeto original. Nesse caso, como ints estão entre os tipos imutáveis ​​do Python , o python usa um ID diferente. Também é interessante notar que o Python usa o mesmo subjacente idpara várias variáveis ​​com o mesmo valor imutável:

a = 1999
b = 1999
c = 1999

print('id a:', id(a))
print('id b:', id(b))
print('id c:', id(c))

id a: 139846953372048
id b: 139846953372048
id c: 139846953372048

tl; dr - Python tem vários tipos imutáveis, que causam o comportamento que você vê. Para todos os tipos mutáveis, sua expectativa está correta.


6

@ A resposta de Idan explica muito bem por que o Python não trata a variável de loop como um ponteiro da maneira que você pode em C, mas vale a pena explicar mais detalhadamente como os trechos de código são descompactados, como no Python muitos bits de aparência simples de código serão na verdade chamadas para métodos internos . Para dar o seu primeiro exemplo

for i in a:
    i += 1

Há duas coisas para descompactar: ​​a for _ in _:sintaxe e a _ += _sintaxe. Para pegar o loop for primeiro, como outras linguagens, o Python possui um for-eachloop que é essencialmente a sintaxe do açúcar para um padrão de iterador. No Python, um iterador é um objeto que define um .__next__(self)método que retorna o elemento atual na sequência, avança para o próximo e aumentará um StopIterationquando não houver mais itens na sequência. Um Iterable é um objeto que define um .__iter__(self)método que retorna um iterador.

(NB: an Iteratortambém é an Iterablee retorna a partir de seu .__iter__(self)método.)

O Python geralmente terá uma função embutida que delega para o método de sublinhado duplo personalizado. Portanto, tem o iter(o)que resolve o.__iter__()e o next(o)que resolve o.__next__(). Observe que essas funções embutidas geralmente tentam uma definição padrão razoável se o método que eles delegariam não estiver definido. Por exemplo, len(o)geralmente resolve, o.__len__()mas se esse método não for definido, ele tentará iter(o).__len__().

Um loop é essencialmente definido em termos de next(), iter()e mais estruturas básicas de controlo. Em geral, o código

for i in %EXPR%:
    %LOOP%

será descompactado para algo como

_a_iter = iter(%EXPR%)
while True:
    try:
        i = next(_a_iter)
    except StopIteration:
        break
    %LOOP%

Então, neste caso

for i in a:
    i += 1

é descompactado para

_a_iter = iter(a) # = a.__iter__()
while True:
    try: 
        i = next(_a_iter) # = _a_iter.__next__()
    except StopIteration:
        break
    i += 1

A outra metade disso é i += 1. Em geral, %ASSIGN% += %EXPR%é descompactado %ASSIGN% = %ASSIGN%.__iadd__(%EXPR%). Aqui __iadd__(self, other)coloca a adição e retorna a si mesma.

(NB: esse é outro caso em que o Python escolherá uma alternativa se o método principal não for definido. Se o objeto não for implementado, __iadd__ele voltará a funcionar __add__. Ele realmente faz isso neste caso como intnão é implementado __iadd__- o que faz sentido porque são imutáveis ​​e, portanto, não podem ser modificados no local.)

Portanto, seu código aqui parece

_a_iter = iter(a)
while True:
    try:
        i = next(_a_iter)
    except StopIteration:
        break
    i = iadd(i,1)

onde podemos definir

def iadd(o, v):
    try:
        return o.__iadd__(v)
    except AttributeError:
        return o.__add__(v)

Há um pouco mais no seu segundo pedaço de código. As duas coisas novas que precisamos saber são: %ARG%[%KEY%] = %VALUE%descompactar (%ARG%).__setitem__(%KEY%, %VALUE%)e %ARG%[%KEY%]descompactar (%ARG%).__getitem__(%KEY%). Juntando esse conhecimento, a[ix] += 1descompactamos a.__setitem__(ix, a.__getitem__(ix).__add__(1))(novamente: __add__e não __iadd__porque __iadd__não é implementado pelo ints). Nosso código final se parece com:

_a_iter = iter(enumerate(a))
while True:
    try:
        index, i = next(_a_iter)
    except StopIteration:
        break
    a.__setitem__(index, iadd(a.__getitem__(index), 1))

Para realmente responder sua pergunta a respeito de porque o primeiro não modificar a lista, enquanto o segundo faz, no nosso primeiro trecho que estamos recebendo ide next(_a_iter)que meios iserá um int. Como intnão pode ser modificado no local, i += 1não faz nada na lista. No nosso segundo caso, não estamos modificando novamente inta lista, mas modificando a lista chamando __setitem__.

A razão para todo este exercício elaborado é porque acho que ensina a seguinte lição sobre Python:

  1. O preço da legibilidade do Python é que ele chama esses métodos de pontuação dupla mágica o tempo todo.
  2. Portanto, para ter uma chance de realmente entender qualquer parte do código Python, você precisa entender essas traduções.

Os métodos de sublinhado duplo são um obstáculo ao iniciar, mas são essenciais para apoiar a reputação de "pseudocódigo executável" do Python. Um programador decente de Python terá um entendimento completo desses métodos e de como eles são chamados e os definirá sempre que fizer sentido.

Edit : @deltab corrigiu meu uso desleixado do termo "coleção".


2
"iteradores também são coleções" não está certo: eles também são iteráveis, mas as coleções também têm __len__e__contains__
deltab 30/01/17

2

+=funciona de maneira diferente com base no valor atual ser mutável ou imutável . Esse foi o principal motivo pelo qual demorou muito tempo para ser implementado no Python, pois os desenvolvedores do Python temiam que isso fosse confuso.

Se ifor um int, ele não poderá ser alterado, pois as entradas são imutáveis ​​e, portanto, se o valor das ialterações for necessário, ele deve necessariamente apontar para outro objeto:

>>> i=3
>>> id(i)
14336296
>>> i+=1
>>> id(i)
14336272   # Other object

No entanto, se o lado esquerdo é mutável , + = pode realmente alterá-lo; como se fosse uma lista:

>>> i=[]
>>> id(i)
140257231883944
>>> i+=[1]
>>> id(i)
140257231883944  # Still the same object!

No seu loop for, irefere-se a cada elemento de asua vez. Se esses são números inteiros, o primeiro caso se aplica e o resultado de i += 1deve ser que ele se refere a outro objeto inteiro. A lista, é aclaro, ainda tem os mesmos elementos que sempre teve.


Eu não entendo essa distinção entre objetos mutáveis ​​e imutáveis: se for i = 1definido icomo um objeto inteiro imutável, i = []deverá ser definido icomo um objeto de lista imutável. Em outras palavras, por que os objetos inteiros são imutáveis ​​e os objetos de lista são mutáveis? Não vejo lógica por trás disso.
Giorgio

@ Giorgio: os objetos são de diferentes classes, listimplementa métodos que alteram seu conteúdo, intnão. [] é um objeto de lista mutável e i = []vamos fazer ireferência a esse objeto.
RemcoGerlich 29/01

@ Giorgio não existe uma lista imutável em Python. As listas são mutáveis. Inteiros não são. Se você quiser algo parecido com uma lista, mas imutável, considere uma tupla. Quanto ao porquê, não está claro em que nível você gostaria que isso respondesse.
precisa saber é o seguinte

@RemcoGerlich: Entendo que classes diferentes se comportam de maneira diferente, não entendo por que foram implementadas dessa maneira, ou seja, não entendo a lógica por trás dessa escolha. Eu teria implementado o +=operador / método para se comportar de maneira semelhante (princípio da menor surpresa) para ambos os tipos: altere o objeto original ou retorne uma cópia modificada para números inteiros e listas.
Giorgio

11
@Giorgio: é absolutamente verdade que +=é surpreendente em Python, mas considerou-se que as outras opções mencionadas também seriam surpreendentes, ou pelo menos menos práticas (alterar o objeto original não pode ser feito com o tipo de valor mais comum você usa + = com, ints. E copiar uma lista inteira é muito mais cara do que modificá-la, o Python não copia coisas como listas e dicionários, a menos que seja explicitamente solicitado). Foi um grande debate na época.
RemcoGerlich 29/01

1

O loop aqui é meio irrelevante. Assim como os parâmetros ou argumentos das funções, configurar um loop for como esse é essencialmente apenas uma atribuição de aparência sofisticada.

Inteiros são imutáveis. A única maneira de modificá-los é criando um novo número inteiro e atribuindo-o ao mesmo nome que o original.

A semântica do Python para atribuição é mapeada diretamente para os C's (sem surpresa, considerando os ponteiros PyObject * do CPython), com as únicas ressalvas de que tudo é um ponteiro e você não tem permissão para ter ponteiros duplos. Considere o seguinte código:

a = 1
b = a
b += 1
print(a)

O que acontece? Imprime 1. Por quê? Na verdade, é aproximadamente equivalente ao seguinte código C:

i64* a = malloc(sizeof(i64));
*a = 1;
i64* b = a;
i64* tmp = malloc(sizeof(i64));
tmp = *b + 1;
b = tmp;
printf("%d\n", *a);

No código C, é óbvio que o valor de não aé afetado completamente.

Quanto ao motivo pelo qual as listas parecem funcionar, a resposta é basicamente o que você está atribuindo ao mesmo nome. As listas são mutáveis. A identidade do objeto nomeado a[0]será alterada, mas a[0]ainda é um nome válido. Você pode verificar isso com o seguinte código:

x = 1
a = [x]
print(a[0] is x)
a[0] += 1
print(a[0] is x)

Mas isso não é especial para listas. Substitua a[0]nesse código por ye você obterá exatamente o mesmo resultado.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.