Внутреннее устройство интерпретатора CPython

В этой статье я попытаюсь дать более полное представление о генераторах Python и о том, как они работают изнутри. Он будет включать как и почему. Разделы будут автономными, и вы можете пропускать их по своему желанию. Без лишних слов, давайте начнем наше путешествие. ⛵️

Что такое генераторы?

Генераторы, добавленные начиная с Python 2.2, представляют собой уникальные функции, которые можно приостанавливать, возобновлять и повторять. Давайте посмотрим на пример:

Определив функцию, содержащую ключевое слово yield, функция помечается как генератор. Затем мы запускаем генератор в следующем порядке:

  1. Инициализируйте генератор и поместите его в g. На данный момент у нас есть новый объект-генератор, который еще не запущен.
  2. Продвиньте генератор, позвонив ему next(). Это заставляет генератор перейти к первому ключевому слову yield, по пути напечатав «Hello».
  3. Снова продвиньте генератор. Он печатает «Goodbye», и, поскольку он достиг конца функции, вызывает StopIteration исключение и завершает работу.

Чтобы понять эти три простых шага, нам сначала нужно начать с самых основ того, как работает функция в Python.

Как работают функции

Запуск функции состоит из двух основных этапов: Инициализация и Оценка.

Инициализация функций

Под капотом CPython выполняет функцию внутри объекта frame.

Объект фрейма, иногда называемый стековым фреймом, состоит из следующих полей:

  • объект кода, который включает фактический скомпилированный код функции.
  • свободное пространство стека, называемое стеком значений, для запуска интерпретатора.
  • указатель выполнения для текущей выполняемой строки кода.
  • другие поля, такие как сопоставления локальных переменных.

Список всех полей фрейма можно увидеть в коде, а более короткий список можно увидеть в документации.

Когда мы инициализируем функцию, создается фрейм. В псевдокоде:

f = Frame()

Аргументы, которые мы передали функции, затем назначаются внутри фрейма:

f.f_locals["arg1"] = arg1
f.f_locals["arg2"] = arg2

Точка выполнения кадра установлена на первую строку кода в функции:

f.lasti = 0  # Last Instruction
f.lineno = 0 # Function line number on file

На этом этапе наша функция запускается.

Беговые функции

Практически весь интерпретатор Python можно свести к одной функции уровня C: PyEval_EvalFrameEx.

Эта функция представляет собой цикл интерпретатора. Состоящий из 3k строк кода, его задача - оценивать фрейм или, другими словами, запускать его. Каждая функция в Python, каждый байт-код или код операции, выполняемые интерпретатором, проходят через эту функцию.

Во время «PyEval» Python работает с уникальным стеком значений фрейма. Эта информация будет иметь решающее значение позже.

Таким образом, когда вы вводите функцию и запускаете ее, интерпретатор создает фрейм и входит в цикл интерпретатора.

На сайте PythonTutor есть очень хороший интерактивный пример этого механизма.

Как работают генераторы

Как и в случае с функциями, запуск генератора включает этап инициализации и этап оценки.

Инициализация генератора

На этом этапе мы создаем объект-генератор. Объект-генератор состоит из объекта фрейма и объекта кода. Если вам интересно, да, объект фрейма также содержит внутри себя объект кода. В псевдокоде:

g = Generator()
f = Frame() # Initialized as before
g.gi_frame = f
g.gi_code = f.f_code = Code()  # The compiled function

Теперь наш генератор g готов к работе.

Продвижение (запуск) генератора

Каждый раз, когда мы вызываем next(g), кадр оценивается с использованием того же PyEval_EvalFrameEx.

Разница в том, что при запуске генератора мы можем достичь ключевого слова yield, после чего оценка кадра останавливается, а генератор «приостанавливается».

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

Важно помнить, что в отличие от функций, когда оценка останавливается, фрейм не уничтожается, поскольку он все еще прикреплен к объекту-генератору. Это позволит нам загрузить его позже.

PyEval возвращается, и наша вызывающая функция возобновляется.

Снова продвигаем генератор

Когда мы продвигаем генератор еще раз, кадр загружается из gi_frame, и PyEval вызывается снова в том же кадре. Он проверяет, где мы остановились, переходит к нашему указателю выполнения и использует стек значений, хранящийся в кадре.

Поскольку мы используем один и тот же стек, а фрейм также содержит все локальные переменные, мы можем продолжить с того же места, как если бы функция никогда не останавливалась.

Когда мы доходим до конца генератора или оператора возврата, StopIteration выбрасывается, и фрейм удаляется. gi_frame устанавливается на None.

Особенности генератора

Теперь, когда мы знаем, как генератор приостанавливается и возобновляется внутри, давайте рассмотрим еще несколько функций, которые делают генераторы действительно замечательными.

Урожайность ценностей

Генераторы могут выдавать значения, позволяя повторять их и возвращать результаты ленивым образом. Например:

Внутренне получение значений очень похоже на получение результата функции. Когда PyEval достигает кода операции YIELD_VALUE, он выталкивает верхнее значение стека и возвращает его. Довольно просто, не правда ли?

Возвращение от генератора

Генераторы могут использовать ключевое слово return для возврата результата:

Как видите, оператор return устанавливает исключение StopIteration. Исключения могут иметь аргументы, и здесь первый аргумент, отправленный исключению StopIteration, - это возвращаемое значение.

Возвращаемое значение можно получить, перехватив исключение и получив его первый аргумент:

Связь с генераторами

Мы также можем общаться с генераторами с помощью .send() и .throw():

Как видите, мы отправили числа в генератор с помощью .send(), и они были возвращаемым значением ключевого слова yield.

Внутренне .send() работает, помещая значение в верхнюю часть стека генератора. Затем он оценивает кадр и извлекает верхнее значение стека, помещая его в нашу локальную переменную.

Аналогичным образом .throw() работает, отправляя throwflag в PyEval, сообщая об исключении. Затем он нормально обрабатывает исключение. Если генератор не поймал его, исключение распространяется наружу, как обычная функция.

Вложенные генераторы

Генераторы могут быть вложенными (или делегированными) с использованием ключевого слова yield from:

Как видите, использование yield from создает способ связи между самым внутренним генератором, полностью.

Внутри он работает с использованием поля gi_yieldfrom объекта-генератора. Он указывает на внутренний генератор, и когда вы используете .send(), он будет работать до конца.

Когда внутренний генератор возвращается, он идет вверх по цепочке и соответственно устанавливает возвращаемое значение yield from.

Заключение

В этой статье я дал некоторое представление о внутренней работе реализации генератора CPython. Хотя статья не предназначена для новичков, она лишь поверхностно затрагивает эту тему и позволяет понять, как работает интерпретатор, гораздо больше. Полная реализация, конечно же, с открытым исходным кодом, поэтому вы можете исследовать ее по своему желанию. Наслаждайтесь 😉