Python изнутри. Объекты. Начало

0b522bbd67604b71187629990ede7ef4

Как я и писал в предыдущем эпизоде (который, кстати, оказался успешным; спасибо всем, ваши просмотры и комментарии буквально заставляют меня двигаться дальше!) – сегодняшний пост посвящён реализации объектов в Python 3.x. Поначалу я думал, что это простая тема. Но даже когда я прочитал весь код, который нужно было прочитать перед тем, как написать пост, я с трудом могу сказать, что объектная система Питона… гхм, «простая» (и точно не могу сказать, что до конца разобрался в ней). Но я ещё больше убедился, что реализация объектов — хорошая тема для начала. В следующих постах мы увидим, насколько она важна. В то же время, я подозреваю, мало кто, даже среди ветеранов Питона, в полной мере в ней разбирается. Объекты слабо связаны со всем остальным Питоном (при написании поста я мало заглядывал в ./Python и больше изучал ./Objects и ./Include). Мне показалось проще рассматривать реализацию объектов так, будто она вообще не связана со всем остальным. Так, будто это универсальный API на языке C для создания объектных подсистем. Возможно, вам тоже будет проще мыслить таким образом: запомните, всё это всего лишь набор структур и функций для управления этими структурами.

Всё в Питоне — объект: числа, словари, пользовательские и встроенные классы, стековые фреймы и объекты кода. Чтобы указатель на участок памяти можно было считать объектом, необходимы как минимум два поля, определённые в структуре./Include/object.hPyObject:

typedef struct _object {
    Py_ssize_t ob_refcnt;
    struct _typeobject *ob_type;
} PyObject;

Многие объекты расширяют эту структуру, добавляя необходимые поля, но эти два поля должны присутствовать в любом случае: счётчик ссылок и тип (в специальных отладочных сборках добавляется пара загадочных полей для отслеживания ссылок).

Счётчик ссылок — это число, показывающее, сколько раз другие объекты ссылаются на данный. В коде >>> a = b = c = object() инициализируется пустой объект и связывается с тремя разными именами: ab и 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.hPyTypeObject. Я рекомендую обращаться к нему по ходу чтения статьи. Многие поля объекта типа называются слотами и указывают на функции (или на структуры, указывающие на родственные функции), которые будут выполнены при вызове функции 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.cbinary_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.ctype_new — сложная, важная функция — это и есть то место, где всё это происходит. Мы подробнее познакомимся с этой функцией в следующем посте, а сейчас обратим внимание на строку почти в самом конце, после того, как новый тип уже был создан, но перед его возвращением: fixup_slot_dispatchers(type);. Эта функция пробегается по всем корректно названным методам, определённым в новом типе, и связывает их с нужными слотами в структуре типа, основываясь на именах методов (но где хранятся эти методы?).

Ещё один непонятный момент: каким образом определение метода __call__ в типе после его создания делает экземпляры этого типа вызываемыми, даже если они были инстанциированы до определения метода? Легко и просто, мои друзья. Как вы помните, тип — это объект, а тип типа — type (если у вас разрывается голова, выполните: >>> class Foo(list): pass ; type(Foo)). Поэтому, когда мы делаем что-то с классом (можно было бы писать и слово тип вместо класса, но т.к. «тип» мы используем в другом контексте, давайте будем некоторое время называть наш тип классом), например, вызываем, вычитаем или определяем атрибут, разыменовывается поле ob_type объекта класса, и обнаруживается, что тип класса — type. Затем для установки атрибута используется слот type->tp_setattro. То есть класс, может иметь отдельную функцию установки атрибутов. И такая специфичная функция (если хотите зафрендить её на фейсбуке, вот её страничка —./Objects/typeobject.ctype_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. Читайте его снова и снова, до тех пор, пока в слезах не рухнете на кровать.

Приятных сновидений.