Ваши генераторные выражения сломаны: чиним и разбираемся

от автора

Всем привет! Меня зовут Ефимов Михаил, я профессиональный разработчик с 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *