Как я и писал в предыдущем эпизоде (который, кстати, оказался успешным; спасибо всем, ваши просмотры и комментарии буквально заставляют меня двигаться дальше!) – сегодняшний пост посвящён реализации объектов в Python 3.x. Поначалу я думал, что это простая тема. Но даже когда я прочитал весь код, который нужно было прочитать перед тем, как написать пост, я с трудом могу сказать, что объектная система Питона… гхм, «простая» (и точно не могу сказать, что до конца разобрался в ней). Но я ещё больше убедился, что реализация объектов — хорошая тема для начала. В следующих постах мы увидим, насколько она важна. В то же время, я подозреваю, мало кто, даже среди ветеранов Питона, в полной мере в ней разбирается. Объекты слабо связаны со всем остальным Питоном (при написании поста я мало заглядывал в ./Python
и больше изучал ./Objects
и ./Include
). Мне показалось проще рассматривать реализацию объектов так, будто она вообще не связана со всем остальным. Так, будто это универсальный API на языке C для создания объектных подсистем. Возможно, вам тоже будет проще мыслить таким образом: запомните, всё это всего лишь набор структур и функций для управления этими структурами.
Всё в Питоне — объект: числа, словари, пользовательские и встроенные классы, стековые фреймы и объекты кода. Чтобы указатель на участок памяти можно было считать объектом, необходимы как минимум два поля, определённые в структуре./Include/object.h
: PyObject
:
typedef struct _object {
Py_ssize_t ob_refcnt;
struct _typeobject *ob_type;
} PyObject;
Многие объекты расширяют эту структуру, добавляя необходимые поля, но эти два поля должны присутствовать в любом случае: счётчик ссылок и тип (в специальных отладочных сборках добавляется пара загадочных полей для отслеживания ссылок).
Счётчик ссылок — это число, показывающее, сколько раз другие объекты ссылаются на данный. В коде >>> a = b = c = object()
инициализируется пустой объект и связывается с тремя разными именами: a
, b
и c
. Каждое имя создаёт новую ссылку на объект, но при этом объект создаётся единожды. Связывание объекта с новым именем или добавление объекта в список создаёт новую ссылку, но не создаёт новый объект! На эту тему можно ещё много говорить, но это больше относится к сборке мусора, а не к объектной системе. Я лучше напишу об этом отдельный пост, вместо того, чтобы разбирать этот вопрос здесь. Но, прежде чем оставить эту тему, скажу, что теперь нам проще понять макрос ./Include/object.h
:Py_DECREF
, с которым мы встретились в первой части: он всего лишь декрементирует ob_refcnt
(и освобождает ресурсы, если ob_refcnt
принимает нулевое значение). На этом пока покончим с подсчётом ссылок.
Остаётся разобрать ob_type
, указатель на тип объекта, центральное понятие объектной модели Питона (имейте в виду: в третьем Питоне, тип и класс по сути одно и то же; по историческим причинам использование этих терминов зависит от контекста). У каждого объекта всего один тип, который не меняется в течение жизни объекта (тип может поменяться в чрезвычайно редких обстоятельствах. Для этой задачи не существует API, и вы вряд ли читали бы эту статью, если бы работали с объектами с изменяющимися типами). Важнее, может быть, то, что тип объекта (и только тип объекта) определяет, что можно с ним делать (пример в спойлере после этого абзаца). Как вы помните из первой части, при выполнении операции вычитания вызывается одна и та же функция (PyNumber_Subtract
) вне зависимости от типа операндов: для целых чисел, для целого и дробного или даже для полнейшего абсурда, вроде вычитания исключения из словаря.
# тип, а не экземпляр, определяет, что можно делать с экземпляром >>> class Foo(object): ... "I don't have __call__, so I can't be called" ... >>> class Bar(object): ... __call__ = lambda *a, **kw: 42 ... >>> foo = Foo() >>> bar = Bar() >>> foo() Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'Foo' object is not callable >>> bar() 42 # может добавить __call__? >>> foo.__call__ = lambda *a, **kw: 42 >>> foo() Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'Foo' object is not callable # а если добавить его к Foo? >>> Foo.__call__ = lambda *a, **kw: 42 >>> foo() 42 >>>
Поначалу это кажется странным. Как одна сишная функция может поддерживать любой вид передаваемых ей объектов? Она может получить указатель void *
(на самом деле, она получит указатель PyObject *
, но это мало что значит, потому что учитываются данные объекта), но как она определит, что делать с полученным аргументом? Ответ заключён в типе объекта. Тип также является объектом (у него есть и счётчик ссылок, и его собственный тип; тип большинства типов — type
), но в дополнение к двум основным полям он содержит множество других полей. Определение структуры и описание её полей изучайте здесь. Само определение находится в ./Include/object.h
: PyTypeObject
. Я рекомендую обращаться к нему по ходу чтения статьи. Многие поля объекта типа называются слотами и указывают на функции (или на структуры, указывающие на родственные функции), которые будут выполнены при вызове функции C-API Питона на объектах этого типа. И хоть нам и кажется, что PyNumber_Subtract
работает с аргументами разных типов, на самом деле типы операндов разыменовываются и вызывается специфичная данному типу функция вычитания. Таким образом, функции C-API не универсальные. Они полагаются на типы и абстрагируются от деталей, и создаётся впечатление, что они работают с любыми данными (при этом выбрасывание исключения TypeError
— это тоже работа).
Давайте разберём детали. PyNumber_Subtract
вызывает универсальную функцию двух аргументов ./Objects/abstract.c
:binary_op
, указав, что работать нужно со слотом nb_subtract
(подобные слоты есть и для других операций, например,nb_negative
для отрицания чисел или sq_length
для определения длины последовательности). binary_op
— это обёртка с проверкой ошибок над binary_op1
, функцией, которая выполняет всю работу. ./Objects/abstract.c
: binary_op1
(почитайте код этой функции — на многое открывает глаза) принимает операнды операции BINARY_SUBTRACT
как v
и w
, и пытается разыменовать v->ob_type->tp_as_number
, структуру, содержащую указатели на функции, которые реализуют числовой протокол. binary_op1
ожидает найти в tp_as_number->nb_subtract
C-функцию, которая либо выполнит вычитание, либо вернёт специальное значение Py_NotImplemented
, если определит, что операнды несовместимы в качестве уменьшаемого и вычитаемого (это приведёт к выбрасыванию исключения TypeError
).
Если вы хотите изменить поведение объектов, то можете написать расширение на C, которое переопределит структуруPyObjectType
и заполнит слоты так, как вам хочется. Когда мы создаём новые типы в Питоне (>>> class Foo(list): pass
создаёт новый тип, классы и типы — одно и то же), мы не описываем вручную какие-либо структуры и не заполняем никаких слотов. Но почему тогда эти типы ведут себя так же, как и встроенные? Правильно, из-за наследования, в котором типизация имеет значительную роль. У Питона уже есть некоторые встроенные типы, вроде list
и dict
. Как было сказано, у этих типов есть определённые функции, заполняющие соответствующие слоты, что даёт объектам нужное поведение: например, изменяемость последовательности значений или отображение ключей на значения. Когда вы создаёте новый тип в Питоне, на куче для него (как для любого другого объекта) динамически определяется новая C-структура и её слоты заполняются соответственно наследуемому, базовому, типу (вы можете спросить, а что же со множественной наследуемостью?, отвечу, в других эпизодах). Т.к. слоты скопированы, вновь сознанный подтип и базовый обладают почти идентичной функциональностью. В Питоне есть базовый тип без какой-либо функциональности — object
(PyBaseObject_Type
в C), в котором почти все слоты обнулены, и который можно расширять без наследования чего бы то ни было.
Таким образом, вы не можете создать тип в Питоне, вы всегда наследуетесь от чего-то другого (если вы определите класс без явного наследования, то он неявно будет наследоваться от object
; в Python 2.x в таком случае будет создан «классический» класс, их мы не будем рассматривать). Естественно, вам не обязательно постоянно наследовать всё. Вы можете изменять поведение типа, созданного прямо в Питоне, как было показано в сниппете выше. Определив специальный метод __call__ у класса Bar
, мы сделали экземпляры этого класса вызываемыми.
Что-то, где-то, во время создания нашего класса, замечает этот метод __call__
и связывает его со слотом tp_call
../Objects/typeobject.c
: type_new
— сложная, важная функция — это и есть то место, где всё это происходит. Мы подробнее познакомимся с этой функцией в следующем посте, а сейчас обратим внимание на строку почти в самом конце, после того, как новый тип уже был создан, но перед его возвращением: fixup_slot_dispatchers(type);
. Эта функция пробегается по всем корректно названным методам, определённым в новом типе, и связывает их с нужными слотами в структуре типа, основываясь на именах методов (но где хранятся эти методы?).
Ещё один непонятный момент: каким образом определение метода __call__
в типе после его создания делает экземпляры этого типа вызываемыми, даже если они были инстанциированы до определения метода? Легко и просто, мои друзья. Как вы помните, тип — это объект, а тип типа — type
(если у вас разрывается голова, выполните: >>> class Foo(list): pass ; type(Foo)
). Поэтому, когда мы делаем что-то с классом (можно было бы писать и слово тип вместо класса, но т.к. «тип» мы используем в другом контексте, давайте будем некоторое время называть наш тип классом), например, вызываем, вычитаем или определяем атрибут, разыменовывается поле ob_type
объекта класса, и обнаруживается, что тип класса — type
. Затем для установки атрибута используется слот type->tp_setattro
. То есть класс, может иметь отдельную функцию установки атрибутов. И такая специфичная функция (если хотите зафрендить её на фейсбуке, вот её страничка —./Objects/typeobject.c
: type_setattro
) вызывает ту же самую функцию (update_one_slot
), которую используетfixup_slot_dispatchers
для урегулирования всех вопросов после определения нового атрибута. Вскрываются новые детали!
На этом, наверное, стоит закончить введение в объекты Питона. Надеюсь, поездка доставила вам удовольствие, и вы до сих пор со мной. Должен признать, что писать этот пост оказалось гораздо сложнее, чем я предполагал (и без помощи Antoine Pitrou и Mark Dickins поздней ночью на #python-dev
я бы скорее всего сдался!). У нас осталось ещё много интересного: какой слот операнда используется в бинарных операциях? Что происходит при множественном наследовании, и что насчёт тех жуткихмельчайших деталях, связанных с ним? А что с метаклассами? А __slots__
и слабые ссылки? Что творится во встроенных объектах? Как словари, списки, множества и их собраться выполняют свою работу? И напоследок, что насчётэтого чуда?
>>> a = object() >>> class C(object): pass ... >>> b = C() >>> a.foo = 5 Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'object' object has no attribute 'foo' >>> b.foo = 5 >>>
Каким образом можно просто так добавить произвольный атрибут в b
, экземпляр класса C
, который наследуется от object
, и нельзя сделать то же самое с a
, экземпляром того же самого object
? Знающие могут сказать: у b
есть __dict__
, а у a
нет. Да, это так. Но откуда тогда взялась эта новая (и совершенно нетривиальная!) функциональность, если мы её не наследуем?
Ха! Я безумно рад таким вопросам! Ответы будут, но в следующем эпизоде.
Небольшой список литературы для любопытствующих:
- документация по модели данных (питонячья сторона силы);
- документация C-API по абстрактным и конкретным объектом (сишная сторона силы);
- descrintro, или Унификация типов и классов в Python 2.2, длинная, мозговыносящая и чрезвычайно важная археологическая находка (считаю, что её следует добавить в интерпретатор в качестве пасхалки, предлагаю
>>> import THAT
); - но прежде всего этот файл —
./Objects/typeobject.c
. Читайте его снова и снова, до тех пор, пока в слезах не рухнете на кровать.
Приятных сновидений.