Perché Python crea una copia del singolo elemento durante l'iterazione di un elenco?


31

L'ho appena capito in Python, se uno scrive

for i in a:
    i += 1

Gli elementi dell'elenco originale ain realtà non saranno affatto influenzati, poiché la variabile irisulta essere solo una copia dell'elemento originale in a.

Per modificare l'elemento originale,

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

sarebbe necessario.

Sono stato davvero sorpreso da questo comportamento. Questo sembra essere molto controintuitivo, apparentemente diverso dalle altre lingue e ha provocato errori nel mio codice che ho dovuto eseguire il debug per molto tempo oggi.

Ho già letto Python Tutorial. Giusto per essere sicuro, ho controllato di nuovo il libro proprio ora, e non menziona nemmeno questo comportamento.

Qual è il ragionamento alla base di questo disegno? Si prevede che sia una pratica standard in molte lingue in modo che il tutorial creda che i lettori dovrebbero ottenerlo naturalmente? In quali altre lingue è presente lo stesso comportamento sull'iterazione, a cui dovrei prestare attenzione in futuro?


19
Questo è vero solo se iè immutabile o se stai eseguendo un'operazione non mutante. Con un elenco nidificato for i in a: a.append(1)avrebbe un comportamento diverso; Python non copia gli elenchi nidificati. Tuttavia, i numeri interi sono immutabili e l'aggiunta restituisce un nuovo oggetto, non modifica quello vecchio.
jonrsharpe,

10
Non è affatto sorprendente. Non riesco a pensare a un linguaggio che non sia esattamente lo stesso per una matrice di tipi di base come intero. Ad esempio, prova in JavaScript a=[1,2,3];a.forEach(i => i+=1);alert(a). Lo stesso in C #
edc65,

7
Ti aspetteresti i = i + 1di influenzare a?
deltab,

7
Si noti che questo comportamento non è diverso in altre lingue. C, Javascript, Java ecc. Si comportano in questo modo.
Slebetman,

1
@jonrsharpe per gli elenchi "+ =" cambia il vecchio elenco, mentre "+" ne crea uno nuovo
Vasily Alexeev,

Risposte:


68

Di recente ho già risposto a una domanda simile ed è molto importante rendersi conto che +=può avere significati diversi:

  • Se il tipo di dati implementa l'aggiunta sul posto (ovvero ha una __iadd__funzione correttamente funzionante), i dati a cui si ifa riferimento vengono aggiornati (non importa se si trovano in un elenco o altrove).

  • Se il tipo di dati non implementa un __iadd__metodo, l' i += xistruzione è solo zucchero sintattico i = i + x, quindi viene creato un nuovo valore e assegnato al nome della variabile i.

  • Se il tipo di dati implementa __iadd__ma fa qualcosa di strano. Potrebbe essere possibile che sia aggiornato ... oppure no, dipende da ciò che è implementato lì.

Gli interi, i float e le stringhe Pythons non vengono implementati, __iadd__quindi non verranno aggiornati sul posto. Tuttavia, altri tipi di dati come numpy.arrayo listlo implementano e si comporteranno come previsto. Quindi non è una questione di copia o di no-copy quando l'iterazione (normalmente non lo fa copie per lists e tuples - ma che pure dipende dall'implementazione dei contenitori __iter__e __getitem__metodo!) - è più una questione del tipo di dati hai memorizzato nel tuo a.


2
Questa è la spiegazione corretta per il comportamento descritto nella domanda.
pabouk,

19

Chiarimento - terminologia

Python non distingue tra i concetti di riferimento e puntatore . Di solito usano solo il termine riferimento , ma se si confronta con linguaggi come C ++ che hanno questa distinzione - è molto più vicino a un puntatore .

Poiché il richiedente proviene chiaramente dal background C ++ e poiché tale distinzione - che è richiesta per la spiegazione - non esiste in Python, ho scelto di usare la terminologia del C ++, che è:

  • Valore : dati effettivi presenti nella memoria. void foo(int x);è una firma di una funzione che riceve un numero intero per valore .
  • Puntatore : un indirizzo di memoria trattato come valore. Può essere rinviato per accedere alla memoria a cui punta. void foo(int* x);è una firma di una funzione che riceve un numero intero dal puntatore .
  • Riferimento : zucchero attorno ai puntatori. C'è un puntatore dietro le quinte, ma puoi solo accedere al valore differito e non puoi cambiare l'indirizzo a cui punta. void foo(int& x);è una firma di una funzione che riceve un numero intero per riferimento .

Cosa intendi con "diverso dalle altre lingue"? La maggior parte delle lingue che conosco supportano per ogni loop copiano l'elemento se non diversamente specificato.

