Dlaczego Python tworzy kopię pojedynczego elementu tylko podczas iteracji listy?

31

Właśnie zdałem sobie sprawę, że w Pythonie, jeśli ktoś pisze

for i in a:
    i += 1

Elementy oryginalnej listy w arzeczywistości nie będą miały żadnego wpływu, ponieważ zmienna iokazuje się być tylko kopią oryginalnego elementu w a.

Aby zmodyfikować oryginalny element,

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

byłoby potrzebne.

Byłem naprawdę zaskoczony tym zachowaniem. Wydaje się to być bardzo sprzeczne z intuicją, pozornie różne od innych języków i spowodowało błędy w moim kodzie, które musiałem debugować przez długi czas dzisiaj.

Wcześniej czytałem Samouczek języka Python. Dla pewności sprawdziłem teraz książkę i nawet nie wspomina o tym zachowaniu.

Jakie jest uzasadnienie tego projektu? Czy oczekuje się, że będzie to standardowa praktyka w wielu językach, aby samouczek był przekonany, że czytelnicy powinni to rozumieć naturalnie? W jakich innych językach występuje to samo zachowanie podczas iteracji, na które powinienem zwrócić uwagę w przyszłości?

Xji
źródło
19
Jest to prawdą tylko wtedy, gdy ijest niezmienne lub przeprowadzasz niemutującą operację. Z zagnieżdżoną listą for i in a: a.append(1)miałoby inne zachowanie; Python nie kopiuje zagnieżdżonych list. Jakkolwiek liczby całkowite są niezmienne, a dodawanie zwraca nowy obiekt, nie zmienia to starego.
jonrsharpe
10
To wcale nie jest zaskakujące. Nie mogę wymyślić języka, który nie jest dokładnie taki sam dla szeregu podstawowych typów, takich jak liczba całkowita. Na przykład spróbuj w javascript a=[1,2,3];a.forEach(i => i+=1);alert(a). To samo w C #
edc65
7
Czy spodziewałbyś i = i + 1się wpłynąć a?
deltab
7
Pamiętaj, że to zachowanie nie różni się w innych językach. C, JavaScript, Java itp. Zachowują się w ten sposób.
slebetman
1
@jonrsharpe dla list „+ =” zmienia starą listę, a „+” tworzy nową
Wasilij Aleksiejew

Odpowiedzi:

68

Ja już Ostatnio odpowiedziałem na podobne pytanie i bardzo ważne jest, aby zdać sobie sprawę, że +=mogą mieć różne znaczenia:

  • Jeśli typ danych implementuje dodawanie w miejscu (tj. Działa poprawnie __iadd__ funkcję), wówczas dane, ido których się odnosi, są aktualizowane (nie ma znaczenia, czy znajduje się na liście, czy gdzie indziej).

  • Jeśli typ danych nie implementuje __iadd__metody, i += xinstrukcja jest tylko cukrem syntaktycznym i = i + x, więc tworzona jest nowa wartość i przypisywana do nazwy zmiennej i.

  • Jeśli typ danych implementuje, __iadd__ale robi coś dziwnego. Możliwe, że jest aktualizowany ... lub nie - zależy to od tego, co tam jest zaimplementowane.

Pythonowe liczby całkowite, zmiennoprzecinkowe, ciągi nie są implementowane, __iadd__więc nie będą one aktualizowane w miejscu. Jednak inne typy danych, takie jak numpy.arraylub lists, implementują go i zachowują się tak, jak się spodziewałeś. Zatem podczas iteracji nie jest to kwestia kopiowania ani braku kopiowania (zwykle nie wykonuje kopii dla listsi i tuples - ale to również zależy od implementacji kontenerów __iter__i __getitem__metody!) - jest to bardziej kwestia typu danych masz w sobie a.

MSeifert
źródło
2
To jest prawidłowe wyjaśnienie zachowania opisanego w pytaniu.
pabouk
19

Wyjaśnienie - terminologia

Python nie rozróżnia pojęć odniesienia i wskaźnika . Zwykle używają po prostu odwołania do odwołania , ale jeśli porównasz z językami takimi jak C ++, które mają takie rozróżnienie - jest to znacznie bliżej wskaźnika .

