Perché ottengo così tante iterazioni durante l'aggiunta e la rimozione da un set durante l'iterazione su di esso?


62

Cercando di capire il Python for-loop, ho pensato che questo avrebbe dato il risultato {1}per una iterazione, o semplicemente rimanevo bloccato in un ciclo infinito, a seconda che facesse l'iterazione come in C o in altre lingue. Ma in realtà non ha fatto nessuno dei due.

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

Perché esegue 16 iterazioni? Da dove viene il risultato {16}?

Stava usando Python 3.8.2. Su pypy fa il risultato atteso {1}.


17
A seconda degli elementi aggiunti, ogni chiamata a s.add(i+1)(e possibilmente, la chiamata a s.remove(i)) può modificare l'ordine di iterazione del set, influenzando ciò che vedrà il successivo iteratore creato dal ciclo for. Non mutare un oggetto mentre hai un iteratore attivo.
Chepner,

6
Ho notato anche questo t = {16}e poi t.add(15)cedo che t è l'insieme {16, 15}. Penso che il problema sia lì da qualche parte.

19
È un dettaglio dell'implementazione - 16 ha un hash inferiore di 15 (questo è ciò che ha notato @Anon), quindi aggiungendo 16 al tipo di set è stato aggiunto alla parte "già vista" dell'iteratore, e quindi l'iteratore era esaurito.
Błotosmętek,

1
Se leggi troppi documenti, c'è una nota che dice che la modifica degli iteratori durante il ciclo potrebbe creare alcuni bug. Vedi: docs.python.org/3.7/reference/…
Marcello Fabrizio

3
@ Błotosmętek: su CPython 3.8.2, hash (16) == 16 e hash (15) == 15. Il comportamento non deriva dall'hash stesso che è inferiore; gli elementi non sono memorizzati direttamente nell'ordine di hash in un set.
user2357112 supporta Monica il

Risposte:


87

Python non promette quando (se mai) questo ciclo finirà. La modifica di un set durante l'iterazione può portare a elementi saltati, elementi ripetuti e altre stranezze. Non fare mai affidamento su tale comportamento.

Tutto quello che sto per dire sono i dettagli di implementazione, soggetti a modifiche senza preavviso. Se si scrive un programma che si basa su uno di essi, il programma potrebbe interrompere qualsiasi combinazione di implementazione e versione di Python diversa da CPython 3.8.2.

La breve spiegazione del perché il loop termina in 16 è che 16 è il primo elemento che si trova in un indice di tabella hash inferiore rispetto all'elemento precedente. La spiegazione completa è di seguito.


La tabella hash interna di un set Python ha sempre una potenza di 2 dimensioni. Per una tabella di dimensioni 2 ^ n, se non si verificano collisioni, gli elementi vengono memorizzati nella posizione nella tabella hash corrispondente ai n bit meno significativi del loro hash. Puoi vederlo implementato in set_add_entry:

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

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

La maggior parte dei piccoli Python inserisce l'hash in se stessi; in particolare, tutti gli ints nel tuo test hanno hash verso se stessi. Puoi vederlo implementato in long_hash. Dato che il tuo set non contiene mai due elementi con bit bassi uguali nei loro hash, non si verifica alcuna collisione.


Un iteratore di set Python tiene traccia della sua posizione in un set con un semplice indice intero nella tabella hash interna del set. Quando viene richiesto l'elemento successivo, l'iteratore cerca una voce popolata nella tabella hash a partire da quell'indice, quindi imposta l'indice memorizzato immediatamente dopo la voce trovata e restituisce l'elemento della voce. Puoi vederlo in 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;

Il set inizia inizialmente con una tabella hash di dimensioni 8 e un puntatore a un 0oggetto int all'indice 0 nella tabella hash. L'iteratore è anche posizionato sull'indice 0. Mentre esegui l'iterazione, gli elementi vengono aggiunti alla tabella hash, ciascuno nell'indice successivo perché è lì che il loro hash dice di metterli, ed è sempre l'indice successivo che l'iteratore osserva. Gli elementi rimossi hanno un indicatore fittizio memorizzato nella loro vecchia posizione, ai fini della risoluzione delle collisioni. Puoi vederlo implementato in 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;

Quando 4viene aggiunto al set, il numero di elementi e manichini nel set diventa abbastanza alto da set_add_entryinnescare una ricostruzione della tabella hash, chiamando 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->usedè il numero di voci popolate e non fittizie nella tabella hash, che è 2, quindi set_table_resizericeve 8 come secondo argomento. Sulla base di questo, set_table_resize decide che la nuova dimensione della tabella hash dovrebbe essere 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.
}

