Perché Python crea solo una copia del singolo elemento quando itera un elenco?

29

Ho appena capito che in Python, se uno scrive

for i in a:
    i += 1

Gli elementi dell'elenco originale a non saranno in effetti influenzati, poiché la variabile i risulta 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 rimasto davvero sorpreso da questo comportamento. Questo sembra essere molto controintuitivo, apparentemente diverso da altre lingue e ha portato a errori nel mio codice che ho dovuto eseguire il debug per molto tempo oggi.

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

Qual è il ragionamento dietro questo design? È previsto che sia una pratica standard in molte lingue, quindi il tutorial crede che i lettori dovrebbero farlo in modo naturale? In quali altre lingue è presente lo stesso comportamento sull'iterazione, a cui dovrei prestare attenzione in futuro?

    
posta xji 29.01.2017 - 18:32
fonte

6 risposte

68

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

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

  • Se il tipo di dati non implementa un metodo __iadd__ l'istruzione i += x è solo zucchero sintattico per i = i + x , quindi un nuovo valore viene creato 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 ... o no - dipende da cosa viene implementato lì.

I pitoni interi, float, stringhe non implementano __iadd__ quindi questi non saranno aggiornati sul posto. Tuttavia, altri tipi di dati come numpy.array o list s lo implementano e si comportano come previsto. Quindi non è questione di copia o no-copy quando itera (normalmente non fa copie per list se tuple s - ma anche questo dipende dall'implementazione dei contenitori __iter__ e __getitem__ metodo!) - è più una questione di tipo di dati che hai memorizzato in a .

    
risposta data 29.01.2017 - 21:33
fonte
18

Precisazione - terminologia

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

Poiché il richiedente proviene chiaramente dallo sfondo C ++, e poiché questa distinzione - che è richiesta per la spiegazione - non esiste in Python, ho scelto di utilizzare la terminologia C ++, che è:

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

Che cosa intendi per "diverso dalle altre lingue"? La maggior parte delle lingue che so che il supporto per-ogni ciclo copia l'elemento se non diversamente specificato diversamente.

Specificamente per Python (anche se molti di questi motivi potrebbero essere applicati ad altre lingue con concetti architettonici o filosofici simili):

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

  2. Le variabili Python sono sempre un singolo puntatore, quindi è economico iterare con la copia - più economico di iterare 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 - per esempio - C ++. Cioè, tutte le variabili in Python sono in realtà riferimenti, ma nel senso che sono dei puntatori, non un riferimento di tipo "dietro le quinte" come gli argomenti di C ++ type& name . Dal momento che questo concetto non esiste in Python, implementando l'iterazione per riferimento - e tanto meno rendendolo predefinito! - richiederà più complessità al bytecode.

  4. L'istruzione for di Python funziona non solo sugli array, ma su un concetto più generale di generatori. Dietro le quinte, Python chiama iter sugli array per ottenere un oggetto che - quando si chiama next su di esso - restituisce l'elemento successivo o raise s a StopIteration . Esistono diversi modi per implementare i generatori in Python e sarebbe stato molto più difficile implementarli per l'iterazione per riferimento.

risposta data 29.01.2017 - 19:04
fonte
12

Nessuna delle risposte qui ti fornisce alcun codice con cui lavorare per illustrare veramente perché questo accade in Python land. 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 si esamina in che cosa l'oggetto è effettivamente 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 vagamente a un indirizzo di memoria in C / C ++. Esecuzione del codice precedente:

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

Questo significa che il primo a non è più uguale al secondo a , perché i loro ID sono diversi. Effettivamente sono in posizioni diverse 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 risultato 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 è in realtà stesso per entrambe le iterazioni, anche se il valore dell'oggetto è diverso (si potrebbe anche trovare il id del valore int dell'oggetto detiene, che cambierebbe mentre si sta mutando - perché gli 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 += , non stai più aggiungendo all'oggetto originale. In questo caso, poiché gli ints sono tra tipi immutabili in Python , python usa un id differente. È anche interessante notare che Python usa lo stesso% co_de sottostante per 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.

    
risposta data 30.01.2017 - 00:35
fonte
6

@ La risposta di Idan fa un buon lavoro nel spiegare perché Python non tratti la variabile di loop come un puntatore nel modo in cui potresti in C, ma vale la pena di spiegare in modo più approfondito come i frammenti di codice si scompattano, come in Python un sacco di semplici - Sembrare pezzi di codice in realtà saranno chiamate ai metodi incorporati . Per fare il tuo primo esempio

for i in a:
    i += 1

Ci sono due cose da decomprimere: la sintassi for _ in _: e la sintassi _ += _ . Per prima cosa prendi il ciclo for, come in altri linguaggi Python ha un ciclo for-each che è essenzialmente lo zucchero di sintassi per un pattern iteratore. In Python, un iteratore è un oggetto che definisce un .__next__(self) metodo che restituisce l'elemento corrente nella sequenza, avanza al successivo e alza un StopIteration quando non ci sono più elementi nella sequenza. Un Iterable è un oggetto che definisce un metodo .__iter__(self) che restituisce un iteratore.

(N.B .: un Iterator è anche un Iterable e restituisce se stesso dal suo metodo .__iter__(self) .)

Python di solito ha una funzione incorporata che delega al metodo di doppia sottolineatura personalizzato. Quindi ha iter(o) che risolve in o.__iter__() e next(o) che si risolve in% codice%. Nota che queste funzioni integrate tentano spesso una definizione predefinita ragionevole se il metodo a cui delegare non è definito. Ad esempio, o.__next__() di solito si risolve in len(o) , ma se quel metodo non è definito proverà quindi o.__len__() .

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

for i in %EXPR%:
    %LOOP%

verrà decompresso in qualcosa di simile

_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 in

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

L'altra metà di questo è iter() . In generale i += 1 viene decompresso a %ASSIGN% += %EXPR% . Qui %ASSIGN% = %ASSIGN%.__iadd__(%EXPR%) aggiunge inplace e restituisce sé stesso.

(NB Questo è un altro caso in cui Python sceglierà un'alternativa se il metodo main non è definito.Se l'oggetto non implementa __iadd__(self, other) ricadrà su __iadd__ . In realtà lo fa in questo caso come __add__ non implementa int - il che ha senso perché sono immutabili e quindi non possono essere modificati sul posto.)

Quindi il tuo codice qui appare come

_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'è un po 'di più nel secondo bit di codice. Le due nuove cose che dobbiamo sapere sono che __iadd__ viene decompresso a %ARG%[%KEY%] = %VALUE% e (%ARG%).__setitem__(%KEY%, %VALUE%) viene decompresso a %ARG%[%KEY%] . Mettendo insieme questa conoscenza otteniamo (%ARG%).__getitem__(%KEY%) decompresso a a[ix] += 1 (di nuovo: a.__setitem__(ix, a.__getitem__(ix).__add__(1)) piuttosto che __add__ perché __iadd__ non è implementato da ints). Il nostro codice finale assomiglia 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 alla tua domanda sul motivo per cui il primo non modifica l'elenco mentre il secondo fa, nel nostro primo snippet otteniamo __iadd__ da i , che significa next(_a_iter) sarà un i . Poiché int non può essere modificato, int non fa nulla alla lista. Nel nostro secondo caso, non stiamo ancora modificando i += 1 , ma stiamo modificando la lista chiamando int .

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

  1. Il prezzo della leggibilità di Python è che chiama sempre questi metodi di doppio punteggio magico.
  2. Pertanto, per avere la possibilità di capire veramente qualsiasi parte del codice Python devi capire che queste traduzioni stanno facendo.

I doppi metodi di sottolineatura rappresentano un ostacolo all'inizio, ma sono essenziali per supportare la reputazione "runnable pseudocode" di Python. Un programmatore Python decente avrà una conoscenza approfondita di questi metodi e di come vengono richiamati e li definirà ovunque abbia senso farlo.

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

    
risposta data 29.01.2017 - 21:56
fonte
2

+= funziona in modo diverso a seconda che il valore corrente sia mutabile o immutabile . Questo è stato il motivo principale per cui è passato molto tempo prima che venisse implementato in Python, dal momento che gli sviluppatori Python temevano che sarebbe stato complicato.

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

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

Tuttavia, se il lato sinistro è mutabile , + + può effettivamente cambiarlo; come se fosse una lista:

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

Nel tuo ciclo for, i si riferisce a ciascun elemento di a a sua volta. Se questi sono numeri interi, allora si applica il primo caso e il risultato di i += 1 deve essere che si riferisce a un altro oggetto intero. La lista a ovviamente ha ancora gli stessi elementi che ha sempre avuto.

    
risposta data 29.01.2017 - 20:35
fonte
1

Il loop qui è abbastanza irrilevante. Molto simile ai parametri di funzione o agli argomenti, l'impostazione di un ciclo for di questo tipo è essenzialmente un compito di aspetto estetico.

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 (puntatori Pyybject * Pyybject non sorpresi, dato il solo 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 cosa succede? 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 non è completamente influenzato.

Per quanto riguarda il motivo per cui le liste sembrano funzionare, la risposta è fondamentalmente solo che stai assegnando lo stesso nome. Le liste sono mutabili. L'identità dell'oggetto denominato 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 le liste. Sostituisci a[0] in quel codice con y e ottieni lo stesso risultato esatto.

    
risposta data 30.01.2017 - 18:57
fonte

Leggi altre domande sui tag