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

    В языках программирования меня всегда интересовало их внутреннее устройство. Как работает тот или иной оператор? Почему лучше писать так, а не иначе? Подобные вопросы не всегда помогают решить задачу «здесь и сейчас», но в долгосрочной перспективе формируют общую картину языка программирования. Сегодня я хочу поделиться результатом одного из таких погружений и ответить на вопрос, что происходит при модификации 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 Лучано Ромальо. Очень рекомендуют ее почитать всем заинтересованным
    ДомКлик
    Место силы

    Комментарии 51

      –6

      Странный чувак…
      "В tuple нельзя заменять элементы". Просто запомни.
      Но если элемент изменяемый, то внутри него можно ковыряться.
      Пример:


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

        ну так о том и речь, что это, фактически, — тот же список внутри тупла, его замена не происходит (если логика с id(a) в начале статьи — верная), элемент тупла действительно меняется без проблем, как вы и говорите, но в добавок мы имеем еще и исключение, не влияющее на результат.

          +6

          Логика в начале статьи не до конца верная.
          Если операция += заменяет содержимое старого объекта полностью новым, то автор ничего не заметит в промежутке.
          А питон — заметит.
          А результатом мы получили exception. То, что list таки расширился, это результат UB.
          PS. А вообще такие статьи похожи на изучение взаимодействия бензопилы и арматуры. С получением удивительных (для автора) результатов.

            +12
            Ну хз. Лично мне кажется не совсем интуитивным, когда ты с одной стороны ловишь исключение, а с другой стороны действие выполняется. Это кажется немного странным. И ровно эта причина заставила поковыряться в байт-коде и разобраться как оно внутри устроено.
              –1

              Вы можете на C сделать нечто вроде "void *ptr = &(main); *ptr = 0x00;" и тоже получить результат. И затертый main и exception.
              Но это повод ковыряться не в коде, а в документации. Где написано "не надо так делать".

                +2
                1. в Си нет исключений
                2. Что в Си, что в Си++ вы скорее всего получите ошибку сегментации во время исполнения, в Си++ нет возможности ее обработать как исключение (с т зрения языка во всяком случае)
                3. Повод ковыряться в документации действительно есть, но в коде поковыряться не менее полезно :)

                P. S. вместо

                void <em>ptr = &(main);
                должно быть, вероятно, что-то вроде
                int </em>ptr = (int <em>)&(main);
                , т. к. вы разыменовываете void и void'у целое присваиваете, оно просто не откомпилируется (invalid use of void expression)

                Я вот кстати не знаю, является ли этот пример именно Undefined Behavior или тут Unspecified, не являюсь знатоком стандарта.


                Но вообще соглашусь с вами по части того, что документацию стоит читать в таких ситуациях, ее разбора не хватает в статье для полноты картины :)

                  –8

                  Возможно это будет откровением, но seg fault — это и есть исключение.
                  Выбрасываемое ядром.
                  Написанном, кстати, на чистокровном С.


                  Но таки да — если "маю час, маю натхнення", то в коде поковыряться всегда полезно)

                    +2

                    Это какая-то нестандартная терминология...

                      +3
                      Возможно это будет откровением, но

                      Давайте я вам тоже накидаю пару «откровений».
                      Выше вы писали: что и main перетрется, и exception будет:
                      и тоже получить результат. И затертый main и exception

                      Так вот:
                      1. main — не перетрется, потому что сегмент кода защищен от записи, от того то вы и получаете segfault от ОС.

                      2. Да, конечно, segfault — это что-то ВРОДЕ исключения в некотором смысле, только оно «выбрасывается» ядром как сигнал процессу (в линухе, так-то поведение может быть разное, зависит от ОС). А обрабатывается ядром в данном случае вполне себе аппаратная ошибка доступа в память на запись, помеченную недоступной для таковой в таблице страниц (аппаратная, потому что таблица страниц и все вот это вот имеет, как правило, аппаратную реализацию). Разницу с языковыми стандартами не улавливаете?

                      Написанном, кстати, на чистокровном С.

                      Может быть, кстати, еще для вас станет откровением, что ядро написано не только на Си, но имеет еще и платформозависимые части с соотв. ассемблерами, а так же то, что Си там далеко не чистокровный.

                      Ну и возвращаясь к нашим баранам, main, как я сказал, не перетрется, так что ваш пример — полная противоположность поведению интерпретатора из данной статьи, ибо сайд эффекта как раз в вашем примере — нет.
                        +2
                        Вы путаете исключения C++ (с C не работал), которые есть языковые конструкции и потому работают строго синхронно с операторами языка (грубо говоря, каждое исключение есть результат выполнения оператора throw где-то) и исключения платформы x86 (защищенный и длинный режим), которые есть частный случай прерываний. Существуют механизмы, позволяющие исключения x86 (те из них, которые ОС пробрасывает в процесс) заворачивать в исключения C++, но они, насколько я знаю, не являются стандартными, и потому разные компиляторы их реализуют по-разному.
                    +4
                    Поковыряться в байт-коде — это завсегда интересное занятие, но можно было обойтись простым рассуждением: выражение a[2] += [4, 5] из Вашего примера должно быть эквивалентно по результату выражению a[2] = a[2] + [4,5], в котором, согласно приоритету операций, сначала выполнится правая часть, модифицирующая список, а затем настанет очередь оператора присваивания, который не поддерживается для элементов кортежа. Так что поведение интерпретатора выглядит вполне логичным.
                      –1
                      На самом деле с точки зрения логики — да, но с точки зрения реализации += и + могут быть реализованы по разному
                      Кроме того, семантически
                      a[2] += [3,4]
                      эквивалентно
                      b = a[2]
                      b += [3,4]
                      а второй код уже отработает без исключения =)
                        +4
                        Но если с точки зрения логики всё работает правильно, тогда описанное поведение списка в кортеже выглядит вполне стандартно для языка, хоть и не совсем интуитивно.

                        А куда в эквивалентной записи подевалось a[2] = b?
                          +1
                          В питоне присваивание массивов идет по ссылке, а не копированием, так что обратное присваивание не требуется.
                            +4

                            Без финального присваивания эквивалент не будет работать для иммутабельных типов вроде int, str, tuple, frozenset etc где вместо модификации создается копия.

                          +2
                          a[2] += [3, 4] точно эквивалентно a[2].extends([3, 4]), что означает, что id(a[2]) не меняется, и стало быть контракт для элемента кортежа не нарушается.
                          0
                          По-моему, в вашем рассуждении ошибка. Вы пишете, что a[2] += [4, 5] должно быть эквивалентно по результату выражению a[2] = a[2] + [4,5], в котором сперва выполнится правая часть, модифицирующая список. Но правая часть не модифицирует список. Она создаёт новый список.

                          Вот исходный пример (использующий список вместо кортежа, чтобы избежать исключений):
                          >>> l1 = [[1,2]]
                          >>> l = l1[0] # сохраним ссылку, чтобы избежать удаления списка
                          >>> hex(id(l1[0]))
                          '0x6fffffebfe88'
                          >>> l1[0] += [3, 4]
                          >>> hex(id(l1[0]))
                          '0x6fffffebfe88' # id тот же, что и выше


                          Вот пример с конструкцией из вашего рассуждения:
                          >>> l2 = [[1,2]]
                          >>> l = l2[0] # сохраним ссылку, чтобы избежать удаления списка
                          >>> hex(id(l2[0]))
                          '0x6fffffe428c8'
                          >>> l2[0] = l2[0] + [3, 4]
                          >>> hex(id(l2[0]))
                          '0x6fffffe427c8' # id изменился, так как был создан новый список


                          Ниже привели ссылку на документацию, которая описывает происходящее более детально.
                            0
                            Действительно, моя попытка рассмотреть ситуацию «на пальцах» не совсем удачная, в документации всё изложено значительно лучше.
                            0
                            выражение a[2] += [4, 5] из Вашего примера должно быть эквивалентно по результату выражению a[2] = a[2] + [4,5]

                            Но ведь это совсем не так. Автор во втором абзаце это же и объясняет.

                              +1
                              выражению a[2] = a[2] + [4,5], в котором, согласно приоритету операций, сначала выполнится правая часть, модифицирующая список, а затем настанет очередь оператора присваивания

                              Каким это образом правая часть, т.е. a[2] + [4,5], модифицирует список?
                              +1

                              Добро пожаловать в мир UB) C++ программисты с этим каждый день живут)

                                –2
                                В Python не может быть UB, потому что нет формального стандарта, а значит, весь язык — одно большое UB.
                                  0

                                  А куда этот стандарт, со всеми PEP, делся?

                                    –2
                                    Где вы его видели?
                        0
                        Почему-то и подумал, что так будет, ожидая подвох. В Питоне нет же переменных, хранящих объекты, а исключение вызванно закономерно при попытке изменить кортеж. Тут просто надо понимать порядок действий. Но определенно инетересный сюжет, нампомнило мне как я по глупости пытался двумерный массив сделать циклом соедиения одного и того же списка.
                          +7
                          ИМХО, все логично, если помнить, что внутри тупла не сами объекты, а ссылки на объекты. Вот эти-то ссылки и нельзя менять. А сами объекты — можно, если конечно эти объекты сами допускают изменение.
                            +5
                            Конечно, но с точки зрения языка поведение интересное.
                            Потому что когда вылетает исключение — ты ожидаешь, что операция не выполнилась, а она по факту выполнилась.
                            Поэтому и хотелось заглянуть внутрь.
                              0
                              Да, это не совсем интуитивно ожидаемое поведение.
                                –1
                                Потому что когда вылетает исключение — ты ожидаешь, что операция не выполнилась, а она по факту выполнилась.
                                а как же выход за границы массива, допустим 999 вставок из 1000 выполнилось, а на 1000 возникло исключение?
                                  +1
                                  И как раз 1000 вставка и не выполнилась. Ваш пример не корректный
                              +1

                              Я только учу питон и возможно мой вопрос покажется глупым, но почему в предпоследнем фрагменте кода при изменении "b" поменялась и "а"?

                                +2
                                Потому что и в tuple и в b живет по факту ссылка на массив.
                                +3

                                Тот факт, что при возникновении исключения ещё и сайд-эффект выполнился, похоже на баг. Может, об этом стоит создать issue?

                                  +1
                                  А как это отследишь? Изменение списка происходит ДО попытки изменить tuple, т.е. до выброса исключения.
                                  Можно подавить исключение, разрешив присваивание элемента tuple себе, т.е. b = a[2]; a[2] = b сработает. Но это еще более странное поведение.

                                  Я подозреваю, дело в том, что по умолчанию a[i] += b разворачивается в
                                  temp = a[i]
                                  temp = temp.__iadd__(b)
                                  a[i] = temp #exception!
                                  


                                  Но вообще это поведение уже описано в FAQ Python.
                                    0

                                    По-идее, это дыра в логике. У объекта могут быть методы __add__ и __iadd__, и если первый всегда создаёт новый объект (а потому должен вернуть значение), то второй нового объекта не создаёт и значения возвращать не должен бы.


                                    То есть x += y должно выполняться один из двух способов (в зависимости от существования метода __iadd__):


                                    1. x = x.__add__(y)
                                    2. x.__iadd__(y)

                                    И, если бы так оно и было, то и проблемы со списком в кортеже не было бы.


                                    Но в реальности второй вариант выполняется как x = x.__iadd__(y), то есть методу __iadd__ зачем-то разрешено одновременно и модифицировать старый объект, и вернуть новый — и отсюда и растёт обсуждаемая проблема.


                                    Но исправить эту дыру уже не получится, ведь это потеря обратной совместимости, а экосистема Питона до сих пор от перехода на третью версию не восстановилась.

                                      0

                                      Это сделано для того, чтобы работа с примитивными типами не вызывала внезапных wtf.


                                      x = 5
                                      y = x
                                      x += 3

                                      Вы же не ожидаете, что в этом случае у изменится?

                                        0

                                        А с какого перепугу в такой ситуации y должен измениться?


                                        Примитивы иммутабелтны, у иммутабельных типов оператора __iadd__ не должно быть, значит оператор += должен сработать как y = y.__add__(x).

                                          –2

                                          В питоне же динамическая типизация, если код += находится в функции, то компилятор не сможет заменить iadd на add.

                                            +1

                                            Ау, но он же уже так делает! Вот, только что проверил:


                                            Python 3.8.2 (default, Feb 26 2020, 02:56:10)
                                            > type(0)
                                            <class 'int'>
                                            > type(0).__add__
                                            <slot wrapper '__add__' of 'int' objects>
                                            > type(0).__iadd__
                                            Traceback (most recent call last):
                                              File "<stdin>", line 1, in <module>
                                            AttributeError: type object 'int' has no attribute '__iadd__'

                                            Как видно, у чисел метода __iadd__ не существует, но это никак не мешает оператору += работать.

                                  +1
                                  спасибо за интересную статью!
                                    +8

                                    Выражение


                                    a[2] += [4, 5]

                                    является буквальным эквивалентом этого:


                                    i = a[2]
                                    i += [4, 5]
                                    a[2] = i # exception
                                      0
                                      Для интересующихся этот и другие примеры есть на гитхабе в What the f*ck Python!
                                        +1

                                        Посмотрел, многие — всего лишь игра с приоритетами операций или даже с определением самих операций.

                                      • НЛО прилетело и опубликовало эту надпись здесь
                                          +3
                                          Уж совсем баян. По-началу выглядит странно. Но не странно ну вот вообще.
                                          Первый пункт, «ты ожидаешь, что операция не выполнилась, а она по факту выполнилась.»
                                          Пример кода:
                                          >>> A = []
                                          >>> def add_to_A(): 
                                          ...     A.append(len(A))
                                          >>> 1 / add_to_A()
                                          Traceback (most recent call last):
                                            File "<input>", line 1, in <module>
                                          TypeError: unsupported operand type(s) for /: 'int' and 'NoneType'
                                          >>> A
                                          [0]
                                          Если в строке два действия, и интерпретация падает на втором, то первое-то выполняется.

                                          Второй пункт, какого чёрта это вообще падает?
                                          В кортеже нельзя менять ссылки на объекты. А операция += вызывает __iadd__ у объекта, который может и новую ссылку вернуть. Поэтому операция типа a[2] += [4,5] может изменить ссылку в кортеже.
                                          class Foo:
                                              def __iadd__(self, other):
                                                  return Foo()
                                          
                                          a = b = Foo()
                                          print(id(a))  # 1424399578256
                                          a += 1
                                          print(id(a))  # 1424400155360
                                          print(id(b), id(a), a is b)  # 2088577971344 2088579662560 False
                                          


                                          Третье. «семантически a[2] += [3,4] эквивалентно b = a[2]; b += [3,4]»
                                          В свете примера выше ни разу не эквивалентно.
                                          class Foo:
                                              def __iadd__(self, other):
                                                  return Foo()
                                          
                                          A = [Foo()]
                                          print(A)  # [<__main__.Foo object at 0x0000019D4A71B490>]
                                          b = A[0]
                                          b += 1  # Всё, теперь в b ссылка вообще на другой объект
                                          print(A)  # [<__main__.Foo object at 0x0000019D4A71B490>]
                                          A[0] += 1  # Вот только теперь ссылку испортили
                                          print(A)  # [<__main__.Foo object at 0x0000019D4A9D6820>]
                                          


                                          Теперь сравнение с другими языками. Первый пример нет смысла обсуждать: почти везде так.

                                          Второй. Ну, например, js. Javascript тоже красавчег:
                                          > A = Object.freeze([1, 2, 3])
                                          < (3) [1, 2, 3]
                                          > A[1] += 10
                                          < 12
                                          > A
                                          < (3) [1, 2, 3]

                                          Круто. Исключения нет, вывод в консоль правильный. Но действия тоже нет :)

                                          Третье.
                                          > A = [1, [2], 3]
                                          < (3) [1, Array(1), 3]
                                          > A
                                          < (3) [1, Array(1), 3]
                                          > b = A[1]
                                          < [2]
                                          > b += 1
                                          < "21"
                                          > A
                                          < (3) [1, Array(1), 3]
                                          > A[1] += 1
                                          < "21"
                                          > A
                                          < (3) [1, "21", 3]
                                          Ну логично же. Мы взяли другую переменную и поменяли. Исходный объект-то тут причём?
                                            +5

                                            Вот что действительно странно:


                                            >>> a = [1, 2]
                                            >>> a += {3: 4, 5: 6}
                                            >>> a
                                            [1, 2, 3, 5]
                                            
                                            >>> a + {3: 4, 5: 6}
                                            TypeError: can only concatenate list (not "dict") to list

                                            Не нашел этого в wtfpython :)

                                              –1
                                              Прям большая новость. Особенно для тех, кому лень читать официальные доки.
                                              docs.python.org/3.8/faq/programming.html?highlight=comprehension#why-does-a-tuple-i-item-raise-an-exception-when-the-addition-works
                                                +1

                                                Этот += в питончике реально странный


                                                a = ['a', 'b', 'c']
                                                a += 'wtf'
                                                print(a)  # ['a', 'b', 'c', 'w', 't', 'f']
                                                a = a + 'wtf'  # TypeError: can only concatenate list (not "str") to list

                                                Почему так понятно, но сильно неожиданно когда ждешь что a=a+something и a+=something должно быть вроде как одно и то же. Т.е. по факту += совершенно самостоятельная операция а никакой не оператор присваивания. И в примере автора (и в доках) ни что иное как попытка создателей натянуть сову на глобус называя баг фичей.

                                                  +2

                                                  А что вас удивляет? a += b и есть совершенно самостоятельная операция — __iadd__. Это же ни C# какой-нибудь, где a += b(практически[1]) полностью эквивалентно a = a + b. То, что для удобства она разворачивается в a = a + b, когда не определена явно — вот это и есть фича.


                                                  [1] За исключеним того, что выражение a вычисляется только один раз, а не два.

                                                    +1
                                                    Человека, незнакомого с языком, вполне закономерно может удивить тот факт, что реализации операторов + и += для некоторых типов могут иметь разную семантику.
                                                    Так как во многих языках a += b как-правило является эквивалентом a = a + b, поведение этих операторов должно бы быть консистентным.
                                                    В Python же для списка реализация оператора a += b эквивалентна вызову метода a.extend(b) (за исключением присваивания), что описано в документации [1], который работает не только со списками, а с любым iterable.

                                                    [1] docs.python.org/3/library/stdtypes.html#mutable-sequence-types
                                                    0
                                                    Скорее не +=, а его реализация для изменяемых последовательностей (mutable sequences), в том числе и списков.

                                                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                                  Самое читаемое