Чому Python робить лише копію окремого елемента під час ітерації списку?


31

Я просто зрозумів, що в Python, якщо хто пише

for i in a:
    i += 1

Елементи оригінального списку aнасправді не впливатимуть, оскільки змінна iвиявляється просто копією оригінального елемента в a.

Щоб змінити оригінальний елемент,

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

знадобиться.

Я був дуже здивований такою поведінкою. Це, здається, дуже контрінтуїтивне, здавалося б, відрізняється від інших мов і призвело до помилок у моєму коді, які мені довелося довго налагоджувати сьогодні.

Я читав підручник з Python раніше. Напевне, я перевірив книгу ще раз, і вона взагалі навіть не згадує про таку поведінку.

Які міркування за цією конструкцією? Чи очікується це стандартна практика для багатьох мов, щоб навчальний посібник вважав, що читачі повинні зрозуміти це природним шляхом? У яких інших мовах є така ж поведінка щодо ітерації, на яку я повинен звернути увагу в майбутньому?


19
Це справедливо лише в тому випадку, якщо iвін непорушний або ви проводите немутуючі операції. З вкладеним списком for i in a: a.append(1)матиме різну поведінку; Python не копіює вкладені списки. Однак цілі числа незмінні і додавання повертає новий об'єкт, він не змінює старого.
jonrsharpe

10
Це зовсім не дивно. Я не можу придумати мову, яка зовсім не однакова для масиву основних типів, таких як цілі числа. Наприклад, спробуйте в JavaScript a=[1,2,3];a.forEach(i => i+=1);alert(a). Те саме в C #
edc65

7
Чи очікуєте ви i = i + 1вплинути a?
дельтаб

7
Зауважте, що така поведінка не відрізняється від інших мов. C, Javascript, Java тощо поводяться таким чином.
slebetman

1
@jonrsharpe для списків "+ =" змінює старий список, тоді як "+" створює новий
Василь Алексєєв

Відповіді:


68

Я вже відповів на подібне питання останнім часом, і дуже важливо усвідомити, що +=може мати різні значення:

  • Якщо тип даних реалізує додавання на місці (тобто має правильно працюючу __iadd__функцію), то дані, на які iпосилається, оновлюються (не має значення, чи є вони у списку чи десь ще).

  • Якщо тип даних не реалізує __iadd__метод,i += x оператор є просто синтаксичним цукром для i = i + x, тому нове значення створюється та присвоюється імені змінної i.

  • Якщо тип даних реалізується __iadd__ але він робить щось дивне. Можливо, воно оновлено ... чи ні - це залежить від того, що там реалізовано.

Цілі числа, поплавці, рядки пітонів не реалізуються, __iadd__тому вони не будуть оновлені на місці. Однак інші типи даних, як-от numpy.arrayабо їх listреалізують, будуть вести себе так, як ви очікували. Отже, справа не в копії або без копіювання при ітерації (зазвичай це не робить копії для lists і tuples - але це також залежить від реалізації контейнерів __iter__і __getitem__методу!) - це більше питання типу даних ви зберігаєте у своєму a.


2
Це правильне пояснення поведінки, описаної у питанні.
пабук

19

Уточнення - термінологія

Python не розрізняє поняття відліку та вказівника . Зазвичай вони просто використовують термін посилання , але якщо порівнювати з мовами на зразок C ++, які мають таке розрізнення - це набагато ближче до вказівника .

Оскільки запитувач явно походить від C ++, а оскільки цього розрізнення, необхідного для пояснення , не існує в Python, я вибрав термінологію C ++, яка є:

  • Значення : фактичні дані, що сидять у пам'яті. void foo(int x);є підписом функції, яка отримує ціле число за значенням .
  • Вказівник : адреса пам'яті, яка розглядається як значення. Можна відкласти для доступу до пам'яті, на яку він вказує. void foo(int* x);є підписом функції, яка отримує ціле число за вказівником .
  • Довідка : Цукор навколо вказівників. За лаштунками є вказівник, але ви можете отримати доступ лише до відкладеного значення і не можете змінити адресу, на яку він вказує. void foo(int& x);є підписом функції, яка отримує ціле число за посиланням .

Що ви маєте на увазі "відрізняється від інших мов"? Більшість мов, з яких я знаю, що підтримка кожного циклу копіює елемент, якщо інше не вказано інше.

