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

в 8:00, , рубрики: bytecode, comprehension, generator expressions, generators, segfault, virtual machine

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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js