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

http://tech.blog.aknin.name/2010/05/12/pythons-innards-objects-101/
  • Перевод
  • Tutorial
1. Введение
2. Объекты. Голова
3. Объекты. Хвост
4. Структуры процесса

Продолжаем разбираться во внутренностях Питона. В прошлый раз мы узнали, как Питон переваривает простую программу. Сегодня начнём изучение устройства его объектной системы.

Как я и писал в предыдущем эпизоде (который, кстати, оказался успешным; спасибо всем, ваши просмотры и комментарии буквально заставляют меня двигаться дальше!) – сегодняшний пост посвящён реализации объектов в 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, которое переопределит структуру PyTypeObject и заполнит слоты так, как вам хочется. Когда мы создаём новые типы в Питоне (>>> 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. Читайте его снова и снова, до тех пор, пока в слезах не рухнете на кровать.

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



Помните! Мы всегда рады встрече с заинтересованными людьми.
Метки:
Буруки 26,80
Компания
Поделиться публикацией
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Похожие публикации
Комментарии 13
  • +4
    Необычный подход изучения языка)
    А можно такой же, скажем, для JavaScript на примере V8?
  • +2
    У каждого объекта всего один тип, который не меняется в течение жизни объекта (тип может поменяться в чрезвычайно редких обстоятельствах. Для этой задачи не существует API, и вы вряд ли читали бы эту статью, если бы работали с объектами с изменяющимися типами)

    В большинстве же случаев можно ведь присваивать магическому атрибуту __class__ новый тип, чем не API? Причем Питон проверяет совместимость memory layout объектов старого и нового типов (то есть такой трюк не сработает, например, для tuple или если один из типов имеет пользовательские слоты), и после смены типа объект действительно ведет себя по-новому.

    Или все-таки я чего-то недопонимаю, и даже в этом случае ob_type продолжает указывать на первоначальный тип?

    P.S. Спасибо за интересные статьи, продолжайте в том же духе! :)
    • +3
      Сам спросил, сам отвечаю (./Objects/typeobject.c :: object_set_class):
      ...
        3357     if (compatible_for_assignment(oldto, newto, "__class__")) {
        3358         Py_INCREF(newto);
        3359         Py_TYPE(self) = newto;
        3360         Py_DECREF(oldto);
        3361         return 0;
        3362     }
      ...
      

      Где Py_TYPE (./Include/object.h):
         117 #define Py_TYPE(ob)             (((PyObject*)(ob))->ob_type)
      

      Все по-настоящему, успешное присваивание __class__ меняет тип объекта.
      • +2
        И обрекает разработчика на казнь…
        • 0
          Да, документация не рекомендует так делать.
          Like its identity, an object’s type is also unchangeable.
          It is possible in some cases to change an object’s type, under certain controlled conditions. It generally isn’t a good idea though, since it can lead to some very strange behaviour if it is handled incorrectly.
          Можете рассказать, что плохого может случиться? Когда можно, а когда не стоит менять тип объекта?
          • +2
            Можете рассказать, что плохого может случиться?
            Могут выкинуть из окна, например. Или посадить на кол… После недели поисков того, почему у объекта внезапно сменился родитель, программист способен на многое.
      • +1
        Или все-таки я чего-то недопонимаю, и даже в этом случае ob_type продолжает указывать на первоначальный тип?
        Насколько я понимаю, ob_type остается прежним. Был у меня на эту тему вопрос с самоответом.
        • 0
          В вашем случае вероятнее всего прокси переопределяет геттер для __class__, тип при этом действительно не меняется.

          Попробуйте в консоли:
          >>> class A(object): pass
          ... 
          >>> class B(object): pass
          ... 
          >>> a = A()
          >>> a.__class__ is type(a) is A
          True
          >>> a.__class__ = B
          >>> a.__class__ is type(a) is B
          True
          
      • 0
        Добрый день!
        Планируются ли статьи, разбирающие встроенные типы данных Python и/или работу интерпретатора по оптимизации кода?
        Спасибо.
      • +1
        Я правильно понял, что диспетчеризация по предопределённым методам (__eq__, __gt__, __call__ и т.п.) не динамическая, а по всем остальным методам работает через хеш-таблицу?
        В общем, вопрос вызова методов объекта foo.bar() не затронут, а там должно быть интересно.

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

      Самое читаемое