¿Por qué Python solo hace una copia del elemento individual cuando itera una lista?

31

Me acabo de dar cuenta de que en Python, si uno escribe

for i in a:
    i += 1

Los elementos de la lista original en arealidad no se verán afectados en absoluto, ya que la variable iresulta ser solo una copia del elemento original en a.

Para modificar el elemento original,

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

sería necesario.

Realmente me sorprendió este comportamiento. Esto parece ser muy contradictorio, aparentemente diferente de otros idiomas y ha resultado en errores en mi código que tuve que depurar durante mucho tiempo hoy.

He leído Python Tutorial antes. Solo para estar seguro, revisé el libro nuevamente en este momento, y ni siquiera menciona este comportamiento en absoluto.

¿Cuál es el razonamiento detrás de este diseño? ¿Se espera que sea una práctica estándar en muchos idiomas para que el tutorial crea que los lectores deberían entenderlo de forma natural? ¿En qué otros idiomas está presente el mismo comportamiento en la iteración, al que debería prestar atención en el futuro?

xji
fuente
19
Eso solo es cierto si ies inmutable o si está llevando a cabo una operación no mutante. Con una lista anidada for i in a: a.append(1)tendría un comportamiento diferente; Python no copia las listas anidadas. Sin embargo, los enteros son inmutables y la suma devuelve un nuevo objeto, no cambia el anterior.
jonrsharpe
10
No es sorprendente en absoluto. No puedo pensar en un lenguaje que no sea exactamente el mismo para una variedad de tipos básicos como el entero. Por ejemplo, intente en javascript a=[1,2,3];a.forEach(i => i+=1);alert(a). Lo mismo en C #
edc65
77
¿Esperarías i = i + 1afectar a?
deltab
77
Tenga en cuenta que este comportamiento no es diferente en otros idiomas. C, Javascript, Java, etc. se comportan de esta manera.
slebetman
1
@jonrsharpe para listas "+ =" cambia la lista anterior, mientras que "+" crea una nueva
Vasily Alexeev

Respuestas:

68

Ya respondí una pregunta similar últimamente y es muy importante darse cuenta de que +=puede tener diferentes significados:

  • Si el tipo de datos implementa la suma in situ (es decir, tiene una __iadd__función que funciona correctamente ), los datos a los que se irefiere se actualizan (no importa si están en una lista o en otro lugar).

  • Si el tipo de datos no implementa un __iadd__ método, la i += xdeclaración es solo azúcar sintáctica i = i + x, por lo que se crea un nuevo valor y se asigna al nombre de la variable i.

  • Si el tipo de datos se implementa __iadd__pero hace algo extraño. Podría ser posible que esté actualizado ... o no, eso depende de lo que se implemente allí.

Los enteros, flotadores y cadenas de Python no se implementan, __iadd__por lo que no se actualizarán in situ. Sin embargo, otros tipos de datos como numpy.arrayo lists lo implementan y se comportarán como esperaba. Por lo tanto, no se trata de copiar o no copiar al iterar (normalmente no hace copias para listsy tuples, ¡pero eso también depende de la implementación de los contenedores __iter__y el __getitem__método!), Sino más bien del tipo de datos has almacenado en tu a.

MSeifert
fuente
2
Esta es la explicación correcta para el comportamiento descrito en la pregunta.
pabouk
19

Aclaración - terminología

Python no distingue entre los conceptos de referencia y puntero . Por lo general, solo usan el término referencia , pero si compara con lenguajes como C ++ que tienen esa distinción, está mucho más cerca de un puntero .

Dado que el autor de la pregunta claramente proviene de antecedentes de C ++, y dado que esa distinción, que se requiere para la explicación, no existe en Python, he elegido usar la terminología de C ++, que es:

  • Valor : datos reales que se encuentran en la memoria. void foo(int x);es una firma de una función que recibe un entero por valor .
  • Puntero : una dirección de memoria tratada como valor. Se puede diferir para acceder a la memoria a la que apunta. void foo(int* x);es una firma de una función que recibe un entero por puntero .
  • Referencia : Azúcar alrededor de punteros. Hay un puntero detrás de escena, pero solo puede acceder al valor diferido y no puede cambiar la dirección a la que apunta. void foo(int& x);es una firma de una función que recibe un número entero por referencia .

