[Перевод] Сколько строк на C нужно, чтобы выполнить a + b в Python?
В своей предыдущей статье я исследовал структуру PyObject
и её роль в качестве заголовка для всех объектов среды исполнения CPython. Эта структура играет важнейшую роль в обеспечении наследования и полиморфизма в системе объектов CPython. Но это лишь вершина айсберга.
В этой статье мы опустимся на один уровень ниже и посмотрим, что же происходит внутри среды исполнения Python для выполнения простого действия a + b
. Иными словами, мы узнаем о подробностях реализации типов, операторов и динамической диспетчеризации в CPython.
Стоит заметить, что хотя мы будем изучать реализацию динамической диспетчеризации для конкретного оператора, те же принципы применимы ко всем операторам, поддерживаемым CPython. То есть, по сути, обладая этими знаниями, вы сами можете реализовать собственный оператор или собственный новый тип.
Высокоуровневая структура динамической диспетчеризации в CPython
Когда мы пишем на Python код наподобие a + b
, конкретное поведение операции +
определяют типы a
и b
. Каждый тип в Python имеет собственную реализацию оператора +
(если этот тип поддерживает +
), и интерпретатор Python сам выбирает подходящую реализацию для вызова на основании типа операндов. Весь этот процесс в языках программирования называется динамической диспетчеризацией. На схеме ниже показано высокоуровневое описание того, как это работает в CPython:
Высокоуровневый поток исполнения оператора в CPython VM. Реализация выбирается в зависимости от того, какой тип имеют a и b: int, float или сложный тип
Давайте вкратце рассмотрим части этой схемы:
Код на Python компилируется в байт-код, исполняемый стековой виртуальной машиной (VM) в CPython. Команда
BINARY_OP
отвечает за исполнение операции+
с двумя операндами,a
иb
.Сама VM не знает, как выполнять
+
с двумя объектами. Она делегирует эту задачу абстрактному интерфейсу объектов.Абстрактный интерфейс объектов в CPython определяет интерфейс, поддерживающий стандартные операции уровня объектов в CPython. Это позволяет VM единым унифицированным образом исполнять все операторы, не зная подробностей реализации системы объектов. Абстрактный интерфейс диспетчеризирует исполнение конкретной реализации внутри типов при помощи таблицы поиска указателей функций в заголовке объекта (подробнее об этом позже).
В предыдущей статье мы вкратце исследовали часть этого потока, рассмотрев реализацию команды BINARY_OP
в VM CPython. На этот раз наша цель — по-настоящему разобраться в том, как происходит динамическая диспетчеризация, поэтому мы будем двигаться внизу вверх.
Сначала мы рассмотрим, как разные типы реализуют различные операторы, затем взглянем на абстрактный интерфейс объектов и узнаем, как он вызывает эти конкретные реализации, а в конце расскажем, как VM CPython интегрируется с абстрактным интерфейсом объектов.
Анализ структуры PyTypeObject
Структура PyTypeObject — это второй строительный блок системы объектов CPython (первый — это PyObject
). Она содержит информацию о типе среды исполнения про объект. Прежде чем рассматривать динамическую диспетчеризацию в CPython, нам сначала нужно понять, что находится внутри PyTypeObject
.
Но сначала повторим пройденное и разберём определение PyObject; здесь на сцене появляется PyTypeObject
:
Определение структуры PyObject
Кроме того, каждое определение типа в CPython содержит PyObject
в качестве первого поля в виде заголовка. Например, вот определение типа float:
Определение типа float в CPython
Это значит, что каждый такой объект может быть приведён к типу PyObject
(чтобы понять, как это сделать, см. предыдущую статью о PyObject), а поскольку PyObject
содержит указатель на PyTypeObject
, среда исполнения CPython имеет всю связанную с типами информацию об объекте, которая доступна ей постоянно.
Теперь давайте заглянем внутрь PyTypeObject
. Это очень большой объект с десятками полей. На этом изображении показано его полное определение:
Определение структуры PyTypeObject
Структура PyTypeObject
хранит подробности о типе среды исполнения об объекте: имя типа, размер типа, функции для распределения и освобождения объекта этого типа.
Кроме этого она также хранит таблицы указателей функций для поддержки различных поведений, специфичных для типа. Например, одна из таких таблиц — поле tp_as_number
. Это указатель на объект типа PyNumberMethods , определяющий таблицу указателей функций для числовых операций.
Мы хотим понять, как CPython исполняет бинарный оператор сложения (+
), поэтому внимательнее присмотримся к тому, что находится внутри PyNumberMethods
. Его определение показано на изображении ниже:
Заглядываем внутрь структуры PyNumberMethods. В поле слева представлено развёрнутое определение PyTypeObject, в котором выделено поле PyNumberMethods. В поле справа показано частичное определение PyNumberMethods
Каждая реализация типа в CPython должна создать экземпляр структуры PyNumberMethods
и заполнить его указателями на функции, которые она реализует для поддержки числовых операций. Если тип не поддерживает числовые операции, она может просто присвоить полю tp_as_number
в PyTypeObject
значениеNULL
; это сообщит среде исполнения CPython, что объект не поддерживает эти операции.
Дальше в качестве конкретного примера рассмотрим то, как эти функции реализует тип float
, а затем создаёт экземпляр PyTypeObject
при создании нового объекта float.
Создание экземпляров типов Float при помощи PyNumberMethods
На изображении ниже показан код из Objects/floatobject.c, содержащий реализацию типа float
в CPython.
Как тип float реализует числовые операции и заполняет таблицу указателей функций в своём экземпляре of PyTypeObject под именем PyFloat_Type
Давайте разберём это:
В левом поле показаны функции, реализующие операции сложения, вычитания и умножения.
В среднем поле показан экземпляр структуры
PyNumberMethods
(с именемfloat_as_number
) для типа данных float. Обратите внимание, что он содержит указатели функций для функций сложения, умножения и вычитания.В правом поле показан экземпляр
PyTypeObject
для создания объектов типа float. Обратите внимание, что он содержит указатель на объектfloat_as_number
.
Кроме того, указатель на float_as_number
включён в каждый заголовок объекта float (например, как значение поля ob_type
в PyObject
). На рисунке ниже показана функция PyFloat_FromDouble, создающая новые объекты типа float и использующая float_as_number
для инициализации заголовка объекта.
Как создаётся экземпляр типа float. Обратите внимание, как он передаёт указатель на PyFloat_Type в вызове _PyObject_Init, чтобы задать его тип. Первый выделенный блок: интерпретатор хранит кэш неиспользуемых объектов float и по возможности использует их повторно. Второй выделенный блок: распределение памяти для объекта float. Третий выделенный блок: _PyObject_Init присваивает счётчику ссылок указанного объекта значение 1 и присваивает его полю ob_type значение указателя на тип передаваемого им объекта. В этом случае ему передаётся указатель на PyFloat_Type
Изображение выше достаточно подробное и понятное, поэтому не будем больше тратить на это время. Но это именно тот код, который исполняется при записи кода a = 3.14
на Python.
Примечание: CPython хранит кэш неиспользуемых свободных объектов типа float и по возможности использует их повторно. Это может позволить сэкономить время на распределение памяти. Существуют похожие кэши и для других объектов: списков, кортежей, словарей и так далее.
Мы уже понимаем, что каждый тип реализует различные операторы как функции и использует их для заполнения таблицы указателей функций в PyTypeObject
, которая включается в заголовок объекта. И мы увидели, как эта схема работает в реализации типа float.
Теперь мы поднимемся на один слой наверх и рассмотрим абстрактный интерфейс объектов, который и выполняет саму динамическую диспетчеризацию.
Абстрактный интерфейс объектов в CPython
CPython определяет абстрактный интерфейс объектов для унификации доступа к конкретным реализациям типов. Это обеспечивает чистоту кода VM, потому что она просто делегирует исполнение оператора этому интерфейсу.
Этот абстрактный интерфейс определяется в файле Include/abstract.h, а на изображении ниже показаны объявленные в нём числовые функции:
Файл заголовка abstract.h
объявляет абстрактный интерфейс объектов, который содержит функции для всех стандартных операций уровня объектов в CPython. На этом изображении показан частичный список числовых операций, объявленных в abstract.h
abstract.h
— это файл заголовка, поэтому он только объявляет прототипы этих функций. Реализации этих функций находятся в файле Objects/abstract.c. Мы рассмотрим только реализацию функции PyNumber_Add
в нём, которую VM вызывает для исполнения оператора +
. На изображении ниже показан её код с объяснениями происходящего:
abstract.c содержит реализации функций, объявленных в in abstrac.h. На этом изображении показана реализация функции PyNumber_Add. Первый выделенный блок: сначала пробуем выполнить бинарную операцию сложения с аргументами v и w. Если внутренняя impl аргументов v и w поддерживает операцию сложения, возвращаем результат. Второй выделенный блок: если объекты не поддерживают бинарного сложения, тогда проверяем, являются ли они объектами типа «последовательность» (например, строкой, списком и так далее…). Если да, то вызываем для них функцию concat. Третий выделенный блок: в противном случае возвращаем TypeError
Операция +
поддерживается двумя классами типов данных в Python: числовыми типами (int
, float
, complex
и так далее) и типами «последовательность» (list
, tuple
и так далее).
ФункцияPyNumber_Add
сначала пытается вызвать для аргументов реализацию бинарного сложеняи. Если эти типы не поддерживают бинарного сложения, то она пытается проверить, являются ли эти они типами «последовательность», и если это так, то пытается вызвать для них функцию concat.
Давайте сосредоточимся на числовых типах. Для числовых типов функция PyNumber_Add
вызывает макрос BINARY_OP1
, который просто вызывает функцию binary_op1
. На изображении ниже показана binary_op1
:
Остальная часть реализации PyNumber_Add в abstract.c. Первый выделенный блок: slotv и slotw — это указатели на функции, которые реализуют бинарные операции, соответственно, в v и w. Второй выделенный блок: если v — числовой тип, выполняем поиск указателя функции в его таблице PyNumberMethods. Третий выделенный блок: если v и w имеют разные типы И w является числовым, то выполняем поиск указателя функции в таблице PyNumberMethods w. Однако также возможно, что и slotv, и slotw указывают на одну функцию. В таком случае достаточно только одной из них, так что сбрасываем slotw на NULL. Четвёртый выделенный блок: теперь, когда мы знаем, какие функции нужно вызвать для выполнения бинарной операции, их можно вызвать. Но это необходимо делать в следующем порядке: 1. если бинарную операцию реализует только v, то есть slotv ≠ NULL И slotw = NULL, то мы просто можем вызвать slotv. 2. Однако если бинарную операцию реализуют и v, и w, а w является подтипом v, то мы должны вызвать реализацию бинарной операции w. Это важно, потому что если вы захотите перегрузить оператор собственным поведением в классе, то это обеспечит его выполнение. 3. Наконец, если бинарную операцию реализует только w, то мы вызываем slotw
Функция выполняет довольно много задач, но всё понятно из объяснений. Самое главное — что abstract.c
просто выполняет поиск указателя функции в таблице методов, находящейся в заголовке объекта, и вызывает эту функцию.
Мы узнали, как тип реализует различные операторы и как абстрактный интерфейс объектов упрощает динамическую диспетчеризацию этих реализаций. Теперь мы вернёмся к VM CPython и посмотрим, где она вызывает абстрактный интерфейс объектов для выполнения оператора.
Привязка выполнения оператора к абстрактному интерфейсу объектов в VM CPython
Это последняя часть, где VM CPython интегрирует выполнение оператора с абстрактным интерфейсом объектов. Частично мы говорили об этом в предыдущей статье, когда разбирались, как структура PyObject
помогает симулировать полиморфизм. На этот раз бы разберём это полностью. Но давайте начнём сначала.
На изображении ниже показана простая функция на Python и её команды в байт-коде:
Как стековая VM CPython исполняет команды. Locals — список символов, локальный для текущего контекста исполнения. Проще говоря, там хранятся локальные переменные и параметры сложения
Мы рассмотрим команду байт-кода BINARY_OP
. На изображении ниже показано, как она обрабатывается VM:
Реализация команды BINARY_OP в VM CPython. Для бинарной операции lhs и rhs получаются извлечением двух верхних значений из стека. BINARY_OP — это обобщённая команда, а конкретная исполняемая операция передаётся как аргумент команды, который доступен в переменной oparg. Каждой бинарной операции назначается числовой код, например, сложение — это 0, умножение — 5 и так далее. binary_ops — это массив указателей функций, индексируемый по опкоду бинарных операций. То есть при помощи значения oparg мы можем найти функцию, которую нужно вызывать для выполнения текущей бинарной операции. После завершения использования lhs и rhs можно выполнить декремент из счётчиков ссылок. Мы изменяем стек так, чтобы lhs и rhs удалились, а в верхнем значении сохранился результат бинарной операции.
Я не буду тратить время на объяснение всего этого, потому что мы рассмотрели это в предыдущей статье. Однако если уж мы коснулись самого выполнения бинарной операции, давайте взглянем на код, потому что именно там VM делегирует задачи abstract.c.
В приведённом выше коде мы видим следующую строку:
res = binary_ops[oparg](lhs, rhs);
Этот код выполняет поиск указателя функции в таблице binary_ops
, используя в качестве индекса опкод бинарного оператора, и вызывает эту функцию. Давайте взглянем на эту таблицу, определяемую в файле ceval.c (в котором реализована основная часть кода исполнения VM).
Таблица binary_ops в VM CPython, которая индексирует функции операторов, определённые в abstract.c, используя в качестве индекса бинарные операторы
Каждый указатель функции в таблице binary_ops
указывает на функцию, которая реализована в Objects/abstract.c
. В предыдущем разделе мы уже видели определение PyNumber_Add
в abstract.c
, и поняли, как он выполняет динамическую диспетчеризацию оператора в зависимости от типов операндов.
Именно так VM делегирует выполнение бинарных операторов реализации абстрактного интерфейса, которая в конечном итоге производит динамическую диспетчеризацию при помощи поиска указателя функции в таблицах, находящихся в заголовках объектов.
Подведём итог
Вот и всё! Именно это происходит на самом деле, когда вы исполняете a + b
в коде на Python. Давайте вкратце подытожим:
Каждый тип реализует функции для поддерживаемых им операторов и заполняет таблицу указателей функций в его заголовке (то есть поле PyTypeObject внутри PyObject).
В зависимости оператора VM CPython вызывает функцию в абстрактном интерфейсе объектов.
Абстрактный интерфейс объектов (при вызове из VM) выполняет поиск в таблице указателей функций в заголовке объектов операндов и вызывает соответствующую функцию.
В заключение
Это было краткое знакомство со ВСЕМ кодом CPython, который задействуется при исполнении чего-то простого, например, a + b
в коде на Python. Хотя информации довольно много, её не так сложно понять, если вы разбираетесь в указателях функций.
Вооружившись этим знанием, вы можете реализовать собственные операторы, однако для этого вам также потребуется модифицировать токенизатор и парсер, о которых мы пока не говорили. Возможно, вскоре я расскажу и о них.