Спеціально для Python (хоча багато з цих причин можуть стосуватися інших мов із подібними архітектурними чи філософськими поняттями):

  1. Така поведінка може спричинити помилки у людей, які цього не знають, але альтернативна поведінка може спричинити помилки навіть у тих, хто про це знає . Призначаючи змінну ( i), ви зазвичай не зупиняєтесь і розглядаєте всі інші змінні, які були б змінені через неї ( a). Обмеження обсягу, над яким ви працюєте, є головним фактором у запобіганні коду спагетті, і тому ітерація копією зазвичай є типовою для мов, які підтримують ітерацію за допомогою посилання.

  2. Змінні Python - це завжди один покажчик, тому копіювати його копією дешевше - дешевше, ніж повторення за посиланням, що вимагатиме додаткового відстрочки кожного разу, коли ви отримуєте доступ до значення.

  3. У Python немає поняття референтних змінних, наприклад - C ++. Тобто, всі змінні в Python насправді є посиланнями, але в тому сенсі, що вони є покажчиками, а не закулісними посиланнями на констати, як type& nameаргументи C ++ . Оскільки цієї концепції в Python не існує, реалізуючи ітерацію за посиланням - не кажучи вже про те, щоб зробити її за замовчуванням! - зажадає додавання більшої складності до байт-коду.

  4. forЗаява Python працює не тільки на масивах, але і на більш загальній концепції генераторів. За лаштунками Python закликає iterваші масиви отримати об’єкт, який - коли ви дзвоните nextна нього - або повертає наступний елемент, або raisesa StopIteration. Існує кілька способів реалізації генераторів в Python, і реалізувати їх для ітерації шляхом посилання було б набагато складніше.


Дякую за відповідь. Здається, моє розуміння ітераторів все ще недостатньо тверде. Чи не є ітераторами посилання C ++ за замовчуванням? Якщо ви обережуєте ітератор, ви завжди можете негайно змінити значення елемента оригінального контейнера?
xji

4
Python робить ітерацію за посиланням (ну, за значенням, але значення є посиланням). Спробуйте це зі списком об'єктів, що змінюються, швидко продемонструє, що копіювання не відбувається.
jonrsharpe

Ітератори в C ++ - це фактично об'єкти, які можна відкласти для доступу до значення в масиві. Щоб змінити оригінальний елемент, ви використовуєте *it = ...- але такий синтаксис вже вказує на те, що ви щось змінюєте десь в іншому місці - що робить причину №1 меншою проблемою. Причини №2 і №3 також не застосовуються, оскільки в C ++ копіювання коштує дорого і існує поняття референсних змінних. Що стосується причини №4 - можливість повернути посилання дозволяє просту реалізацію для всіх випадків.
Ідан Ар’є

1
@jonrsharpe Так, це називається посиланням, але в будь-якій мові, яка має відмінність між покажчиками та посиланнями, така ітерація буде ітерацією вказівника (а оскільки покажчики - це значення - ітерація за значенням). Я додам уточнення.
Ідан Ар'є

20
Ваш перший абзац говорить про те, що Python, як і інші мови, копіює елемент у циклі for. Це не так. Це не обмежує коло змін, які ви вносите до цього елемента. ОП бачить таку поведінку лише тому, що їх елементи незмінні; навіть не згадуючи про цю відмінність, ваша відповідь у кращому випадку неповна і в гіршому випадку хибна.
jonrsharpe

11

Жодна з відповідей тут не дає вам жодного коду для роботи, щоб дійсно проілюструвати, чому це відбувається на землі Python. І це цікаво подивитися на більш глибокий підхід, так що тут ідеться.

Основна причина, що це не працює так, як ви очікували, полягає в тому, що в Python, коли ви пишете:

i += 1

це не те, що ти думаєш, що робить. Цілі особи незмінні. Це можна побачити, коли ви подивитеся, що об’єкт насправді є в Python:

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

Функція id представляє унікальне і постійне значення об'єкта протягом його життя. Концептуально він вільно відображає адресу пам'яті в C / C ++. Запуск вищевказаного коду:

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

Це означає, що перше aвже не те саме, що другеa , оскільки їхні ідентифікатори різні. Ефективно вони знаходяться в різних місцях пам'яті.

Однак з об'єктом все працює інакше. Я тут перезаписав +=оператора:

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

Запуск цього результату має такий результат:

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