Ponieważ pytający wyraźnie pochodzi z tła C ++, a ponieważ to rozróżnienie - które jest wymagane do wyjaśnienia - nie istnieje w Pythonie, zdecydowałem się użyć terminologii C ++, która brzmi:

  • Wartość : rzeczywiste dane przechowywane w pamięci. void foo(int x);jest sygnaturą funkcji, która otrzymuje liczbę całkowitą według wartości .
  • Wskaźnik : Adres pamięci traktowany jako wartość. Można odroczyć, aby uzyskać dostęp do pamięci, na którą wskazuje. void foo(int* x);jest sygnaturą funkcji, która otrzymuje liczbę całkowitą przez wskaźnik .
  • Odniesienie : Cukier wokół wskaźników. Za kulisami znajduje się wskaźnik, ale można uzyskać dostęp tylko do odroczonej wartości i nie można zmienić adresu, na który wskazuje. void foo(int& x);jest sygnaturą funkcji, która otrzymuje liczbę całkowitą przez odniesienie .

Co masz na myśli mówiąc „różni się od innych języków”? Większość języków, o których wiem, że obsługuje dla każdej pętli, kopiuje element, chyba że wyraźnie postanowiono inaczej.

Specjalnie dla Pythona (choć wiele z tych powodów może dotyczyć innych języków o podobnych koncepcjach architektonicznych lub filozoficznych):

  1. Takie zachowanie może powodować błędy dla osób, które nie są tego świadome, ale alternatywne zachowanie może powodować błędy nawet dla tych, którzy są tego świadomi . Kiedy przypisujesz zmienną ( i), zwykle nie zatrzymujesz się i rozważasz wszystkie inne zmienne, które zostałyby zmienione z tego powodu ( a). Ograniczenie zakresu, nad którym pracujesz, jest głównym czynnikiem zapobiegającym kodowi spaghetti, dlatego iteracja po kopii jest zwykle domyślna nawet w językach, które obsługują iterację przez odniesienie.

  2. Zmienne w języku Python są zawsze pojedynczym wskaźnikiem, więc iteracja przy kopiowaniu jest tania - tańsza niż iteracja przez odniesienie, co wymagałoby dodatkowego odroczenia przy każdym dostępie do wartości.

  3. Python nie ma pojęcia zmiennych odniesienia, takich jak - na przykład - C ++. Oznacza to, że wszystkie zmienne w Pythonie są w rzeczywistości referencjami, ale w tym sensie, że są wskaźnikami, a nie zakulisowymi stałymi referencjami, takimi jak type& nameargumenty C ++ . Ponieważ ta koncepcja nie istnieje w Pythonie, implementacja iteracji przez odniesienie - nie mówiąc już o ustawieniu jej jako domyślnej! - będzie wymagać dodania większej złożoności do kodu bajtowego.

  4. forInstrukcja Pythona działa nie tylko na tablicach, ale na bardziej ogólnej koncepcji generatorów. Za kulisami Python wywołuje itertablice, aby uzyskać obiekt, który - gdy go wywołujesz next- zwraca następny element lub raisesa StopIteration. Istnieje kilka sposobów implementacji generatorów w Pythonie i byłoby znacznie trudniej zaimplementować je dla iteracji przez odniesienie.

