Как стать автором
Обновить

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

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


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

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

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

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

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

  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, не являюсь знатоком стандарта.


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

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


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

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

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

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

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

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

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

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

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

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

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

a[2] += [3, 4] точно эквивалентно a[2].extends([3, 4]), что означает, что id(a[2]) не меняется, и стало быть контракт для элемента кортежа не нарушается.
По-моему, в вашем рассуждении ошибка. Вы пишете, что 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 изменился, так как был создан новый список


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

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

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

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

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

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

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

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

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

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

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

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

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


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

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


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


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

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


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


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

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


x = 5
y = x
x += 3

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

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


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

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

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


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__ не существует, но это никак не мешает оператору += работать.

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

Выражение


a[2] += [4, 5]

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


i = a[2]
i += [4, 5]
a[2] = i # exception

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

НЛО прилетело и опубликовало эту надпись здесь
Уж совсем баян. По-началу выглядит странно. Но не странно ну вот вообще.
Первый пункт, «ты ожидаешь, что операция не выполнилась, а она по факту выполнилась.»
Пример кода:
>>> 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]
Ну логично же. Мы взяли другую переменную и поменяли. Исходный объект-то тут причём?

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


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 должно быть вроде как одно и то же. Т.е. по факту += совершенно самостоятельная операция а никакой не оператор присваивания. И в примере автора (и в доках) ни что иное как попытка создателей натянуть сову на глобус называя баг фичей.

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


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

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

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