Зауважте, що атрибут id у цьому випадку насправді однаковий для обох ітерацій, навіть якщо значення об'єкта є різним (ви також можете знайти idзначення int, яке має об'єкт, яке змінюватиметься, оскільки воно мутує - бо цілі числа незмінні).

Порівняйте це з тим, коли ви виконуєте ту саму вправу з незмінним об'єктом:

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

Це виводи:

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

Тут можна помітити кілька речей. По-перше, у циклі з символом +=ви більше не додаєте до початкового об'єкта. У цьому випадку, оскільки int є одними з незмінних типів у Python , python використовує інший ідентифікатор. Також цікаво відзначити, що Python використовує один і той самий підгрунт idдля декількох змінних з тим же незмінним значенням:

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 має кілька незмінних типів, які викликають поведінку, яку ви бачите. Для всіх типів, що змінюються, ваше сподівання правильне.


6

@ Відповідь Ідана добре допомагає пояснити, чому Python не трактує змінну циклу як вказівник так, як ви могли б в C, але варто детальніше пояснити, як розпаковується фрагмент коду, як у Python безліч простих начебто бітів коду - це фактично дзвінки до вбудованих методів . Щоб взяти ваш перший приклад

for i in a:
    i += 1

Розпакувати дві речі: for _ in _:синтаксис та _ += _синтаксис. Щоб спочатку взяти цикл for, як і інші мови, Python має for-eachцикл, який по суті є синтаксичним цукром для ітераторного шаблону. У Python ітератор - це об'єкт, який визначає .__next__(self)метод, який повертає поточний елемент у послідовності, переходить до наступного та підніме a, StopIterationколи в послідовності більше елементів немає. Iterable це об'єкт , який визначає .__iter__(self)метод , який повертає ітератор.

(Примітка: an Iteratorтакож є Iterableі повертається із свого .__iter__(self)методу.)

Python, як правило, має вбудовану функцію, яка делегує спеціальний метод подвійного підкреслення. Отже, воно має, iter(o)що вирішує o.__iter__()і next(o)те, що вирішує o.__next__(). Зауважте, що ці вбудовані функції часто спробують прийнятне визначення за замовчуванням, якщо метод, який вони делегують, не визначений. Наприклад, len(o)зазвичай вирішується, o.__len__()але якщо цей метод не визначений, він спробує iter(o).__len__().

Для циклу, по суті , визначається в термінах next(), iter()і більше основних структур управління. Загалом код

for i in %EXPR%:
    %LOOP%

розпакується на щось подібне

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

Так у цьому випадку

for i in a:
    i += 1

розпаковується на

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

Інша половина цього є i += 1. Взагалі %ASSIGN% += %EXPR%розпаковується %ASSIGN% = %ASSIGN%.__iadd__(%EXPR%). Тут __iadd__(self, other)відбувається додавання і повертається.