Ricostruisce la tabella hash con la dimensione 16. Tutti gli elementi finiscono ancora nei loro vecchi indici nella nuova tabella hash, poiché non avevano bit alti impostati nei loro hash.

Man mano che il ciclo continua, gli elementi continuano a essere posizionati nell'indice successivo a cui apparirà l'iteratore. Viene attivata un'altra ricostruzione della tabella hash, ma la nuova dimensione è ancora 16.

Il pattern si interrompe quando il loop aggiunge 16 come elemento. Non esiste un indice 16 in cui posizionare il nuovo elemento. I 4 bit più bassi di 16 sono 0000, ponendo 16 all'indice 0. L'indice memorizzato dell'iteratore è 16 a questo punto, e quando il ciclo richiede l'elemento successivo dall'iteratore, l'iteratore vede che è passato oltre la fine del tabella hash.

L'iteratore termina il ciclo a questo punto, lasciando solo 16nel set.


14

Credo che questo abbia qualcosa a che fare con l'implementazione effettiva dei set in Python. Gli insiemi usano le tabelle hash per archiviare i loro elementi e quindi iterare su un set significa iterare sulle righe della sua tabella hash.

Mentre esegui l'iterazione e aggiungi elementi al tuo set, vengono creati e aggiunti nuovi hash alla tabella hash fino a raggiungere il numero 16. A questo punto, il numero successivo viene effettivamente aggiunto all'inizio della tabella hash e non alla fine. E poiché hai già ripetuto la prima riga della tabella, il ciclo di iterazione termina.

La mia risposta si basa su questa di una domanda simile, in realtà mostra esattamente lo stesso esempio. Consiglio vivamente di leggerlo per maggiori dettagli.


5

Dalla documentazione di Python 3:

Il codice che modifica una raccolta durante l'iterazione su quella stessa raccolta può essere difficile da ottenere correttamente. Invece, di solito è più semplice passare in rassegna una copia della raccolta o creare una nuova raccolta:

Scorrere su una copia

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

che dovrebbe ripetere solo 1 volta

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

Modifica: una possibile ragione per questa iterazione è perché un set non è ordinato, causando una sorta di traccia dello stack. Se lo fai con un elenco e non con un set, allora finirà, s = [1]perché gli elenchi sono ordinati in modo che il ciclo for inizi con l'indice 0 e poi passi all'indice successivo, scoprendo che non ce n'è uno e uscendo dal ciclo.


Sì. Ma la mia domanda è: perché compie 16 iterazioni.
noob overflow

il set non è ordinato. Dizionari e imposta iterare in un ordine non casuale, e questo algoritmo per iterare vale solo se non si modifica nulla. Per elenchi e tuple, può semplicemente scorrere per indice. Quando ho provato il tuo codice in 3.7.2, ha fatto 8 iterazioni.
Eric Jin

L'ordine di iterazione probabilmente ha a che fare con l'hash, come altri hanno già detto
Eric Jin

1
Cosa significa "causare una sorta di cosa di ordinamento della traccia dello stack"? Il codice non ha provocato arresti anomali o errori, quindi non ho visto alcuna traccia dello stack. Come abilito la traccia dello stack in Python?
noob overflow

1

Python imposta una raccolta non ordinata che non registra la posizione dell'elemento o l'ordine di inserimento. Non esiste alcun indice associato a nessun elemento in un set di Python. Pertanto non supportano alcuna operazione di indicizzazione o divisione.

Quindi non aspettarti che il tuo ciclo for funzioni in un ordine definito.

Perché esegue 16 iterazioni?

user2357112 supports Monicaspiega già la causa principale. Ecco un altro modo di pensare.

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

Quando esegui questo codice ti dà questo output:

{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}       

Quando accediamo a tutti gli elementi insieme come loop o stampando il set, ci deve essere un ordine predefinito per attraversare l'intero set. Quindi, nell'ultima iterazione vedrai che l'ordine è cambiato come da {i,i+1}a {i+1,i}.

Dopo l'ultima iterazione è successo che i+1è già stato attraversato, quindi esce dal loop.

Fatto interessante: Usa qualsiasi valore inferiore a 16 tranne 6 e 7 ti darà sempre il risultato 16.


"Usa qualsiasi valore inferiore a 16 ti darà sempre il risultato 16." - provalo con 6 o 7 e vedrai che non regge.
user2357112 supporta Monica il

@ user2357112 supporta Monica L'ho aggiornato. Grazie
Eklavya il
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.