Разница между += и .append для списков в кортежах
Пытаюсь понять различия между += и методом .append() для списка в кортеже. Данный вопрос задаётся ради того, чтобы разобраться во "внутренней кухне", а не найти практическое применение. Пример кода:
def add_func():
a = (1, 2, [1, 2])
a[-1] += [3] # в список значение добавится, но потом падает ошибка
И вторая версия:
def append_func():
a = (1, 2, [1, 2])
a[-1].append(3) # в данном случае всё корректно отработает
Посмотрев байт-код, я нашёл разницу в некоторых строчках:
print(dis.dis(add_func))
print('--------------')
print(dis.dis(append_func))
4 0 LOAD_CONST 1 (1)
2 LOAD_CONST 2 (2)
4 LOAD_CONST 1 (1)
6 LOAD_CONST 2 (2)
8 BUILD_LIST 2
10 BUILD_TUPLE 3
12 STORE_FAST 0 (a)
5 14 LOAD_FAST 0 (a)
16 LOAD_CONST 3 (-1)
18 DUP_TOP_TWO
20 BINARY_SUBSCR
22 LOAD_CONST 4 (3)
24 BUILD_LIST 1
26 INPLACE_ADD # Кажется, это "отголоски" __iadd__
28 ROT_THREE
30 STORE_SUBSCR
32 LOAD_CONST 0 (None)
34 RETURN_VALUE
--------------
8 0 LOAD_CONST 1 (1)
2 LOAD_CONST 2 (2)
4 LOAD_CONST 1 (1)
6 LOAD_CONST 2 (2)
8 BUILD_LIST 2
10 BUILD_TUPLE 3
12 STORE_FAST 0 (a)
9 14 LOAD_FAST 0 (a)
16 LOAD_CONST 3 (-1)
18 BINARY_SUBSCR
20 LOAD_METHOD 0 (append)
22 LOAD_CONST 4 (3)
24 CALL_METHOD 1
26 POP_TOP
28 LOAD_CONST 0 (None)
30 RETURN_VALUE
Разница в байт-коде позволила мне подумать о том, что я разобрался в данном вопросе, но после этого я решил почитать SO.
Цитата из данного вопроса:
+= is an assignment. When you use it you're really saying ‘some_list2= some_list2+['something']’. Assignments involve rebinding, so:
При этом про __iadd__ вот тут пишут:
From an API perspective, iadd is supposed to be used for modifying mutable objects in place (returning the object which was mutated) whereas add should return a new instance of something.
Также цитата из книги Л.Рамальо "Python – к вершинам мастерства":
Составное присваивание (операторы +=, *= и т. п.) создает новый объект, если переменная в левой части связана с неизменяемым объектом, а из- меняемый объект может быть модифицирован на месте.
Признаться, я запутался в этом и вижу в этом много противоречий. Если через += объект изменяется "на месте", почему падает ошибка?
P.S. Также не совсем понял строчку про STORE_FAST в байткоде из документации:
Stores STACK.pop() into the local co_varnames[var_num].
Ответы (2 шт):
Рассмотрим байт-код операции s[a] += b:
>>> dis.dis('s[a] += b')
1 0 LOAD_NAME 0 (s)
2 LOAD_NAME 1 (a)
4 DUP_TOP_TWO
6 BINARY_SUBSCR # (1)
8 LOAD_NAME 2 (b)
10 INPLACE_ADD # (2)
12 ROT_THREE
14 STORE_SUBSCR # (3)
16 LOAD_CONST 0 (None)
18 RETURN_VALUE
- поместить значение
s[a]на вершину стека; - выполнить присваивание на месте (inplace add,
+=)bк объекту на вершине списка (s[a]) - эта операция выполнится успешно, т. к.s[a]ссылается на изменяемый объект; - выполнить присваивание объекта на вершине стека (изменённый
s[a]) вs[a]- эта операция завершится ошибкой, т. к.s- неизменяемый объект (кортеж).
В то же время, во втором примере:
>>> dis.dis('s[a].append(b)')
1 0 LOAD_NAME 0 (s)
2 LOAD_NAME 1 (a)
4 BINARY_SUBSCR
6 LOAD_METHOD 2 (append)
8 LOAD_NAME 3 (b)
10 CALL_METHOD 1
12 RETURN_VALUE
никого присваивания нет, просто вызывается метод одного из элементов кортежа, и это проходит без ошибок - ведь кортеж хранит только ссылки на объекты, а ссылки при изменении объекта не изменяются. В первом же примере была попытка сохранить новую ссылку вместо старой, и это приводило к ошибке.
Это дефект языка. Конструкция x += y в Питоне обрабатывается единообразно следующим образом:
# x += y
z = x.__iadd__(y)
x = z
Первым шагом мы обращаемся к объекту x и просим его что-то сделать с объектом y. В результате этого вызова возвращается какой-то объект z, который в итоге записывается в x.
Сравним реализации __iadd__ для списка и для кортежа.
def __iadd__list(self, y): # для списка
self.extend(y)
return self
def __iadd__tuple(self, y): # для кортежа
z = create_new_tuple_from(self, y)
return z
В первом случае список модифицирует сам себя и себя же возвращает. Во втором случае так не получится - кортежи неизменны. Лучшее что мы можем сделать, скопировать содержимое self и y в новый кортеж и вернуть его. Операция для списка имеет лучшую сложность - амортизированную константу. Операция для кортежа имеет линейную сложность. Разница в скорости оправдывает разные реализации.
При трансляции кода x += y компилятор Питона обязан создать такой код, который правильно обработает и список и кортеж. Для кортежа присвоение x = z обязательно, иначе результат операции не дойдёт до программиста. Для списка это присвоение безвредно. Почти безвредно.
Что если слева от += стоит конструкция, которую менять нельзя, например a[-1], где a кортеж? Тогда при исполнении программы будет вызван метод __setitem__ кортежа. А он всегда завершается ошибкой - менять кортежи нельзя.
Желание авторов языка сделать использование списков и кортежей полиморфным привело к такой ситуации. Которую, кстати, нельзя исправить не изменив язык.
Можно было бы улучшить поведение компилятора.
Первая идея: разрешить кортежам присваивание, если это самоприсваивание. Ведь в случае списка в операторе x = z на самом деле написано x = x. В методе __setitem__ кортежа можно было бы отловить самоприсваивание и вернуть управление без ошибки.
Вторая идея: проверить что конструкция слева от += допускает присваивание до того как вычислять правую часть. Это потребует изменить язык, но, наверно, это изменение можно сделать обратно совместимым.
Третья идея: не вызывать присваивание, если оно не нужно. Например так:
# x += y
z = x.__iadd__(y)
if x is not z:
x = z
Я думаю, что никто не будет править этот небольшой дефект. Это даже не дефект, а повод подумать как много мы платим вперед чтобы код работал мало зная про типы значений, которые он обрабатывает.