(Примітка. Це ще один випадок, коли Python обрав альтернативу, якщо основний метод не визначений. Якщо об'єкт не реалізує, __iadd__він знову потрапить __add__. Він насправді робить це в цьому випадку як intне реалізованим __iadd__- що має сенс, оскільки вони вони незмінні, тому їх не можна змінити на місці.)

Отже, ваш код тут виглядає

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

де ми можемо визначитись

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

У вашому другому біті коду відбувається трохи більше. Дві нові речі, які ми повинні знати, - це те, що їх %ARG%[%KEY%] = %VALUE%розпаковують (%ARG%).__setitem__(%KEY%, %VALUE%)і %ARG%[%KEY%]розпаковують (%ARG%).__getitem__(%KEY%). Збираючи ці знання разом, ми отримуємо a[ix] += 1розпаковане a.__setitem__(ix, a.__getitem__(ix).__add__(1))(знову: __add__а не __iadd__тому __iadd__, що не реалізовано ints). Наш заключний код виглядає так:

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

Насправді відповісти на ваше запитання, чому перший один не змінює список , а другий робить в нашому першому фрагменті коду ми отримуємо iвід next(_a_iter), а це значить , iбуде int. Оскільки int's не може бути змінено на місці, i += 1нічого не вносить до списку. У другому випадку ми знову не змінюємо, intале змінюємо список, зателефонувавши __setitem__.

Причина всієї цієї складної вправи полягає в тому, що я думаю, що вона вчить наступному уроку про Python:

  1. Ціна читабельності Python полягає в тому, що він постійно називає ці магічні методи подвійної оцінки.
  2. Тому, щоб мати можливість по-справжньому зрозуміти будь-який фрагмент коду Python, ви повинні зрозуміти, що це робить.

Методи подвійного підкреслення є перешкодою при запуску, але вони мають важливе значення для підтримки репутації псевдокоду Python ", що можна керувати". Гідний програміст Python буде глибоко розуміти ці методи та спосіб їх застосування та визначатиме їх, де це має сенс робити.

Редагувати : @deltab виправив моє неохайне використання терміна "колекція".


2
"Ітератори також колекції" не зовсім правильно: вони теж ітерабельні, але колекції також є __len__і__contains__
deltab

2

+=працює по-різному, залежно від того, чи є поточне значення змінним чи незмінним . Це було основною причиною того, що довго виглядало його впровадження в Python, оскільки розробники Python боялися, що це буде заплутаним.

Якщо iце int, то його неможливо змінити, оскільки ints незмінні, і, якщо значення iзмінюється, воно обов'язково повинно вказувати на інший об'єкт:

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

Однак якщо ліва частина є змінною , то + = може насправді змінити її; як, якщо це список:

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

У вашому циклі iпосилається на кожен елемент aпо черзі. Якщо це цілі числа, тоді застосовується перший випадок, і результат i += 1повинен бути таким, що він посилається на інший цілий об'єкт. У переліку, aзвичайно, є ті ж елементи, що й у минулому.


Я не розумію цього розрізнення між змінними та незмінними об'єктами: якщо i = 1встановлено iнезмінний цілий об'єкт, то i = []слід встановити iоб'єкт незмінного списку. Іншими словами, чому цілі об'єкти є незмінними і перераховують об'єкти, що змінюються? Я не бачу жодної логіки за цим.
Джорджіо

@Giorgio: об'єкти є з різних класів, listреалізує методи, що змінюють його вміст, intні. [] є змінним об'єктом списку, і i = []дозволяє iзвертатися до цього об'єкта.
RemcoGerlich

@Giorgio у Python немає такого поняття, як незмінний список. Списки змінні. Цілі особи - ні. Якщо ви хочете щось подібне до списку, але незмінне, розгляньте кортеж. Щодо того, не зрозуміло, на якому рівні ви б хотіли, щоб на це відповіли.
jonrsharpe

@RemcoGerlich: Я розумію, що різні класи поводяться по-різному, я не розумію, чому вони були реалізовані таким чином, тобто я не розумію логіки цього вибору. Я би реалізував +=оператор / метод, щоб поводитись однаково (принцип найменшого здивування) для обох типів: або змінити оригінальний об'єкт, або повернути змінену копію для цілих чисел та списків.
Джорджіо

1
@Giorgio: це абсолютно правда, що +=в Python дивно, але відчувалося, що інші варіанти, про які ти згадуєш, також були б дивовижними або, принаймні, менш практичними (зміна оригінального об'єкта неможливо здійснити з найпоширенішим типом значення ви використовуєте + = with, ints. І копіювання цілого списку набагато дорожче, ніж його мутація, Python не копіює речі, такі як списки та словники, якщо прямо не вказано). Тоді це була величезна дискусія.
RemcoGerlich

1

Цикл тут - це неактуально. Настільки ж, як параметри функцій або аргументи, налаштування подібного циклу - це по суті лише фантазійне призначення.

Цілі особи незмінні. Єдиний спосіб змінити їх - це створити нове ціле число та призначити його тим же назвою, що і оригіналу.

Семантика Python для призначення карт безпосередньо на C (не дивно, що дані Pyybject * покажчиків CPython), єдині застереження - це те, що все є вказівником, і вам не дозволяється мати подвійні покажчики. Розглянемо наступний код:

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

Що сталося? Це друкує 1. Чому? Це насправді приблизно еквівалентно наступному коду С:

i64* a = malloc(sizeof(i64));
*a = 1;
i64* b = a;
i64* tmp = malloc(sizeof(i64));
tmp = *b + 1;
b = tmp;
printf("%d\n", *a);

У коді С очевидно, що на значення aповністю не впливає.

Що стосується того, чому списки, здається, працюють, відповідь в основному полягає лише в тому, що ви присвоюєте те саме ім’я. Списки змінні. Ідентифікація названого об'єкта a[0]зміниться, але a[0]все ще є дійсною назвою. Ви можете перевірити це за допомогою наступного коду:

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

Але це не особливо для списків. Замініть a[0]цей код на yі отримаєте точно такий же результат.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.