¿Qué quiere decir "diferente de otros idiomas"? La mayoría de los idiomas que conozco que admiten bucles para cada uno están copiando el elemento a menos que se indique específicamente lo contrario.

Específicamente para Python (aunque muchas de estas razones pueden aplicarse a otros lenguajes con conceptos arquitectónicos o filosóficos similares):

  1. Este comportamiento puede causar errores para las personas que no lo conocen, pero el comportamiento alternativo puede causar errores incluso para aquellos que lo conocen . Cuando asigna una variable ( i), por lo general, no se detiene y considera todas las otras variables que se cambiarían debido a ella ( a). Limitar el alcance en el que está trabajando es un factor importante para prevenir el código de espagueti y, por lo tanto, la iteración por copia suele ser el valor predeterminado incluso en los idiomas que admiten la iteración por referencia.

  2. Las variables de Python siempre son un puntero único, por lo que es barato iterar por copia, más barato que iterar por referencia, lo que requeriría un aplazamiento adicional cada vez que acceda al valor.

  3. Python no tiene el concepto de variables de referencia como, por ejemplo, C ++. Es decir, todas las variables en Python son en realidad referencias, pero en el sentido de que son punteros, no referencias constantes detrás de escena como los type& nameargumentos de C ++ . Dado que este concepto no existe en Python, implementar la iteración por referencia, ¡y mucho menos convertirlo en el predeterminado! - requerirá agregar más complejidad al bytecode.

  4. La fordeclaración de Python funciona no solo en matrices, sino en un concepto más general de generadores. Detrás de escena, Python llama itera sus matrices para obtener un objeto que, cuando lo llama next, devuelve el siguiente elemento o raisesa StopIteration. Hay varias formas de implementar generadores en Python, y hubiera sido mucho más difícil implementarlos para la iteración por referencia.

Idan Arye
fuente
Gracias por la respuesta. Parece que mi comprensión de los iteradores todavía no es lo suficientemente sólida. ¿No están los iteradores en la referencia de C ++ por defecto? Si desreferencia el iterador, ¿siempre puede cambiar inmediatamente el valor del elemento del contenedor original?
xji
44
Python hace iterate por referencia (así, por valor, pero el valor es una referencia). Intentar esto con una lista de objetos mutables demostrará rápidamente que no se produce ninguna copia.
jonrsharpe
Los iteradores en C ++ son en realidad objetos que pueden diferirse para acceder al valor en la matriz. Para modificar el elemento original, usa *it = ..., pero este tipo de sintaxis ya indica que está modificando algo en otro lugar, lo que hace que la razón # 1 sea un problema menor. Las razones # 2 y # 3 no se aplican también, porque en C ++ la copia es costosa y existe el concepto de variables de referencia. En cuanto a la razón # 4, la capacidad de devolver una referencia permite una implementación simple para todos los casos.
Idan Arye
1
@jonrsharpe Sí, se llama por referencia, pero en cualquier lenguaje que tenga una distinción entre punteros y referencias, este tipo de iteración será una iteración por puntero (y dado que los punteros son valores, iteración por valor). Agregaré una aclaración.
Idan Arye
20
Su primer párrafo sugiere que Python, como esos otros lenguajes, copia el elemento en un bucle for. No lo hace. No limita el alcance de los cambios que realice en ese elemento. El OP solo ve este comportamiento porque sus elementos son inmutables; sin siquiera mencionar esa distinción, su respuesta es, en el mejor de los casos, incompleta y, en el peor, engañosa.
jonrsharpe
11

Ninguna de las respuestas aquí le brinda un código con el que trabajar para ilustrar realmente por qué sucede esto en Python land. Y esto es divertido de ver en un enfoque más profundo, así que aquí va.

La razón principal por la que esto no funciona como espera es porque en Python, cuando escribe:

i += 1

no está haciendo lo que crees que está haciendo. Los enteros son inmutables. Esto se puede ver cuando observa qué es realmente el objeto en Python:

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

La función id representa un valor único y constante para un objeto en su vida útil. Conceptualmente, se asigna libremente a una dirección de memoria en C / C ++. Ejecutando el código anterior:

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

Esto significa que el primero aya no es el mismo que el segundo.a , porque sus identificaciones son diferentes. Efectivamente están en diferentes lugares en la memoria.

Con un objeto, sin embargo, las cosas funcionan de manera diferente. He sobrescrito el +=operador aquí:

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))

Ejecutar esto da como resultado el siguiente resultado:

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

