¿Por qué Python solo hace una copia del elemento individual al iterar una lista?

29

Acabo de darme cuenta de que en Python, si uno escribe

for i in a:
    i += 1

Los elementos de la lista original a realmente no se verán afectados, ya que la variable i resulta 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.

Me sorprendió mucho este comportamiento. Esto parece ser muy contrario a la intuición, aparentemente diferente de otros idiomas y ha dado como resultado errores en mi código que tuve que depurar durante mucho tiempo hoy.

He leído Python Tutorial antes. Solo para estar seguro, volví a revisar el libro ahora mismo, 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 obtenerlo de forma natural? ¿En qué otros idiomas es el mismo comportamiento en la iteración presente, al que debería prestar atención en el futuro?

    
pregunta xji 29.01.2017 - 18:32

6 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 una adición en el lugar (es decir, tiene una función __iadd__ que funciona correctamente), entonces se actualizan los datos a los que se refiere i (no importa si está en una lista o en otro lugar) ).

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

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

Los enteros, flotantes y cadenas de Pythons no implementan __iadd__ , por lo que no se actualizarán en el lugar. Sin embargo, otros tipos de datos como numpy.array o list s lo implementan y se comportarán como usted esperaba. Por lo tanto, no es una cuestión de copiar o no copiar cuando se realiza la iteración (normalmente no hace copias para list sy tuple s, pero eso también depende de la implementación de los contenedores __iter__ y __getitem__ ¡Método!) - es más una cuestión del tipo de datos que ha almacenado en su a .

    
respondido por el MSeifert 29.01.2017 - 21:33
18

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 lo comparas con lenguajes como C ++ que sí tienen esa distinción, está mucho más cerca de un puntero .

Dado que el autor de la pregunta proviene claramente de los antecedentes de C ++, y desde 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. Puede ser diferido para acceder a la memoria a la que apunta. void foo(int* x); es una firma de una función que recibe un entero mediante el puntero .
  • Referencia : Azúcar alrededor de los 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 entero por referencia .

¿Qué quieres decir con "diferente de otros idiomas"? La mayoría de los idiomas que conozco y que admiten cada uno de los bucles están copiando el elemento a menos que se indique 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 saben, pero el comportamiento alternativo puede causar errores incluso para aquellos que lo saben . 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 spaghetti y, por lo tanto, la iteración por copia suele ser la opción predeterminada incluso en idiomas que admiten iteración por referencia.

  2. Las variables de Python siempre son un solo puntero, 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 entre bambalinas como los argumentos C ++ type& name . Dado que este concepto no existe en Python, se implementa la iteración por referencia, y mucho menos se convierte en el valor predeterminado. - requerirá agregar más complejidad al código de bytes.

  4. La declaración for de Python no solo funciona en matrices, sino en un concepto más general de generadores. Detrás de las escenas, Python invoca iter en tus arreglos para obtener un objeto que, cuando llamas a next , devuelve el siguiente elemento o raise s a StopIteration . Hay varias formas de implementar generadores en Python, y habría sido mucho más difícil implementarlos para la iteración por referencia.

respondido por el Idan Arye 29.01.2017 - 19:04
12

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

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

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 de identificación 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 primer a ya no es el mismo que el segundo a , porque sus ID son diferentes. De hecho, se encuentran en diferentes ubicaciones en la memoria.

Sin embargo, con un objeto, 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))

Ejecutando estos resultados en 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

Observe que el atributo id en este caso es en realidad el mismo para ambas iteraciones, aunque el valor del objeto es diferente (también puede encontrar el id del valor int del objeto Hold, que estaría cambiando a medida que se está mutando, porque los enteros son inmutables).

Compare esto cuando ejecute 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 bucle con el += , ya no está agregando al objeto original. En este caso, debido a que los ints están entre los tipos inmutables en Python , python usa una identificación diferente. También es interesante observar que Python usa el mismo id subyacente para 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.

    
respondido por el enderland 30.01.2017 - 00:35
6

