¿Por qué obtengo tantas iteraciones cuando agrego y elimino de un conjunto al iterar sobre él?

62

Intentando entender el ciclo for Python, pensé que esto daría el resultado {1}para una iteración, o simplemente quedaría atascado en un ciclo infinito, dependiendo de si lo hace como en C u otros lenguajes. Pero en realidad no lo hizo.

>>> s = {0}
>>> for i in s:
...     s.add(i + 1)
...     s.remove(i)
...
>>> print(s)
{16}

¿Por qué hace 16 iteraciones? De donde viene el resultado{16} viene ?

Esto estaba usando Python 3.8.2. En pypy hace el resultado esperado {1}.

desbordamiento de novatos
fuente
17
Dependiendo de los elementos que agregue, cada llamada a s.add(i+1)(y posiblemente la llamada a s.remove(i)) puede alterar el orden de iteración del conjunto, afectando lo que verá el iterador de conjunto que creó el bucle for. No mutes un objeto mientras tengas un iterador activo.
Chepner
66
También me di cuenta de eso t = {16}y luego t.add(15)arrojó que t es el conjunto {16, 15}. Creo que el problema está en alguna parte.
19
Es un detalle de implementación: 16 tiene un hash inferior a 15 (eso es lo que @Anon notó), por lo que agregar 16 al tipo de conjunto lo agregó a la parte "ya vista" del iterador y, por lo tanto, el iterador se agotó.
Błotosmętek
1
Si lees trough de docs, hay una nota que dice que los iteradores mutantes durante el ciclo podrían crear algunos errores. Ver: docs.python.org/3.7/reference/…
Marcello Fabrizio
3
@ Błotosmętek: En CPython 3.8.2, hash (16) == 16 y hash (15) == 15. El comportamiento no proviene de que el hash sea más bajo; los elementos no se almacenan directamente en orden hash en un conjunto.
user2357112 es compatible con Monica el

Respuestas:

87

Python no promete cuándo (si alguna vez) finalizará este ciclo. La modificación de un conjunto durante la iteración puede conducir a elementos omitidos, elementos repetidos y otras rarezas. Nunca confíes en tal comportamiento.

Todo lo que voy a decir son detalles de implementación, sujetos a cambios sin previo aviso. Si escribe un programa que se basa en alguno de ellos, su programa puede interrumpir cualquier combinación de implementación y versión de Python que no sea CPython 3.8.2.

La breve explicación de por qué el ciclo termina en 16 es que 16 es el primer elemento que se coloca en un índice de tabla hash más bajo que el elemento anterior. La explicación completa está abajo.


La tabla hash interna de un conjunto de Python siempre tiene una potencia de 2 tamaños. Para una tabla de tamaño 2 ^ n, si no se producen colisiones, los elementos se almacenan en la posición en la tabla hash correspondiente a los n bits menos significativos de su hash. Puede ver esto implementado en set_add_entry:

mask = so->mask;
i = (size_t)hash & mask;

entry = &so->table[i];
if (entry->key == NULL)
    goto found_unused;

La mayoría de los pequeños Python se dividen a sí mismos; particularmente, todas las entradas en tu prueba se dividen. Puedes ver esto implementado en long_hash. Como su conjunto nunca contiene dos elementos con bits bajos iguales en sus hashes, no se produce una colisión.


Un iterador de conjunto de Python realiza un seguimiento de su posición en un conjunto con un índice entero simple en la tabla hash interna del conjunto. Cuando se solicita el siguiente elemento, el iterador busca una entrada poblada en la tabla hash que comience en ese índice, luego establece su índice almacenado inmediatamente después de la entrada encontrada y devuelve el elemento de la entrada. Puedes ver esto en setiter_iternext:

while (i <= mask && (entry[i].key == NULL || entry[i].key == dummy))
    i++;
si->si_pos = i+1;
if (i > mask)
    goto fail;
si->len--;
key = entry[i].key;
Py_INCREF(key);
return key;

Inicialmente, su conjunto comienza con una tabla hash de tamaño 8 y un puntero a un 0objeto int en el índice 0 en la tabla hash. El iterador también se posiciona en el índice 0. A medida que itera, los elementos se agregan a la tabla hash, cada uno en el siguiente índice porque allí es donde su hash dice ponerlos, y ese es siempre el siguiente índice que mira el iterador. Los elementos eliminados tienen un marcador ficticio almacenado en su posición anterior, con fines de resolución de colisiones. Puedes ver eso implementado en set_discard_entry:

entry = set_lookkey(so, key, hash);
if (entry == NULL)
    return -1;
if (entry->key == NULL)
    return DISCARD_NOTFOUND;
old_key = entry->key;
entry->key = dummy;
entry->hash = -1;
so->used--;
Py_DECREF(old_key);
return DISCARD_FOUND;

Cuando 4se agrega al conjunto, el número de elementos y dummies en el conjunto se vuelve lo suficientemente alto como para set_add_entrydesencadenar una reconstrucción de la tabla hash, llamando set_table_resize:

if ((size_t)so->fill*5 < mask*3)
    return 0;
return set_table_resize(so, so->used>50000 ? so->used*2 : so->used*4);

so->usedes el número de entradas pobladas no ficticias en la tabla hash, que es 2, por lo que set_table_resizerecibe 8 como segundo argumento. En base a esto, set_table_resize decide que el nuevo tamaño de la tabla hash debe ser 16:

