399.19
Rating
ДомКлик
Место силы
10 June

Почему список в кортеже ведет себя странно в Python?

ДомКлик corporate blogPythonProgramming
🔥 Technotext 2020
В языках программирования меня всегда интересовало их внутреннее устройство. Как работает тот или иной оператор? Почему лучше писать так, а не иначе? Подобные вопросы не всегда помогают решить задачу «здесь и сейчас», но в долгосрочной перспективе формируют общую картину языка программирования. Сегодня я хочу поделиться результатом одного из таких погружений и ответить на вопрос, что происходит при модификации tuple'а в list'е.

Все мы знаем, что в Python есть тип данных list:

a = []
a.append(2)

list — это просто массив. Он позволяет добавлять, удалять и изменять элементы. Также он поддерживает много разных интересных операторов. Например, оператор += для добавления элементов в list. += меняет текущий список, а не создает новый. Это хорошо видно тут:

>>> a = [1,2]
>>> id(a)
4543025032
>>> a += [3,4]
>>> id(a)
4543025032

В Python есть еще один замечательный тип данных: tuple — неизменяемая коллекция. Она не позволяет добавлять, удалять или менять элементы:

>>> a = (1,2)
>>> a[1] = 3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

При использовании оператора += создается новый tuple:

>>> a = (1,2)
>>> id(a)
4536192840
>>> a += (3,4)
>>> id(a)
4542883144

Внимание, вопрос: что сделает следующий код?

a = (1,2,[3,4])
a[2] += [4,5]

Варианты:

  1. Добавятся элементы в список.
  2. Вылетит исключение о неизменяемости tuple.
  3. И то, и другое.
  4. Ни то, ни другое.

Запишите свой ответ на бумажке и давайте сделаем небольшую проверку:

>>> a = (1,2,[3,4])
>>> a[2] += [4,5]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

Ну что же! Вот мы и разобрались! Правильный ответ — 2. Хотя, подождите минутку:

>>> a
(1, 2, [3, 4, 4, 5])

На самом деле правильный ответ — 3. То есть и элементы добавились, и исключение вылетело — wat?!


Давайте разберемся, почему так происходит. И поможет нам в этом замечательный модуль dis:

import dis

def foo():
    a = (1,2,[3,4])
    a[2] += [4,5]

dis.dis(foo)
  2     0 LOAD_CONST      1 (1)
        3 LOAD_CONST      2 (2)
        6 LOAD_CONST      3 (3)
        9 LOAD_CONST      4 (4)
       12 BUILD_LIST      2
       15 BUILD_TUPLE     3
       18 STORE_FAST      0 (a)

  3    21 LOAD_FAST       0 (a)
       24 LOAD_CONST      2 (2)
       27 DUP_TOP_TWO
       28 BINARY_SUBSCR
       29 LOAD_CONST      4 (4)
       32 LOAD_CONST      5 (5)
       35 BUILD_LIST      2
       38 INPLACE_ADD
       39 ROT_THREE
       40 STORE_SUBSCR
       41 LOAD_CONST      0 (None)
       44 RETURN_VALUE

Первый блок отвечает за построение tuple'а и его сохранение в переменной a. Дальше начинается самое интересное:

       21 LOAD_FAST       0 (a)
       24 LOAD_CONST      2 (2)

Загружаем в стек указатель на переменную a и константу 2.

       27 DUP_TOP_TWO

Дублируем их и кладем в стек в том же порядке.

       28 BINARY_SUBSCR

Этот оператор берет верхний элемент стека (TOS) и следующий за ним (TOS1). И записывает на вершину стека новый элемент TOS = TOS1[TOS]. Так мы убираем из стека два верхних значения и кладем в него ссылку на второй элемент tuple'а (наш массив).

       29 LOAD_CONST      4 (4)
       32 LOAD_CONST      5 (5)
       35 BUILD_LIST      2

Строим список из элементов 4 и 5 и кладем его на вершину стека:

       38 INPLACE_ADD

Применяем += к двум верхним элементам стека (Важно! Это два списка! Один состоит из 4 и 5, а другой взяты из tuple). Тут всё нормально, инструкция выполняется без ошибок. Поскольку += изменяет оригинальный список, то список в tuple'е уже поменялся (именно в этот момент).

       39 ROT_THREE
       40 STORE_SUBSCR

Тут мы меняем местами три верхних элемента стека (там живет tuple, в нём индекс массива и новый массив) и записываем новый массив в tuple по индексу. Тут-то и происходит исключение!

Ну что же, вот и разобрались! На самом деле список менять можно, а падает всё на операторе =.

Давайте напоследок разберемся, как переписать этот код без исключений. Как мы уже поняли, надо просто убрать запись в tuple. Вот парочка вариантов:

>>> a = (1,2,[3,4])
>>> b = a[2]
>>> b += [4,5]
>>> a
(1, 2, [3, 4, 4, 5])

>>> a = (1,2,[3,4])
>>> a[2].extend([4,5])
>>> a
(1, 2, [3, 4, 4, 5])

Спасибо всем, кто дочитал до конца. Надеюсь, было интересно =)

UPD. Коллеги подсказали, что этот пример так же разобран в книге Fluent Python Лучано Ромальо. Очень рекомендуют ее почитать всем заинтересованным
Tags:pythonразработкаотладка
Hubs: ДомКлик corporate blog Python Programming
+102
15.2k 118
Comments 51
Python developer
ДомКликМосква
Frontend developer (React)
ДомКликМосква
Senior Java/Kotlin developer
from 120,000 to 330,000 ₽ДомКликМоскваRemote job
Java developer
ДомКликНовосибирск
Top of the last 24 hours
Information
Founded

19 August 2015

Location

Россия

Employees

501–1,000 employees

Registered

17 March