La respuesta de @ Idan hace un buen trabajo explicando por qué Python no trata la variable de bucle como un puntero de la forma en que lo haría en C, pero vale la pena explicar con más detalle cómo se desempaquetan los fragmentos de código, como en Python es muy simple. -parte bits de código serán en realidad llamadas a métodos integrados . Para tomar tu primer ejemplo

for i in a:
    i += 1

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

(N.B .: un Iterator también es un Iterable y se devuelve a sí mismo de su método .__iter__(self) .)

Por lo general, Python tendrá una función incorporada que delega al método de subrayado doble personalizado. Así que tiene iter(o) que se resuelve en o.__iter__() y next(o) que se resuelve en% código%. 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, o.__next__() generalmente se resuelve en len(o) , pero si ese método no está definido, intentará con o.__len__() .

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

for i in %EXPR%:
    %LOOP%

se desempaquetará a algo como

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

Así que 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 iter() . En general, i += 1 se desempaqueta en %ASSIGN% += %EXPR% . Aquí %ASSIGN% = %ASSIGN%.__iadd__(%EXPR%) hace una adición in situ y se devuelve.

(NB: este es otro caso en el que Python elegirá una alternativa si no se define el método principal. Si el objeto no implementa __iadd__(self, other) , recurrirá a __iadd__ . En realidad, esto lo hace en este caso como __add__ no implementa int , lo que tiene sentido porque son inmutables y, por lo tanto, no se pueden modificar en su lugar.)

Así que su código aquí se ve como

_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 tu segundo bit de código. Las dos cosas nuevas que necesitamos saber es que __iadd__ se desempaqueta en %ARG%[%KEY%] = %VALUE% y (%ARG%).__setitem__(%KEY%, %VALUE%) se desempaqueta en %ARG%[%KEY%] . Al juntar este conocimiento, obtenemos (%ARG%).__getitem__(%KEY%) desempaquetado en a[ix] += 1 (nuevamente: a.__setitem__(ix, a.__getitem__(ix).__add__(1)) en lugar de __add__ porque __iadd__ no está implementado por ints). Nuestro código final se ve como:

_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 a tu pregunta de por qué el primero no modifica la lista, mientras que el segundo sí lo hace, en nuestro primer fragmento obtenemos __iadd__ de i , lo que significa que next(_a_iter) será un i . Dado que int 's no se puede modificar en su lugar, int no hace nada a la lista. En nuestro segundo caso, nuevamente no estamos modificando el i += 1 , pero estamos modificando la lista llamando a int .

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

  1. El precio de la legibilidad de Python es que llama a estos métodos mágicos de puntuación doble todo el tiempo.
  2. Por lo tanto, para tener la oportunidad de entender verdaderamente cualquier parte del código Python, debes entender estas traducciones que está haciendo.

Los métodos de subrayado doble son un obstáculo al comenzar, pero son esenciales para respaldar la reputación del "pseudocódigo ejecutable" de Python. Un programador decente de Python comprenderá en profundidad estos métodos y cómo se invocan, y los definirá donde sea más sensato hacerlo.

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

    
respondido por el walpen 29.01.2017 - 21:56
2

+= funciona de forma diferente según si el valor actual es mutable o immutable . Esta fue la razón principal por la que parece que se ha implementado en Python durante mucho tiempo, ya que los desarrolladores de Python temían que fuera confuso.

Si i es un int, entonces no puede cambiarse ya que los ints son inmutables, y por lo tanto si el valor de i cambia, entonces necesariamente debe apuntar a otro objeto:

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

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

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

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

    
respondido por el RemcoGerlich 29.01.2017 - 20:35
1

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

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

La semántica de Python para asignación se asigna directamente a las C (no sorprende dado los PyObject * punteros de CPython), con las únicas advertencias de que todo es un puntero, y no está permitido tener punteros dobles. Considere el siguiente código:

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

¿Qué pasa? 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 a no se 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 llamado a[0] cambiará, pero a[0] sigue siendo un nombre válido. Puedes 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] en ese código con y y obtendrá exactamente el mismo resultado.

    
respondido por el Kevin Mills 30.01.2017 - 18:57

Lea otras preguntas en las etiquetas