Почему Python делает копию отдельного элемента только при итерации списка?

31

Я только что понял, что в Python, если кто-то пишет

for i in a:
    i += 1

Элементы исходного списка aфактически не будут затронуты вообще, поскольку переменная iоказывается просто копией исходного элемента в a.

Для того, чтобы изменить оригинальный элемент,

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

будет необходимо.

Я был очень удивлен таким поведением. Это кажется очень нелогичным, по-видимому, отличается от других языков и привело к ошибкам в моем коде, которые мне пришлось долго отлаживать сегодня.

Я читал Учебник по Python раньше. Просто чтобы быть уверенным, я только что проверил книгу только сейчас, и она даже не упоминает об этом поведении вообще.

В чем причина этого дизайна? Ожидается ли, что это будет стандартная практика на многих языках, так что учебник считает, что читатели должны понимать это естественно? На каких других языках присутствует такое же поведение на итерации, на что я должен обратить внимание в будущем?

xji
источник
19
Это верно только в том случае, если оно iявляется неизменным или вы выполняете операцию без мутаций. С вложенным списком for i in a: a.append(1)было бы другое поведение; Python не копирует вложенные списки. Однако целые числа неизменны, и сложение возвращает новый объект, но не меняет старый.
Джоншарпе
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__но он делает что-то странное. Вполне возможно, что он обновляется ... или нет - это зависит от того, что там реализовано.

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

MSeifert
источник
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 на самом деле являются ссылками, но в том смысле, что они являются указателями, а не закулисными ссылками на constat, такими как type& nameаргументы C ++ . Так как эта концепция не существует в Python, реализация итерации по ссылке - не говоря уже о том, чтобы сделать ее по умолчанию! - потребует добавления большей сложности к байт-коду.

  4. forЗаявление Python работает не только над массивами, но и над более общей концепцией генераторов. За кулисами Python вызывает iterваши массивы, чтобы получить объект, который - когда вы вызываете nextего - либо возвращает следующий элемент, либо raisesa StopIteration. Есть несколько способов реализовать генераторы в Python, и было бы намного сложнее реализовать их для итерации по ссылке.

Идан Арье
источник
Спасибо за ответ. Кажется, что мое понимание итераторов еще недостаточно. Разве итераторы в C ++ не являются ссылками по умолчанию? Если вы разыменовываете итератор, вы всегда можете сразу изменить значение элемента исходного контейнера?
xji
4
Python выполняет итерацию по ссылке (ну, по значению, но значение является ссылкой). Попытка сделать это со списком изменяемых объектов быстро продемонстрирует, что копирование не происходит.
Джоншарпе
Итераторы в C ++ на самом деле являются объектами, которые могут быть отложены для доступа к значению в массиве. Чтобы изменить исходный элемент, вы используете *it = ...- но этот вид синтаксиса уже указывает, что вы изменяете что-то где-то еще - что делает причину № 1 меньшей проблемой. Причины № 2 и № 3 также не применяются, потому что в С ++ копирование обходится дорого, и существует понятие ссылочных переменных. Что касается причины № 4 - возможность возврата ссылки позволяет простую реализацию для всех случаев.
Идан Арье
1
@jonrsharpe Да, он вызывается по ссылке, но на любом языке, в котором есть различие между указателями и ссылками, этот вид итерации будет итерацией по указателю (а поскольку указатели являются значениями - итерация по значению). Я добавлю уточнение.
Идан Арье
20
Ваш первый абзац предполагает, что Python, как и другие языки, копирует элемент в цикл for. Это не так. Это не ограничивает объем изменений, которые вы вносите в этот элемент. ОП видит это только потому, что их элементы неизменны; даже не упоминая это различие, ваш ответ в лучшем случае неполный, а в худшем - вводит в заблуждение.
Джоншарпе
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

Несколько вещей здесь, чтобы заметить. Во-первых, в цикле с +=, вы больше не добавляете к исходному объекту. В этом случае, поскольку целые числа входят в число неизменяемых типов в 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 имеет несколько неизменяемых типов, которые вызывают поведение, которое вы видите. Для всех изменчивых типов ваше ожидание верное.

enderland
источник
6

Ответ @ Idan хорошо объясняет, почему Python не обрабатывает переменную цикла как указатель так, как вы это делаете в C, но стоит более подробно объяснить, как распаковываются фрагменты кода, поскольку в Python много простых на первый взгляд битов. кода на самом деле будут вызовы встроенных методов . Взять ваш первый пример

for i in a:
    i += 1