Idan Arye
źródło
Dziękuję za odpowiedź. Wydaje się, że moje rozumienie iteratorów wciąż nie jest wystarczająco solidne. Czy iteratory nie są domyślnie w C ++? Jeśli odrzucisz iterator, zawsze możesz natychmiast zmienić wartość elementu oryginalnego kontenera?
Xji
4
Python wykonuje iterację przez referencję (cóż, według wartości, ale wartość jest referencją). Wypróbowanie tego z listą zmiennych obiektów szybko pokaże, że nie ma miejsca kopiowanie.
jonrsharpe
Iteratory w C ++ są w rzeczywistości obiektami, które można odroczyć w celu uzyskania dostępu do wartości w tablicy. Aby zmodyfikować oryginalny element, używasz *it = ...- ale tego rodzaju składnia już wskazuje, że modyfikujesz coś gdzie indziej - co sprawia, że ​​powód nr 1 jest mniejszym problemem. Powody # 2 i # 3 również nie mają zastosowania, ponieważ w C ++ kopiowanie jest kosztowne i istnieje pojęcie zmiennych odniesienia. Co do przyczyny 4 - możliwość zwrócenia referencji pozwala na prostą implementację we wszystkich przypadkach.
Idan Arye
1
@jonrsharpe Tak, nazywa się to przez referencję, ale w każdym języku, w którym rozróżnia się wskaźniki i odniesienia, tego rodzaju iteracja będzie iteracją po wskaźniku (a ponieważ wskaźniki są wartościami - iteracja według wartości). Dodam wyjaśnienie.
Idan Arye
20
Twój pierwszy akapit sugeruje, że Python, podobnie jak inne języki, kopiuje element w pętli for. Nie ma Nie ogranicza zakresu zmian wprowadzanych do tego elementu. PO widzi to zachowanie tylko dlatego, że ich elementy są niezmienne; nawet nie wspominając o tym rozróżnieniu, twoja odpowiedź jest w najlepszym razie niekompletna, aw najgorszym - myląca.
jonrsharpe
11

Żadna z odpowiedzi tutaj nie daje żadnego kodu do pracy, aby naprawdę zilustrować, dlaczego tak się dzieje w krainie Python. I fajnie jest patrzeć głębiej, więc proszę bardzo.

Głównym powodem, dla którego nie działa to tak, jak się spodziewasz, jest to, że podczas pisania w Pythonie:

i += 1

nie robi tego, co myślisz. Liczby całkowite są niezmienne. Można to zobaczyć, patrząc na obiekt znajdujący się w Pythonie:

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

Funkcja id reprezentuje unikalną i stałą wartość obiektu w czasie jego życia. Pod względem koncepcyjnym luźno mapuje na adres pamięci w C / C ++. Uruchamianie powyższego kodu:

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

Oznacza to, że pierwszy anie jest już taki sam jak drugia , ponieważ ich identyfikatory są różne. W rzeczywistości znajdują się w różnych miejscach w pamięci.

Z przedmiotem jednak wszystko działa inaczej. Zastąpiłem +=tutaj operatora:

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

Uruchomienie tego powoduje następujące wyniki:

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

Zauważ, że atrybut id w tym przypadku jest w rzeczywistości taki sam dla obu iteracji, nawet jeśli wartość obiektu jest inna (możesz również znaleźć idwartość int, którą posiada obiekt, który zmieniałby się w miarę mutacji - ponieważ liczby całkowite są niezmienne).

Porównaj to z uruchomieniem tego samego ćwiczenia z niezmiennym obiektem:

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

To daje:

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

Kilka rzeczy do zauważenia. Po pierwsze, w pętli z +=nie dodajesz już do oryginalnego obiektu. W tym przypadku, ponieważ ints należą do niezmiennych typów w Pythonie , python używa innego identyfikatora. Warto również zauważyć, że Python używa tego samego instrumentu bazowego iddla wielu zmiennych o tej samej niezmiennej wartości:

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 ma garść niezmiennych typów, które powodują zachowanie, które widzisz. Dla wszystkich typów zmiennych twoje oczekiwania są poprawne.

kraina krańca
źródło
6

@ Odpowiedź Idana dobrze wyjaśnia, dlaczego Python nie traktuje zmiennej pętli jako wskaźnika tak jak w C, ale warto wyjaśnić bardziej szczegółowo, w jaki sposób rozpakowywane są fragmenty kodu, ponieważ w Pythonie wiele prostych pozornie bitów kodu będą w rzeczywistości wywołaniami metod wbudowanych . Weźmy swój pierwszy przykład

for i in a:
    i += 1

Są dwie rzeczy do rozpakowania: for _ in _:składnia i _ += _składnia. Aby najpierw wziąć pętlę for, podobnie jak inne języki, Python ma for-eachpętlę, która jest zasadniczo cukrem składniowym dla wzorca iteratora. W Pythonie iterator to obiekt, który definiuje .__next__(self)metodę, która zwraca bieżący element w sekwencji, przechodzi do następnego i podniesie wartość, StopIterationgdy nie będzie już więcej elementów w sekwencji. Iterowalny jest obiektem, który określa .__iter__(self), która zwraca iteracyjnej.