/* Find the smallest table size > minused. */
/* XXX speed-up with intrinsics */
size_t newsize = PySet_MINSIZE;
while (newsize <= (size_t)minused) {
    newsize <<= 1; // The largest possible value is PY_SSIZE_T_MAX + 1.
}

Reconstruye la tabla hash con tamaño 16. Todos los elementos aún terminan en sus antiguos índices en la nueva tabla hash, ya que no tenían ningún bit alto establecido en sus hashes.

A medida que el ciclo continúa, los elementos se siguen colocando en el siguiente índice que verá el iterador. Se activa otra reconstrucción de la tabla hash, pero el nuevo tamaño sigue siendo 16.

El patrón se rompe cuando el ciclo agrega 16 como elemento. No hay un índice 16 para colocar el nuevo elemento. Los 4 bits más bajos de 16 son 0000, poniendo 16 en el índice 0. El índice almacenado del iterador es 16 en este punto, y cuando el bucle solicita el siguiente elemento del iterador, el iterador ve que ha pasado el final del tabla de picadillo.

El iterador termina el ciclo en este punto, dejando solo 16en el conjunto.

user2357112 es compatible con Monica
fuente
14

Creo que esto tiene algo que ver con la implementación real de conjuntos en python. Los conjuntos usan tablas hash para almacenar sus elementos, por lo que iterar sobre un conjunto significa iterar sobre las filas de su tabla hash.

A medida que itera y agrega elementos a su conjunto, se crean nuevos hashes y se agregan a la tabla hash hasta llegar al número 16. En este punto, el siguiente número se agrega al principio de la tabla hash y no al final. Y dado que ya iteraste sobre la primera fila de la tabla, el ciclo de iteración termina.

Mi respuesta se basa en esta pregunta similar, en realidad muestra exactamente el mismo ejemplo. Realmente recomiendo leerlo para más detalles.

Jan Koci
fuente
5

De la documentación de Python 3:

El código que modifica una colección mientras itera sobre esa misma colección puede ser difícil de entender. En cambio, generalmente es más sencillo recorrer una copia de la colección o crear una nueva colección:

Iterar sobre una copia

s = {0}
s2 = s.copy()
for i in s2:
     s.add(i + 1)
     s.remove(i)

que debe iterar solo 1 vez

>>> print(s)
{1}
>>> print(s2)
{0}

Editar: Una posible razón para esta iteración es porque un conjunto no está ordenado, lo que causa algún tipo de seguimiento de pila. Si lo hace con una lista y no con un conjunto, simplemente terminará, s = [1]porque las listas están ordenadas de modo que el ciclo for comenzará con el índice 0 y luego pasará al siguiente índice, encontrando que no hay uno, y saliendo del bucle.

Eric Jin
fuente
Si. Pero mi pregunta es por qué hace 16 iteraciones.
noob desbordamiento
El conjunto no está ordenado. Los diccionarios y los conjuntos iteran en un orden no aleatorio, y este algoritmo para iterar solo se mantiene si no modifica nada. Para listas y tuplas, solo puede iterar por índice. Cuando probé su código en 3.7.2, hizo 8 iteraciones.
Eric Jin
El orden de iteración probablemente tenga que ver con el hashing, como otros han mencionado
Eric Jin,
1
¿Qué significa "causar algún tipo de cosa de rastreo de pila"? El código no produjo un bloqueo o error, por lo que no vi ningún rastro de la pila. ¿Cómo habilito el seguimiento de pila en Python?
noob desbordamiento
1

Python estableció una colección desordenada que no registra la posición del elemento ni el orden de inserción. No hay índice adjunto a ningún elemento en un conjunto de Python. Por lo tanto, no admiten ninguna operación de indexación o corte.

Así que no esperes que tu bucle for funcione en un orden definido.

¿Por qué hace 16 iteraciones?

user2357112 supports MonicaYa explica la causa principal. Aquí, hay otra forma de pensar.

s = {0}
for i in s:
     s.add(i + 1)
     print(s)
     s.remove(i)
print(s)

Cuando ejecuta este código, le da salida a esto:

{0, 1}                                                                                                                               
{1, 2}                                                                                                                               
{2, 3}                                                                                                                               
{3, 4}                                                                                                                               
{4, 5}                                                                                                                               
{5, 6}                                                                                                                               
{6, 7}                                                                                                                               
{7, 8}
{8, 9}                                                                                                                               
{9, 10}                                                                                                                              
{10, 11}                                                                                                                             
{11, 12}                                                                                                                             
{12, 13}                                                                                                                             
{13, 14}                                                                                                                             
{14, 15}                                                                                                                             
{16, 15}                                                                                                                             
{16}       

Cuando accedemos a todos los elementos juntos como un bucle o imprimiendo el conjunto, debe haber un orden predefinido para que atraviese todo el conjunto. Entonces, en la última iteración verá que el orden cambia de {i,i+1}a{i+1,i} .

Después de la última iteración sucedió que i+1ya está atravesado, así que salga del bucle.

Dato interesante: Use cualquier valor menor que 16 excepto 6 y 7 siempre le dará el resultado 16.

Eklavya
fuente
"Usar cualquier valor menor que 16 siempre te dará el resultado 16". - pruébalo con 6 o 7, y verás que eso no funciona.
user2357112 es compatible con Monica el
@ user2357112 es compatible con Monica. Lo actualicé. Gracias
Eklavya