In particolare per Python (anche se molti di questi motivi possono applicarsi ad altri linguaggi con concetti architettonici o filosofici simili):

  1. Questo comportamento può causare bug per le persone che non lo conoscono, ma il comportamento alternativo può causare bug anche per coloro che ne sono consapevoli . Quando assegni una variabile ( i) di solito non ti fermi e consideri tutte le altre variabili che verrebbero modificate a causa di essa ( a). Limitare l'ambito su cui si sta lavorando è un fattore importante nella prevenzione del codice spaghetti, e quindi l'iterazione per copia è di solito l'impostazione predefinita anche nelle lingue che supportano l'iterazione per riferimento.

  2. Le variabili Python sono sempre un singolo puntatore, quindi è economico iterare per copia - più economico dell'iterazione per riferimento, che richiederebbe un ulteriore rinvio ogni volta che si accede al valore.

  3. Python non ha il concetto di variabili di riferimento come, ad esempio, C ++. Cioè, tutte le variabili in Python sono in realtà riferimenti, ma nel senso che sono puntatori - non riferimenti costanti dietro le quinte come type& nameargomenti C ++ . Poiché questo concetto non esiste in Python, implementando l'iterazione per riferimento - figuriamoci rendendolo il valore predefinito! - richiederà l'aggiunta di maggiore complessità al bytecode.

  4. L' foraffermazione di Python funziona non solo sugli array, ma su un concetto più generale di generatori. Dietro le quinte, Python chiama i itertuoi array per ottenere un oggetto che - quando lo chiami next- restituisce l'elemento successivo o raisesa StopIteration. Esistono diversi modi per implementare i generatori in Python e sarebbe stato molto più difficile implementarli per iterazione per riferimento.


Grazie per la risposta. Sembra che la mia comprensione sugli iteratori non sia ancora abbastanza solida allora. Gli iteratori nel riferimento C ++ non sono predefiniti? Se si fa riferimento all'iteratore, è sempre possibile modificare immediatamente il valore dell'elemento del contenitore originale?
xji,

4
Python esegue l' iterazione per riferimento (bene, per valore, ma il valore è un riferimento). Provare questo con un elenco di oggetti mutabili dimostrerà rapidamente che non si verifica alcuna copia.
jonrsharpe,

Gli iteratori in C ++ sono in realtà oggetti che possono essere rinviati per accedere al valore nell'array. Per modificare l'elemento originale, devi usare *it = ...- ma questo tipo di sintassi indica già che stai modificando qualcosa da qualche altra parte - il che rende la ragione n. 1 meno un problema. Anche i motivi n. 2 e n. 3 non si applicano, perché in C ++ la copia è costosa e esiste il concetto di variabili di riferimento. Per quanto riguarda il motivo n. 4, la possibilità di restituire un riferimento consente una semplice implementazione per tutti i casi.
Idan Arye,

1
@jonrsharpe Sì, viene chiamato per riferimento, ma in qualsiasi lingua che distingue puntatori e riferimenti questo tipo di iterazione sarà un'iterazione per puntatore (e poiché i puntatori sono valori - iterazione per valore). Aggiungerò un chiarimento.
Idan Arye,

20
Il tuo primo paragrafo suggerisce che Python, come quelle altre lingue, copia l'elemento in un ciclo for. Non Non limita l'ambito delle modifiche apportate a quell'elemento. L'OP vede questo comportamento solo perché i suoi elementi sono immutabili; senza nemmeno menzionare questa distinzione, la tua risposta è nella migliore delle ipotesi incompleta e nella peggiore delle ipotesi fuorviante.
jonrsharpe,

11

Nessuna delle risposte qui ti dà alcun codice con cui lavorare per illustrare davvero perché ciò accade nella terra di Python. E questo è divertente da guardare in un approccio più profondo, quindi ecco qui.

Il motivo principale per cui questo non funziona come previsto è perché in Python, quando scrivi:

i += 1

non sta facendo quello che pensi che stia facendo. I numeri interi sono immutabili. Questo può essere visto quando guardi cosa è effettivamente l'oggetto in Python:

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

La funzione id rappresenta un valore unico e costante per un oggetto nella sua vita. Concettualmente, si associa liberamente a un indirizzo di memoria in C / C ++. Esecuzione del codice sopra:

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

Ciò significa che il primo anon è più lo stesso del secondo a, perché i loro ID sono diversi. In effetti si trovano in diverse posizioni nella memoria.

Con un oggetto, tuttavia, le cose funzionano diversamente. Ho sovrascritto l' +=operatore qui:

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