(Uwaga: an Iteratorjest również Iterablei zwraca się po swojej .__iter__(self)metodzie).

Python zwykle ma wbudowaną funkcję, która deleguje do niestandardowej metody podwójnego podkreślenia. Więc ma to, iter(o)co rozwiązuje, o.__iter__()a next(o)które rozwiązuje o.__next__(). Uwaga: te wbudowane funkcje często próbują zastosować rozsądną domyślną definicję, jeśli metoda, którą delegują, nie jest zdefiniowana. Na przykład len(o)zwykle rozwiązuje, o.__len__()ale jeśli ta metoda nie jest zdefiniowana, spróbuje iter(o).__len__().

A dla pętli jest zasadniczo definiowane next(), iter()i więcej podstawowych struktur sterowania. Ogólnie kod

for i in %EXPR%:
    %LOOP%

rozpakuje się do czegoś takiego

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

Więc w tym przypadku

for i in a:
    i += 1

zostaje rozpakowany do

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

Druga połowa to i += 1. Ogólnie %ASSIGN% += %EXPR%jest rozpakowywany do %ASSIGN% = %ASSIGN%.__iadd__(%EXPR%). Tutaj __iadd__(self, other)dodaje się w miejscu i zwraca się.

(Uwaga: Jest to kolejny przypadek, w którym Python wybierze alternatywę, jeśli główna metoda nie zostanie zdefiniowana. Jeśli obiekt nie zaimplementuje __iadd__, zacznie się opierać __add__. W rzeczywistości robi to w tym przypadku, gdy intnie implementuje __iadd__- co ma sens, ponieważ są niezmienne i dlatego nie można ich modyfikować).

Twój kod tutaj wygląda

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

gdzie możemy zdefiniować

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

W twoim drugim fragmencie kodu dzieje się coś więcej. Dwie nowe rzeczy, o których musimy wiedzieć, to: %ARG%[%KEY%] = %VALUE%rozpakowywanie (%ARG%).__setitem__(%KEY%, %VALUE%)i %ARG%[%KEY%]rozpakowywanie (%ARG%).__getitem__(%KEY%). Łącząc tę ​​wiedzę, a[ix] += 1rozpakowujemy się a.__setitem__(ix, a.__getitem__(ix).__add__(1))(ponownie: __add__zamiast __iadd__dlatego, że __iadd__nie jest zaimplementowana przez ints). Nasz końcowy kod wygląda następująco:

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

Aby odpowiedzieć na twoje pytanie, dlaczego pierwszy nie modyfikuje listy, a drugi tak, w naszym pierwszym fragmencie, iz next(_a_iter)którego otrzymujemy , co oznacza, iże będzie to int. Ponieważ intnie można modyfikować w miejscu, i += 1nic nie robi na liście. W naszym drugim przypadku ponownie nie modyfikujemy, intale modyfikujemy listę, dzwoniąc __setitem__.

Powodem tego całego skomplikowanego ćwiczenia jest to, że myślę, że uczy następującej lekcji o Pythonie:

  1. Ceną czytelności Pythona jest to, że cały czas nazywa te magiczne metody podwójnego wyniku.
  2. Dlatego, aby mieć szansę na prawdziwe zrozumienie dowolnego fragmentu kodu Pythona, musisz zrozumieć te tłumaczenia.

Metody podwójnego podkreślenia stanowią przeszkodę na początku, ale są niezbędne do wspierania reputacji Pythona w zakresie „uruchamialnego pseudokodu”. Przyzwoity programista w języku Python dokładnie zrozumie te metody i sposób ich wywoływania oraz zdefiniuje je tam, gdzie ma to sens.

Edycja : @deltab poprawił moje niechlujne użycie terminu „kolekcja”.

walpen
źródło
2
„iteratory to także kolekcje” nie do końca mają rację: są one również iterowalne, ale kolekcje również mają __len__i__contains__
deltab 30.01.2017
2

+=działa inaczej w zależności od tego, czy bieżąca wartość jest zmienna czy niezmienna . To był główny powód, dla którego implementacja w Pythonie długo trwa, ponieważ deweloperzy Pythona bali się, że będzie to mylące.

