Всем привет! Меня зовут Ефимов Михаил, я профессиональный разработчик с 2010 года и начинающий contributor в CPython.
Итак, название статьи говорит, что генераторные выражения сломаны. О чем вообще речь? Посмотрим на такой код, не содержащий никаких import:
g = (x for x in range(10)) g.gi_frame.f_locals['.0'] = range(20) list(g)
Устанавливаем с официального сайта новенький Python 3.13.0. Запускаем интерпретатор в режиме консоли, копируем в консоль эти строки кода, ожидаем увидеть содержимое списка…
А содержимого никакого нет, да и консоль закрылась — интерпретатор завершил работу. В зависимости от того, на какой операционной системе был запущен код, будет сформирован Segmentation Fault
или его вариации. Например, Windows использует обозначение STATUS_ACCESS_VIOLATION
, но суть та же.
Для подобных исследований нам пригодится faulthandler из стандартной библиотеки:
>>> import faulthandler >>> faulthandler.enable() >>> g = (x for x in range(10)) >>> g.gi_frame.f_locals['.0'] = range(20) >>> list(g) Windows fatal exception: access violation Current thread 0x000018bc (most recent call first): File "<python-input-13>", line 1 in <genexpr> File "<python-input-15>", line 1 in <module> …
Что ж, всё честно, crash на месте, а теперь давайте разбираться, что вообще произошло. Благо, строчек у нас всего три, так что мы можем подробно описать все объекты, вызовы методов и функций.
Встроенный объект range и генераторные выражения
Первая строка:
g = (x for x in range(10))
Тут фигурирует некий вызов range(10)
, с него и начнем. В Python3 range
— это встроенный объект, реализация которого написана на Си. Он ценен тем, что является Iterable
, то есть из него можно получить итератор при помощи встроенной функции iter
. Так как это built-in объект, то и реализация метода __iter__
у него специальная, возвращающая объект класса range_iterator.
С range
разобрались, теперь к генераторным выражениям. Для начала, посмотрим на абстрактное синтаксическое дерево:
>>> import ast >>> print(ast.dump(ast.parse('(x for x in range(10))'), indent=2)) Module( body=[ Expr( value=GeneratorExp( elt=Name(id='x', ctx=Load()), generators=[ comprehension( target=Name(id='x', ctx=Store()), iter=Call( func=Name(id='range', ctx=Load()), args=[ Constant(value=10)]), is_async=0)]))])
И на генерируемый байт-код:
>>> import dis >>> dis.dis('(x for x in range(10))') 0 RESUME 0 1 LOAD_CONST 0 (<code object <genexpr> ...>) MAKE_FUNCTION LOAD_NAME 0 (range) PUSH_NULL LOAD_CONST 1 (10) CALL 1 GET_ITER CALL 0 RETURN_VALUE Disassembly of <code object <genexpr> ...>)>: 1 RETURN_GENERATOR POP_TOP L1: RESUME 0 LOAD_FAST 0 (.0) L2: FOR_ITER 6 (to L3) STORE_FAST_LOAD_FAST 17 (x, x) YIELD_VALUE 0 RESUME 5 POP_TOP JUMP_BACKWARD 8 (to L2) L3: END_FOR POP_TOP RETURN_CONST 0 (None) -- L4: CALL_INTRINSIC_1 3 (INTRINSIC_STOPITERATION_ERROR) RERAISE 1 ExceptionTable: L1 to L4 -> L4 [0] lasti
Видно, что байт-код состоит из двух частей. Отдельно описан вспомогательный code object <genexpr>
, который реализует логику yield
, выдающую значения «наружу». Посмотрите на инструкцию LOAD_FAST
, она помещает на стек значение из переменной со странным именем '.0'
!
Кроме того, в байт-коде присутствует “окружающий код”, который вычисляет значение iter(range(10))
и передает его в качестве единственного аргумента в сформированный code object
. Вычисление этого выражения нетрудно проследить непосредственно, обратите внимание на инструкции LOAD_NAME, LOAD_CONST
и CALL
для вычисления range(10),
к которому после применяется GET_ITER
. И результат вычисления используется последующим CALL
, относящемуся к code object <genexpr>
. Определение built-in класса генератора можно посмотреть тут.
Итак, вернемся к выражению в первой строке:
g = (x for x in range(10))
Переменной g
присваивается значение генераторного выражения, записанного в правой части. В данном случае генератор получился максимально простой: каждому x
из range(10)
сопоставлено то же самое значение x
.
Как известно, list(range(10))
всегда будет выдавать список от 0 до 9, а list(g)
для нашего g
— тот же список, но только один раз. Потому что после первого прохода g
«закончится», и для получения тех же значений еще раз genexpr
придется пересоздать. Кстати, именно list(g)
и вызывается в третьей строке нашего теста. Но к этому моменту уже явно «что-то пошло не так», раз интерпретатор завершает работу.
Фреймы и f_locals
Что же случилось во второй строке:
g.gi_frame.f_locals['.0'] = range(20)
Здесь присутствуют генератор g
, некие gi_frame
и f_locals
и уже знакомый нам объект range
. Рассмотрим их по порядку. Объект gi_frame
— это специальный внутренний объект интерпретатора, называемый stack frame, который связан с нашим генератором. Причем фактически использован на стеке он будет только в момент итерирования генератора. Необходимость в отдельном stack frame обусловлена тем, что выполнение кода генератора прерывается каждый раз, когда в генераторной функции вызывается yield
. Узнать, где именно прервалось выполнение, можно с помощью переменной g.gi_frame.f_lasti
.
Перейдем к f_locals
. Это Python-словарь, который описывает локальные переменные внутри определенного фрейма, ранее он содержал копии значений каждой переменной. Но посмотрим в актуальный PEP 667. Оказывается, начиная с Python 3.13, объект f_locals
— это некий «view of namespace». Иными словами, f_locals
выглядит как словарь, описывающий локальные переменные. Однако при изменении этого «как бы словаря» будет меняться не только значение внутри f_locals
, но и реальное значение переменной внутри фрейма. Больше про f_locals
можно почитать на Хабре тут.
Так что же происходит во второй строке? Фактически, мы меняем значение некой локальной переменной внутри генераторного фрейма с уже знакомым нам странным именем '.0'
. А разве бывают переменные с таким названием, спросите вы? Бывают, но в очень специфических случаях, и это как раз он.
Рассмотрим чуть внимательнее code object
, связанный с g
:
>>> g = (x for x in range(10)) >>> g.gi_frame.f_code is g.gi_code True >>> import dis >>> print(dis.code_info(g.gi_code)) Name: <genexpr> Filename: <python-input-5> Argument count: 1 Positional-only arguments: 0 Kw-only arguments: 0 Number of locals: 2 Stack size: 2 Flags: OPTIMIZED, NEWLOCALS, GENERATOR Constants: 0: None Variable names: 0: .0 1: x
Он имеет один параметр — итератор, равный iter(range(10))
для нашего g
. Внутри генераторной функции переданный итератор попадает в локальную переменную '.0'
. Такое название — внутренняя деталь реализации CPython. Получается, что вместо iter(range(10))
в генераторное выражение попал range(20)
. Важно: не iter(range(20))
, а range(20)
, т.к. именно его мы непосредственно указали в коде. Можете проверить, что в варианте iter(range(20))
никакого crash не будет, list(g)
просто будет списком чисел от 0 до 19.
Итак, проблема в том, что вместо объекта итератора внутрь генераторной функции «пролез» объект range
, который является Iterable
, но не является итератором.
Как исправить?
Падение интерпретатора Python — это нехорошо, подумал я, и создал issue. Сообщество СPython — очень живое и отзывчивое. Вероятно, поэтому в тот же день я получил ответ Jelle Zijlstra — одного из Core Developers. В нем он указал причину проблемы и предложил решение: добавить NULL-check
в Си-код в нужное место. Чуть позже именно это решение было отклонено Mark Shannon — разработчиком CPython, много лет занимающимся виртуальной машиной — как недостаточно эффективное. Замечу, что в своё время Mark защитил PhD-работу про виртуальные машины.
Тем не менее, в результате совместного обсуждения подходящее решение было выработано, мой PR принят и влит в main, а также произведён его backport в ветку 3.13. Так что на ближайшей версии 3.13.1, которая выйдет в ноябре, этого падения не будет. А код изначального теста будет работать одинаково и для range(20)
, и для iter(range(20))
.
В Python 3.13 изменения этим и ограничатся, а на будущих версиях поменяется поведение кода при создании заведомо некорректных генераторных выражений. Например, таких:
>>> (x for x in 42) Traceback (most recent call last): File "<python-input-7>", line 1, in <module> (x for x in 42) ^^ TypeError: 'int' object is not iterable
Эта ошибка больше выдаваться не будет, но аналогичная по сути ошибка проявится позже, в момент итерирования полученного генератора. Этих изменений в ветке main ещё нет, идет работа над Pull Request.
Альтернативные варианты модификации генераторов
Получается, это всё касается только версии Python 3.13? И да, и нет. Именно такой код в более ранних версиях Python сработает по-другому: изменится только значение внутри объекта f_locals
, но не настоящее значение локальной переменной с именем '.0'
. Поэтому вторая строка не сделает ничего существенного. Но можно придумать и другие способы. Например, такой:
g = (x for x in range(10)) import types g_fn = types.FunctionType(g.gi_code, {}) g = g_fn(range(20)) list(g)
Или такой:
g = (x for x in range(10)) g_fn = lambda it: None g_fn.__code__ = g.gi_code g = g_fn(range(20)) list(g)
По сути, всё это вариации на тему одной и той же идеи: получить генератор, у которого тот же самый code object
, но другой «нижележащий итератор».
Бонус первый
Наряду с генераторными выражениями в Python есть и другие похожие конструкции. Речь о comprehensions: listcomp
, setcomp
, dictcomp
. Можно задаться вопросом: а они тоже внутри себя делают какой-нибудь code object
и потом его вызывают? Оказывается, раньше так и было, но, начиная с Python 3.12, comprehension встраиваются в код и не имеют stack frame, детали можно узнать из PEP 709.
В качестве демонстрации отсутствия code object
покажу байт-код для listcomp
:
>>> dis.dis('[x for x in range(10)]') 0 RESUME 0 1 LOAD_NAME 0 (range) PUSH_NULL LOAD_CONST 0 (10) CALL 1 GET_ITER LOAD_FAST_AND_CLEAR 0 (x) SWAP 2 L1: BUILD_LIST 0 SWAP 2 L2: FOR_ITER 4 (to L3) STORE_FAST_LOAD_FAST 0 (x, x) LIST_APPEND 2 JUMP_BACKWARD 6 (to L2) L3: END_FOR POP_TOP L4: SWAP 2 STORE_FAST 0 (x) RETURN_VALUE -- L5: SWAP 2 POP_TOP 1 SWAP 2 STORE_FAST 0 (x) RERAISE 0 ExceptionTable: L1 to L4 -> L5 [2]
Бонус второй
Даже после внесения фикса в main поведение интерпретатора можно улучшать с точки зрения читаемости. Так, если в качестве «нижележащего итератора» указать заведомо некорректное значение, то выпадет TypeError, текст которого не совсем корректен:
>>> g = (x for x in range(10)) >>> g.gi_frame.f_locals['.0'] = 42 >>> list(g) Traceback (most recent call last): File "<python-input-2>", line 1, in <module> list(g) ~~~~^^^ File "<python-input-0>", line 1, in <genexpr> g = (x for x in range(10)) ~~~~~^^^^ TypeError: 'int' object is not iterable
В идеале, TypeError должен указывать не на то место, где генераторное выражение создавалось, а на то, где оно изменялось через f_locals
.
Бонус третий
А вы знали, что comprehension бывают сложными, составными, что они могут содержать несколько for, и каждый из них — несколько условий? Приведу пример подобного синтаксиса:
>>> [x**2 + 1000*y**2 for x in range(10) if x%2 == 0 for y in range(2*x) if y % 2 != 0 if x + y > 3 if x >= y] [1016, 9016, 1036, 9036, 25036, 1064, 9064, 25064, 49064]
Как вы думаете, можно ли совмещать for и async for в одном comprehension? И зачем это может быть нужно? Пишите свои идеи в комментарии, обсудим! (А тут можно почитать про построение do-нотации на их основе).
Завершение
Благодарю за идею этой статьи и помощь в подготовке Никиту Соболева. Заглядывайте в его телеграмм-канал, там много похожего контента про кишки питона!
Если статья понравится аудитории, возможны продолжения на смежные темы. Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/articles/853864/
Добавить комментарий