Необходимо распаковать две вещи: for _ in _:синтаксис и _ += _синтаксис. Прежде всего, для цикла for, как и в других языках, в Python есть for-eachцикл, который по сути является синтаксическим сахаром для шаблона итератора. В Python итератор - это объект, который определяет .__next__(self)метод, который возвращает текущий элемент в последовательности, переходит к следующему и вызывает значение, StopIterationкогда в последовательности больше нет элементов. Iterable это объект , который определяет .__iter__(self)метод , который возвращает итератор.

(Примечание: an Iteratorтакже является an 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)делает на месте сложение и возвращает себя.

(NB. Это еще один случай, когда 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__не реализуются целыми числами). Наш окончательный код выглядит так:

_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как нельзя изменить на месте, i += 1ничего не делает в списке. Во втором случае мы снова не модифицируем, intа модифицируем список, вызывая __setitem__.

Причина всего этого сложного упражнения в том, что я думаю, что он преподает следующий урок о Python:

  1. Цена читабельности Python заключается в том, что он постоянно вызывает эти магические методы с двойным счетом.
  2. Следовательно, чтобы иметь возможность по-настоящему понять любой фрагмент кода Python, вы должны понимать эти переводы, которые он выполняет.

Методы двойного подчеркивания являются препятствием при запуске, но они необходимы для поддержки репутации Python "runnable pseudocode". Приличный программист на Python хорошо разберется в этих методах и способах их вызова и определит их везде, где это имеет смысл.

Редактировать : @deltab исправил мое небрежное использование термина «коллекция».

walpen
источник
2
«итераторы также являются коллекциями» не совсем верно: они также итеративны, но коллекции также имеют __len__и__contains__
deltab
2

+=работает по-разному в зависимости от того, является ли текущее значение изменчивым или неизменным . Это было главной причиной того, что его реализация в Python выглядит долгое время, так как разработчики Python боялись, что это будет сбивать с толку.

Если iint, то его нельзя изменить, так как int неизменны, и, следовательно, если значение 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!

В вашем цикле for, iотносится к каждому элементу по aочереди. Если это целые числа, то применяется первый случай, и результатом i += 1должно быть то, что он ссылается на другой целочисленный объект. Список, aконечно, по-прежнему имеет те же элементы, что и всегда.

RemcoGerlich
источник
Я не понимаю этого различия между изменяемыми и неизменяемыми объектами: если i = 1устанавливается iнеизменяемый целочисленный объект, то i = []следует устанавливать iнеизменяемый объект списка. Другими словами, почему целочисленные объекты неизменяемы и объекты списков изменчивы? Я не вижу никакой логики за этим.
Джорджио
@ Джорджио: объекты из разных классов, listреализует методы, которые изменяют его содержимое, intнет. [] является объектом изменяемого списка, и i = []позволяет iссылаться на этот объект.
RemcoGerlich
@ Джорджио нет такого понятия, как неизменный список в Python. Списки изменчивы. Целые числа нет. Если вы хотите что-то вроде списка, но неизменяемого, рассмотрите кортеж. Что касается того, почему, не ясно, на каком уровне вы хотели бы ответить.
Джоншарпе
@RemcoGerlich: я понимаю, что разные классы ведут себя по-разному, я не понимаю, почему они были реализованы таким образом, т.е. я не понимаю логику этого выбора. Я бы реализовал +=оператор / метод, чтобы вести себя одинаково (принцип наименьшего удивления) для обоих типов: либо изменить исходный объект, либо вернуть измененную копию как для целых чисел, так и для списков.
Джорджио
1
@ Джорджио: это абсолютно верно, что +=удивительно в Python, но чувствовалось, что другие упомянутые вами варианты также были бы удивительными или, по крайней мере, менее практичными (изменение исходного объекта не может быть выполнено с использованием наиболее распространенного типа значения). Вы используете + = с, ints. И копирование всего списка намного дороже, чем его изменение, Python не копирует такие вещи, как списки и словари, если это явно не указано). Тогда это была огромная дискуссия.
RemcoGerlich
1

Цикл здесь не имеет значения. Подобно параметрам или аргументам функции, установка подобного цикла по сути является просто необычным назначением.

Целые числа неизменны. Единственный способ изменить их - создать новое целое число и присвоить ему то же имя, что и у оригинала.

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

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

Что случилось? Это печатает 1. Зачем? На самом деле это примерно эквивалентно следующему коду 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);

В коде C очевидно, что значение 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и вы получите точно такой же результат.

Kevin
источник