Jeśli ijest int, to nie można go zmienić, ponieważ ints są niezmienne, a zatem jeśli wartość izmian musi koniecznie wskazywać na inny obiekt:

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

Jeśli jednak lewa strona jest zmienna , + = może ją zmienić; na przykład jeśli jest to lista:

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

W twojej pętli for iodnosi się do każdego elementu z akolei. Jeśli są to liczby całkowite, zastosowanie ma pierwszy przypadek, a wynikiem tego i += 1musi być to, że odnosi się do innego obiektu liczb całkowitych. aOczywiście lista wciąż zawiera te same elementy, które zawsze miała.

RemcoGerlich
źródło
Nie rozumiem tego rozróżnienia między obiektami zmiennymi i niezmiennymi: jeśli i = 1ustawia isię na niezmienny obiekt liczb całkowitych, to i = []należy ustawić ina niezmienny obiekt listy. Innymi słowy, dlaczego obiekty całkowite są niezmienne, a obiekty listy można modyfikować? Nie widzę w tym żadnej logiki.
Giorgio
@Giorgio: obiekty pochodzą z różnych klas, listimplementuje metody zmieniające ich zawartość, intnie robi tego. [] jest zmiennym obiektem listy i i = []pozwala iodnosić się do tego obiektu.
RemcoGerlich
@Giorgio nie ma czegoś takiego jak niezmienna lista w Pythonie. Listy są zmienne. Liczby całkowite nie są. Jeśli chcesz coś w rodzaju listy, ale niezmienną, zastanów się nad krotką. Co do tego, dlaczego nie jest jasne, na jakim poziomie chciałbyś uzyskać odpowiedź.
jonrsharpe
@RemcoGerlich: Rozumiem, że różne klasy zachowują się inaczej, nie rozumiem, dlaczego zostały zaimplementowane w ten sposób, tj. Nie rozumiem logiki stojącej za tym wyborem. Zaimplementowałbym +=operator / metodę, aby zachowywały się podobnie (zasada najmniejszego zaskoczenia) dla obu typów: albo zmień oryginalny obiekt, albo zwróć zmodyfikowaną kopię dla liczb całkowitych i list.
Giorgio
1
@Giorgio: to absolutnie prawda, że +=jest to zaskakujące w Pythonie, ale wydawało się, że inne wspomniane opcje byłyby również zaskakujące lub co najmniej mniej praktyczne (zmiana oryginalnego obiektu nie może być wykonana przy użyciu najczęstszego rodzaju wartości używasz + = z, ints. A kopiowanie całej listy jest znacznie droższe niż jej mutowanie, Python nie kopiuje takich rzeczy jak listy i słowniki, chyba że jest to wyraźnie wskazane). To była wtedy ogromna debata.
RemcoGerlich
1

Pętla tutaj jest trochę nieistotna. Podobnie jak parametry funkcji lub argumenty, konfiguracja takiej pętli for jest w zasadzie tylko fantazyjnym przypisaniem.

Liczby całkowite są niezmienne. Jedynym sposobem na ich zmodyfikowanie jest utworzenie nowej liczby całkowitej i przypisanie jej do tej samej nazwy co oryginał.

Semantyka Pythona dla mapowania przypisań bezpośrednio na C (co nie dziwi, biorąc pod uwagę wskaźniki PyObject * CPython), z jedynym zastrzeżeniem, że wszystko jest wskaźnikiem i nie możesz mieć podwójnych wskaźników. Rozważ następujący kod:

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

Co się dzieje? Drukuje 1. Czemu? Jest to w przybliżeniu odpowiednik następującego kodu 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);

W kodzie C oczywiste jest, że wartość parametru nie aulega zmianie.

Jeśli chodzi o to, dlaczego listy wydają się działać, odpowiedź jest po prostu taka, że ​​przypisujesz to samo nazwisko. Listy są zmienne. Tożsamość nazwanego obiektu a[0]ulegnie zmianie, ale a[0]nadal jest prawidłową nazwą. Możesz to sprawdzić za pomocą następującego kodu:

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

Ale to nie jest specjalne dla list. Zamień a[0]w tym kodzie na, ya otrzymasz dokładnie ten sam wynik.

Kevin
źródło