L'esecuzione di questo risulta nel seguente output:

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

Si noti che l'attributo id in questo caso è effettivamente lo stesso per entrambe le iterazioni, anche se il valore dell'oggetto è diverso (è possibile trovare anche idil valore int contenuto nell'oggetto, che cambierebbe mentre sta mutando, perché numeri interi sono immutabili).

Confronta questo con quando esegui lo stesso esercizio con un oggetto immutabile:

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

Questo 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

Alcune cose qui da notare. Innanzitutto, nel ciclo con il +=, non si sta più aggiungendo all'oggetto originale. In questo caso, poiché gli ints sono tra i tipi immutabili in Python , python utilizza un ID diverso. È anche interessante notare che Python utilizza lo stesso sottostante idper più variabili con lo stesso valore immutabile:

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 ha una manciata di tipi immutabili, che causano il comportamento che vedi. Per tutti i tipi mutabili, le tue aspettative sono corrette.


6

La risposta di @ Idan fa un buon lavoro nel spiegare perché Python non considera la variabile loop come un puntatore come si potrebbe fare in C, ma vale la pena di spiegare in modo più approfondito come decomprimono gli snippet di codice, come in Python un sacco di bit apparentemente semplici di codice saranno in realtà chiamate a metodi integrati . Per fare il tuo primo esempio

for i in a:
    i += 1

Ci sono due cose da decomprimere: la for _ in _:sintassi e la _ += _sintassi. Per prima cosa prendere il ciclo for, come altre lingue Python ha un for-eachciclo che è essenzialmente zucchero di sintassi per un modello iteratore. In Python, un iteratore è un oggetto che definisce un .__next__(self)metodo che restituisce l'elemento corrente nella sequenza, avanza al successivo e solleverà un StopIterationquando non ci sono più elementi nella sequenza. Un Iterable è un oggetto che definisce un .__iter__(self)metodo che restituisce un iteratore.

(NB: an Iteratorè anche an Iterablee si restituisce dal suo .__iter__(self)metodo.)

Python di solito avrà una funzione integrata che delega al metodo di sottolineatura doppio personalizzato. Quindi ha ciò iter(o)che si risolve o.__iter__()e next(o)quello che si risolve o.__next__(). Nota che queste funzioni integrate provano spesso una ragionevole definizione predefinita se il metodo a cui delegare non è definito. Ad esempio, di len(o)solito si risolve, o.__len__()ma se quel metodo non è definito, allora proverà iter(o).__len__().

Un ciclo è essenzialmente definito in termini di next(), iter()e più strutture di controllo di base. In generale il codice

for i in %EXPR%:
    %LOOP%

verrà decompresso in qualcosa del genere

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

Quindi in questo caso

for i in a:
    i += 1

viene decompresso

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

L'altra metà è i += 1. In generale %ASSIGN% += %EXPR%viene decompresso %ASSIGN% = %ASSIGN%.__iadd__(%EXPR%). Qui __iadd__(self, other)fa l'aggiunta sul posto e restituisce se stesso.

(NB Questo è un altro caso in cui Python sceglierà un'alternativa se il metodo principale non è definito. Se l'oggetto non lo implementa __iadd__, si ripiegherà __add__. In questo caso lo intfa in questo caso in quanto non implementato __iadd__, il che ha senso perché sono immutabili e quindi non possono essere modificati sul posto.)

Quindi il tuo codice qui sembra

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

dove possiamo definire

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

C'è qualcosa in più nel secondo bit di codice. Le due nuove cose che dobbiamo sapere sono che %ARG%[%KEY%] = %VALUE%vengono decompresse (%ARG%).__setitem__(%KEY%, %VALUE%)e per cui %ARG%[%KEY%]vengono decompresse (%ARG%).__getitem__(%KEY%). Mettendo insieme queste conoscenze veniamo a[ix] += 1decompressi a.__setitem__(ix, a.__getitem__(ix).__add__(1))(di nuovo: __add__piuttosto che __iadd__perché __iadd__non è implementato da ints). Il nostro codice finale è simile a:

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

Per rispondere realmente alla tua domanda sul motivo per cui il primo non modifica la lista mentre il secondo lo fa, nel nostro primo frammento stiamo ottenendo ida next(_a_iter), quali mezzi isarà un int. Dal momento intche non può essere modificato sul posto, i += 1non fa nulla per la lista. Nel nostro secondo caso non stiamo di nuovo modificando il intma stiamo modificando l'elenco chiamando __setitem__.

La ragione di questo esercizio così elaborato è perché penso che insegni la seguente lezione su Python:

  1. Il prezzo della leggibilità di Python è che chiama continuamente questi metodi magici a doppio punteggio.
  2. Pertanto, per avere la possibilità di comprendere veramente qualsiasi pezzo di codice Python devi capire queste traduzioni che sta facendo.

I metodi di doppia sottolineatura sono un ostacolo all'inizio, ma sono essenziali per sostenere la reputazione "pseudocodice eseguibile" di Python. Un programmatore Python decente avrà una conoscenza approfondita di questi metodi e di come vengono invocati e li definirà ovunque abbia senso farlo.

Modifica : @deltab ha corretto il mio uso sciatto del termine "raccolta".


2
"gli iteratori sono anche raccolte" non è del tutto giusto: sono anche iterabili, ma anche le raccolte hanno __len__e__contains__
deltab

2

+=funziona in modo diverso a seconda che il valore corrente sia mutabile o immutabile . Questo è stato il motivo principale per cui ci vuole molto tempo per implementarlo in Python, poiché gli sviluppatori di Python temevano che potesse essere fonte di confusione.

Se iè un int, allora non può essere modificato poiché gli ints sono immutabili, e quindi se il valore di icambiamenti allora deve necessariamente indicare un altro oggetto:

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

Tuttavia, se il lato sinistro è mutabile , allora + = può effettivamente cambiarlo; come se fosse un elenco:

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

Nel tuo ciclo for, si iriferisce a ciascun elemento di aturno. Se quelli sono numeri interi, si applica il primo caso e il risultato di i += 1deve essere che si riferisce a un altro oggetto intero. L'elenco aovviamente ha ancora gli stessi elementi che ha sempre avuto.


Non capisco questa distinzione tra oggetti mutabili e immutabili: se i = 1impostato isu un oggetto intero immutabile, i = []dovrebbe essere impostato isu un oggetto elenco immutabile. In altre parole, perché gli oggetti interi sono immutabili e gli oggetti elenco mutabili? Non vedo alcuna logica dietro questo.
Giorgio,

@Giorgio: gli oggetti appartengono a classi diverse, listimplementa metodi che ne cambiano il contenuto, intno. [] è un oggetto elenco mutabile e i = []fa iriferimento a tale oggetto.
RemcoGerlich,

@Giorgio non esiste un elenco immutabile in Python. Le liste sono mutabili. I numeri interi non lo sono. Se vuoi qualcosa come un elenco ma immutabile, considera una tupla. Quanto al perché, non è chiaro a quale livello vorresti che rispondesse.
jonrsharpe,

@RemcoGerlich: Capisco che le diverse classi si comportano diversamente, non capisco perché siano state implementate in questo modo, cioè non capisco la logica dietro questa scelta. Avrei implementato l' +=operatore / metodo per comportarsi in modo simile (principio della minima sorpresa) per entrambi i tipi: cambiare l'oggetto originale o restituire una copia modificata sia per numeri interi che per liste.
Giorgio,

1
@Giorgio: è assolutamente vero che +=è sorprendente in Python, ma si è ritenuto che anche le altre opzioni menzionate sarebbero state sorprendenti, o almeno meno pratiche (la modifica dell'oggetto originale non può essere fatta con il tipo di valore più comune usi + = con, ints. E copiare un intero elenco è molto più costoso che mutarlo, Python non copia cose come elenchi e dizionari se non esplicitamente detto a). All'epoca era un grande dibattito.
RemcoGerlich,

1

Il ciclo qui è piuttosto irrilevante. Proprio come i parametri o gli argomenti delle funzioni, impostare un ciclo for in questo modo è essenzialmente un compito dall'aspetto fantasioso.

I numeri interi sono immutabili. L'unico modo per modificarli è creare un nuovo numero intero e assegnarlo allo stesso nome dell'originale.

La semantica di Python per l'assegnazione mappa direttamente su C (non sorprendente dato i puntatori PyObject * di CPython), con l'unico avvertimento che tutto è un puntatore e non ti è permesso avere doppi puntatori. Considera il seguente codice:

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

Che succede? Esso stampa 1. Perché? In realtà è approssimativamente equivalente al seguente codice 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);

Nel codice C, è ovvio che il valore di aè completamente inalterato.

Per quanto riguarda il motivo per cui gli elenchi sembrano funzionare, la risposta è fondamentalmente solo che stai assegnando lo stesso nome. Le liste sono mutabili. L'identità dell'oggetto nominato a[0]cambierà, ma a[0]è ancora un nome valido. Puoi verificarlo con il seguente codice:

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

Ma questo non è speciale per gli elenchi. Sostituisci a[0]quel codice con ye otterrai esattamente lo stesso risultato.

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.