Tenga en cuenta que el atributo id en este caso es en realidad el mismo para ambas iteraciones, aunque el valor del objeto es diferente (también podría encontrar el idvalor int que tiene el objeto, que estaría cambiando a medida que muta, porque los enteros son inmutables)

Compare esto con cuando ejecuta el mismo ejercicio con un objeto inmutable:

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))

Esto produce:

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

Algunas cosas aquí para notar. Primero, en el ciclo con el +=, ya no está agregando al objeto original. En este caso, dado que los ints se encuentran entre los tipos inmutables en Python , python usa una identificación diferente. También es interesante notar que Python usa el mismo subyacente idpara múltiples variables con el mismo valor inmutable:

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 tiene un puñado de tipos inmutables, que causan el comportamiento que ves. Para todos los tipos mutables, su expectativa es correcta.

Enderland
fuente
6

La respuesta de @Idan hace un buen trabajo al explicar por qué Python no trata la variable de bucle como un puntero de la manera que podría hacerlo en C, pero vale la pena explicar con mayor profundidad cómo se descomprimen los fragmentos de código, como en Python muchos bits aparentemente simples. de código en realidad serán llamadas a métodos integrados . Para tomar tu primer ejemplo

for i in a:
    i += 1

Hay dos cosas para desempaquetar: la for _ in _:sintaxis y la _ += _sintaxis. Para tomar el bucle for primero, al igual que otros lenguajes, Python tiene un for-eachbucle que es esencialmente azúcar de sintaxis para un patrón iterador. En Python, un iterador es un objeto que define un .__next__(self)método que devuelve el elemento actual en la secuencia, avanza al siguiente y generará un StopIterationcuando no haya más elementos en la secuencia. Un Iterable es un objeto que define un .__iter__(self)método que devuelve un iterador.

(Nota: an Iteratortambién es an Iterabley se devuelve de su .__iter__(self)método).

Python generalmente tendrá una función incorporada que delega al método personalizado de doble subrayado. Entonces tiene iter(o)cuál resuelve o.__iter__()y next(o)eso resuelve o.__next__(). Tenga en cuenta que estas funciones incorporadas a menudo intentarán una definición predeterminada razonable si el método al que delegarían no está definido. Por ejemplo, len(o)generalmente se resuelve o.__len__()pero, si ese método no está definido, lo intentará iter(o).__len__().

Un bucle se define esencialmente en términos de next(), iter()y estructuras de control más básico. En general el código

for i in %EXPR%:
    %LOOP%

será desempaquetado a algo como

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

Entonces en este caso

for i in a:
    i += 1

se desempaqueta a

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

La otra mitad de esto es i += 1. En general %ASSIGN% += %EXPR%se desempaqueta %ASSIGN% = %ASSIGN%.__iadd__(%EXPR%). Aquí __iadd__(self, other)hace una adición in situ y se devuelve.

(Nota: este es otro caso en el que Python elegirá una alternativa si el método principal no está definido. Si el objeto no se implementa __iadd__, volverá a funcionar __add__. De hecho, en este caso lo inthace, ya que no se implementa __iadd__, lo cual tiene sentido porque son inmutables y, por lo tanto, no se pueden modificar en su lugar).

Entonces su código aquí se ve así

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

donde podemos definir

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

Hay un poco más en su segundo código. Las dos cosas nuevas que necesitamos saber es que %ARG%[%KEY%] = %VALUE%se desempaqueta (%ARG%).__setitem__(%KEY%, %VALUE%)y %ARG%[%KEY%]se desempaqueta (%ARG%).__getitem__(%KEY%). Al reunir este conocimiento, nos a[ix] += 1descomprimimos a.__setitem__(ix, a.__getitem__(ix).__add__(1))(de nuevo: en __add__lugar de __iadd__porque __iadd__no se implementa por ints). Nuestro código final se ve así:

_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 responder realmente su pregunta de por qué el primero no modifica la lista, mientras que la segunda lo hace, en nuestro primer fragmento que estamos recibiendo idesde next(_a_iter)que los medios iserán una int. Como intno se puede modificar en su lugar, i += 1no hace nada a la lista. En nuestro segundo caso, nuevamente no estamos modificando el intpero estamos modificando la lista llamando __setitem__.

