Всем привет! Меня зовут Ефимов Михаил, я профессиональный разработчик с 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-нотации на их основе).
Завершение
Благодарю за идею этой статьи и помощь в подготовке Никиту Соболева. Заглядывайте в его телеграмм-канал, там много похожего контента про кишки питона!
Если статья понравится аудитории, возможны продолжения на смежные темы. Спасибо за внимание!
Автор: efimov-mikhail