La razón de todo este elaborado ejercicio es porque creo que enseña la siguiente lección sobre Python:

  1. El precio de la legibilidad de Python es que está llamando a estos métodos mágicos de doble puntaje todo el tiempo.
  2. Por lo tanto, para tener la oportunidad de comprender realmente cualquier parte del código de Python, debe comprender estas traducciones.

Los métodos de subrayado doble son un obstáculo al comenzar, pero son esenciales para respaldar la reputación de "pseudocódigo ejecutable" de Python. Un programador decente de Python tendrá un conocimiento profundo de estos métodos y cómo se invocan y los definirá donde sea que tenga sentido hacerlo.

Editar : @deltab corrigió mi uso descuidado del término "colección".

Walpen
fuente
2
"los iteradores también son colecciones" no está del todo bien: también son iterables, pero las colecciones también tienen __len__y__contains__
deltab
2

+=funciona de manera diferente en función de si el valor actual es mutable o inmutable . Esta fue la razón principal por la que tardó mucho tiempo en implementarse en Python, ya que los desarrolladores de Python temían que fuera confuso.

Si ies un int, entonces no se puede cambiar ya que los ints son inmutables y, por lo tanto, si el valor de los icambios debe necesariamente apuntar a otro objeto:

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

Sin embargo, si el lado izquierdo es mutable , entonces + = puede cambiarlo; como si fuera una lista:

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

En su bucle for, se irefiere a cada elemento de aa su vez. Si esos son enteros, entonces se aplica el primer caso, y el resultado i += 1debe ser que se refiere a otro objeto entero. La lista, apor supuesto, todavía tiene los mismos elementos que siempre tuvo.

RemcoGerlich
fuente
No entiendo esta distinción entre objetos mutables e inmutables: si se i = 1establece ien un objeto entero inmutable, entonces i = []debería establecerse ien un objeto de lista inmutable. En otras palabras, ¿por qué los objetos enteros son inmutables y los objetos de lista mutables? No veo ninguna lógica detrás de esto.
Giorgio
@Giorgio: los objetos son de diferentes clases, listimplementa métodos que cambian su contenido, intno. [] es un objeto de lista mutable y i = []hagamos ireferencia a ese objeto.
RemcoGerlich
@Giorgio no existe una lista inmutable en Python. Las listas son mutables. Los enteros no lo son. Si quieres algo como una lista pero inmutable, considera una tupla. En cuanto a por qué, no está claro en qué nivel le gustaría que respondiera.
jonrsharpe
@RemcoGerlich: Entiendo que las diferentes clases se comportan de manera diferente, no entiendo por qué se implementaron de esta manera, es decir, no entiendo la lógica detrás de esta elección. Hubiera implementado el +=operador / método para comportarse de manera similar (principio de menor sorpresa) para ambos tipos: cambiar el objeto original o devolver una copia modificada para los enteros y las listas.
Giorgio
1
@Giorgio: es absolutamente cierto que +=es sorprendente en Python, pero se consideró que las otras opciones que mencionas también habrían sido sorprendentes, o al menos menos prácticas (no se puede cambiar el objeto original con el tipo de valor más común) usa + = con, ints. Y copiar una lista completa es mucho más costoso que mutarlo, Python no copia cosas como listas y diccionarios a menos que se le indique explícitamente). Fue un gran debate en aquel entonces.
RemcoGerlich
1

El bucle aquí es algo irrelevante. Al igual que los parámetros o argumentos de la función, configurar un bucle for como ese es esencialmente una asignación elegante.

Los enteros son inmutables. La única forma de modificarlos es creando un nuevo entero y asignándolo al mismo nombre que el original.

La semántica de Python para el mapa de asignación directamente en C (no sorprende dado los punteros PyObject * de CPython), con la única advertencia de que todo es un puntero, y no se le permite tener punteros dobles. Considere el siguiente código:

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

¿Lo que pasa? Se imprime 1. ¿Por qué? En realidad, es aproximadamente equivalente al siguiente 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);

En el código C, es obvio que el valor de no ase ve afectado por completo.

En cuanto a por qué las listas parecen funcionar, la respuesta es básicamente que estás asignando el mismo nombre. Las listas son mutables. La identidad del objeto nombrado a[0]cambiará, pero a[0]sigue siendo un nombre válido. Puede verificar esto con el siguiente código:

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

Pero, esto no es especial para las listas. Reemplace a[0]ese código con yy obtendrá exactamente el mismo resultado.

Kevin
fuente