среда, января 13, 2016

CPython для самых маленьких: объекты и их атрибуты

В прошлый раз мы начали обсуждать бекенд CPython - все то, что стоит за транслятором текста в байткод. Бекенд этот имеет как минимум два выраженных слоя - стековую машину и низкоуровневый фреймворк моделирования простых сущностей, изображающих встроенные и пользовательские типы данных и их инстансы. Между этими двумя слоями лежит тонкая прослойка API функций, которыми пользуется как сама машина для доступа к самому нижнему слою, так и разработчики расширений, написанных на C. Таким образом стековая машина и слой API функций выполняют роль интерфейса бекенда - первая задает модель вычислений, второй же - абстрагирует компоненты низлежащего слоя. Настоящая же работа происходит на самом нижнем слое - слое объектов о котором сегодня и поговорим.

Центральная идея
В свое время меня удивило то, что многие решения, наблюдаемые в современном питоне были приняты в очень бородатые годы. Знакомьтесь, это CPython 1.0.1, для желающих его собрать вот инструкция. Уже тогда BDFLу было понято не только то, что питон должен быть ООП языком с классами (пользовательские классы были тогда совсем другими, впрочем), но и то, как это сделать, причем реализация в корне не изменилась до сих пор. Заключается она вот в чем - все объекты, операбельные питоном, расширяют тип PyObject, который грязные хиппи девяностых (вроде так всё было) называли просто object, а потом переименовали из-за конфликта имен с чем-то виндовым. Сейчас он выглядит так:
typedef struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt;
    struct _typeobject *ob_type;
} PyObject;
Вот такой крошечный структ на два поля, макрос _PyObject_HEAD_EXTRA в релизных сборках обычно разворачивается в ничто. Поле ob_refcnt - это счетчик ссылок на этот объект, используется в gc, ob_type - ссылка на тип объекта. Тип имеет смысл описателя поведения объекта, и сам по себе он не обязан быть чем то питоновским, типы могли бы быть реализованы как чисто сишный хардкод где то в недрах интерпретатора. Но если бы это было так, программирование на питоне стало бы довольно скучным занятием - даже простое наследование стало бы невозможным. В результате инсайт заключается в том, что мы скорее договорились до объктно-классовой интерпретации, в то время как на самом деле есть только объекты (всевозможные расширения PyObject) ссылающиеся на другие объекты (и даже на себя) как на типы. Поскольку у всех объектов по определению есть тип, и все типы нарочно реализованы как объекты мы естественно приходим к знаменитому "в питоне всё - объект некоторого класса".

Задраить люки!
Писать статью дальше было довольно сложно. Штука в том, что исследуя что-то для себя новое ты неизбежно проходишь множество тупиковых путей. Понятное дело, что такое изучение может затянуться на значительное время, зато в результате картина исследуемого объекта становится более объемной. И вот однажды ты думаешь "хэй, да я же могу написать об этом статью в бложике" и удивляешься тому, насколько сложно проложить для другого человека дорогу вдоль которой изучаемый объект делал бы максимум смысла, если вы понимаете о чем я. В твоей копилке просто нет такого пути, и всё что ты можешь сделать - это построить его плохонькую аппроксимацию. Как и в прошлый раз в качестве такой аппроксимации мы просто что-нибудь запустим и попытаемся расслышать шепот из под капота CPython.

Теперь, когда фундаментальные основы слоя объектов установлены мы можем рассмотреть отношения объект-класс в непосредственной близости. Мы ещё не касались тонкостей анатомии объектов (да и не будем в этот раз, как-то это утомительно), пока нам достаточно того, что это структы, расширяющие PyObject. Давайте рассмотрим поиск атрибута у объекта. Дизассемблируем простой лукап, вроде foo.bar
>>> import dis
>>> dis.dis("foo.bar")
  1           0 LOAD_NAME                0 (foo)
              3 LOAD_ATTR                1 (bar)
              6 RETURN_VALUE
В момент исполнения по имени foo интерпретатор попытается загрузить на связанный с текущим фреймом value stack некоторый объект. Для этого сначала будет просмотрен словарь локального окружения, затем глобального и, напоследок, встроенного (builtin). У каждого фрейма эти словари свои. Если такой лукап не окончился ошибкой, то далее будет выполнен опкод LOAD_ATTR, вот соответствуюший отрывок из PyEval_EvalFrameEx:
TARGET(LOAD_ATTR) {
    PyObject *name = GETITEM(names, oparg);
    PyObject *owner = TOP();
    PyObject *res = PyObject_GetAttr(owner, name);
    Py_DECREF(owner);
    SET_TOP(res);
    if (res == NULL)
        goto error;
    DISPATCH();
}
Из специального массивчика имен, который лежит в объекте кода и доступен в питоне как атрибут co_names, по индексу 1 (см. дизассемблерный листинг) забирается строчка "bar", после чего она "лукапится" у объекта owner специализированной C-API функцией PyObject_GetAttr.  Она является частью abstract objects layer. Этот "слой" (логический слой C-API) представлен шестью группами функций или, как их называет дока, протоколами. За счет использования протоколов стековая машина в значительной мере изолирована от слоя объектов. Реализация этой функции так же довольно абстрактна: берем тип объекта (переменная tp) и уже у него просим достать из объекта атрибут.
PyObject *
PyObject_GetAttr(PyObject *v, PyObject *name)
{
    PyTypeObject *tp = Py_TYPE(v);
 
    if (tp->tp_getattro != NULL)
        return (*tp->tp_getattro)(v, name);
 
    PyErr_Format(PyExc_AttributeError,
                 "'%.50s' object has no attribute '%U'",
                 tp->tp_name, name);
    return NULL;
}
Важно понимать, что такое делегирование запроса в тип не является особенностью именно этой API функции. Объект выражает лишь конкретное значение, в то время как его поведение определено в типе.
Все типы - это расширения базового типа PyTypeObject, который, конечно, тоже является PyObject`ом. Так, класс всех классов type - это лишь очередная реализация PyTypeObject, наряду с остальными, причем его PyObject->ob_type смотрит на себя, так что он сам себе класс. Шаблон, которым и является PyTypeObject - это здоровенный структ, состоящий преимущественно из указателей на сишные функции. В терминах местной тусовки подобные члены структа называются слотами. Слоты - любопытнейшая штука, многие из них, хотя и не все, отображаются в некоторый magic атрибут класса, навроде __call__, __new__ или __add__. Магия заключается в том, что несмотря на их низкоуровневость, слотами можно рулить из питона, причем в динамике:
class A: pass
>>> A.__call__ = lambda self: print('HelloWorld')
>>> A()()
HelloWorld
>>> A.__call__ = lambda self: print('Some other text, maybe')
>>> A()()
Some other text, maybe
Как именно это происходит мы будем говорить в следуюший раз, для этого нам понадобится пониманием механизмов, стоящих за встроенным метаклассом type. Вернемся к слоту tp_getattro, в питоне он отображается в атрибут __getattribute__, так что в общем случае его значение определено только в рантайме. Тем не менее у него есть стандартная реализация, которую использует большинство встроенных типов. Давайте поймаем её в отладчике, будем реверсить такой код:
a = A()
stop()  # мы обсудили эту функцию в прошлый раз
a.b
Сразу после SIGTRAP, сгенериррованного вызовом stop() ставим новый брейк
(gdb) b PyObject_GetAttr
Breakpoint 1 at 0x4cbb21: file Objects/object.c, line 864.
и даем интерпретатору до него добраться
(gdb) cont
В месте остановки (мы внутри PyObject_GetAttr в Си и на строчке a.b в питоне) мы имеем доступ к локальным переменным, возьмем тип объекта a
(gdb) p tp
(PyTypeObject *) 0x9f6458
По адресу 0x9f6458 лежит класс A, осталось лишь заглянуть ему под днище
(gdb) p ((PyTypeObject *) 0x9f6508)->tp_getattro
(getattrofunc) 0x4cc71d <PyObject_GenericGetAttr>
Итого видим, что дефолтовая реализация tp_getattro - это функция PyObject_GenericGetAttr, имеющая разочаровывающе предсказуемое имя. Чуть ниже мы рассмотрим её подробнее, оказывается, что её устройство имеет фундаментальные корни. Пока подведем мини-итог:
1. Объекты и их типы представлены одной и той же базовой сущностью PyObject. Связь между ними заключается в поле ob_type объекта и куче сишного хардкода в типе.
2. Поведение любого объекта всегда определяется его типом, даже если он сам является типом.
3. Каждый тип определяет некоторое множество слотов - функций, определяющих реакцию его инстансов на те или иные внешние раздражители.
Убедимся, что для написания простейших программ нам не нужен ни сам питон, ни стековая машина. Давайте вычислим значение выражения 123 + 456.
Создаем новые PyLongObject - объекты класса int в питоне, соответствуюшие числам 123 и 456:
(gdb) call PyLong_FromLong(123)
(PyObject *) 0x9836e0
(gdb) call PyLong_FromLong(456)
(PyObject *) 0x7ffff7f64e50
Теперь попросим объект PyLong_Type их сложить, дернув соответствующий слот:
(gdb) call PyLong_Type->tp_as_number->nb_add(0x9836e0, 0x7ffff7f64e50)
(PyObject *) 0x7ffff7f64ea0
В результате в памяти по адресу 0x7ffff7f64ea0 лежит результат - новый инстанс класса int, попринтим его что-ли:
(gdb) call PyObject_Print(0x7ffff7f64ea0, stderr, 1)
579
Похоже на то, что 123 + 456 = 579. Если эту задачу отдать типу PyComplex_Type, то в результате получится (579+0j), как и ожидается.

Атрибуты
Пример, рассмотренный выше, может показаться местячковым, ну подумаешь лукап всякого из объекта. Однако не стоит забывать, что любое вычисление - это чтение, обработка и запись  данных, которые в питоне хранятся в виде атрибутов объектов. Более того, оказывается, что эти механизмы неявно используются повсеместно, так без них невозможна правильная работа функций. Работа поиска атрибутов крутится вокруг результата функции _PyType_Lookup, на диаграмме ниже показан путь, по которому проходит её результат descr
azaza (3).png
Именно _PyType_Lookup реализует правильный обход линеаризации графа наследования (о том что это, поговорим в следующий раз). Результатом является указатель на питоновский объект или null. Дальше, согласно диаграмме, если тип объекта descr реализует слоты tp_descr_get & tp_descr_set (последнее будет проверено в is_data?), то такой объект нарекают дата дескриптором и возвращают результат работы его tp_descr_get слота. Если объект descr оказался null или не дата дескрипртором, то поиск атрибута происходит в дикте самого объекта. И только если атрибута в дикте не оказалось, будет возвращено значение слота tp_descr_get если он не null. Рассмотренный дата дескриптор является частной реализацией так называемого протокола дескрипторов. Произвольный класс, реализующий питоновские методы __get__, __set__ или __del__ называется дескриптором (описателем) атрибута. Наглядный пример работы дата дескрипторов представлен ниже.
Создаем класс, реализующий поведение дата дескриптора:
class DataDescriptor:
    def __get__(self, obj, klass=None):
        return "This came from descr_get."
    def __set__(self, obj, value):
        return "This is descr_set speaking."
Создаем пользователя этого дескриптора
class Foo:
    bar = DataDescriptor()
Атрибут bar в классе теперь контролируется дескриптором
>>> print(Foo.bar)
This came from descr_get.
так же как и в инстансе
>>> f = Foo()
>>> f.bar
This came from descr_get.
В этом инстансе мы, конечно, можем хранить другое значение для bar
>>> f.__dict__["bar"] = "This came from instance."
>>> f.__dict__['bar']
This came from instance.
Но PyObject_GenericGetAttr предпочитает этого не замечать
>>> f.bar
This came from descr_get.
Заметьте, что этот пример работает только для дата дескрипторов, т.е. помимо __get__ мы должны иметь ненулевой __set__. Выбросим последний и чуда не случится.
И тут мы вплотную подходим к самому интересному - к пониманию того, что дескрипторы являются универсальным клеем внутри питона, на котором висят все magic атрибуты и многое другое. Рассмотрим довольно типичный код:
>>> class SomeClass: pass
>>> s = SomeClass()
>>> s.foo = 10
>>> s.__dict__
{'foo': 10}
>>> s.__dict__['bar'] = 20
>>> s.bar
20
Не знаю как у вас, а у меня такое поведение вызывает массу вопросов, хотя бы вот:
1. как атрибуты, доступные через точку оказываются в дикте объекта;
2. почему изменяя какой то там дикт, пусть на него даже есть ссылка в объекте, мы изменяем атрибуты объекта;
3. что вообще такое __dict__ в конце концов, я такой атрибут не просил и почему s.__dict__ != SomeClass.__dict__, в то время как для других атрибутов ожидается противоположное. What kind of black sorcery is this?
Ну что же, %username%, думаю, ты уже все понял, __dict__ - это дескриптор, который нашла _PyType_Lookup в слоте tp_dict самого SomeClass. Чтобы копать дальше, хотелось бы взглянуть на __dict__ класса SomeClass, но мы уже поняли, что вокруг обман, так что для начала проверим слот как есть, SomeClass у меня лежит в 0x9f6808:
(gdb) call PyObject_Print(((PyTypeObject *) 0x9f6808)->tp_dict, stderr, 0)
{..., '__dict__': <attribute '__dict__' of 'SomeClass' objects>, ...}
Ключи этого дикта в точности совпадают с ключами в SomeClass.__dict__, хотя тип последнего и не dict, а некий mapping_proxy, это нас устраивает:
>>> SomeClass.__dict__['__dict__']
<attribute '__dict__' of 'SomeClass' objects>
>>> type(SomeClass.__dict__['__dict__'])
<class 'getset_descriptor'>
Получается что атрибут __dict__ инстансов класса SomeClass контролируется дескриптором getset_descriptor. Мы можем дернуть его __get__, чтобы получить __dict__ объекта s:
>>> SomeClass.__dict__['__dict__'].__get__(s, SomeClass)
{'foo': 10}
Достигается это за счет того, что внутри объекта s и правда есть ссылка на питоновский dict, лежащий где-то в памяти. Положение этой ссылки внутри объекта знает класс SomeClass - в слоте tp_dictoffset в явном виде лежит смещение внутри памяти объекта s в байтах, чем и пользуется getset_descriptor. Если бы в tp_dict класса SomeClass по ключу __dict__ был бы обычный питонячий __dict__, пункт 2 не мог бы быть правдой, давайте в этом тоже убедимся:
>>> import ctypes
>>> pointer = ctypes.cast(
      id(SomeClass)+type.__dictoffset__, ctypes.POINTER(ctypes.py_object)
    )
>>> pointer[0]
# pointer[0] это то, что лежит в tp_dict в явном виде, мы обошли стороной все трюки дескрипторов
{'__dict__': <attribute '__dict__' of 'SomeClass' objects>, ...}
Суем вместо дескриптора пустой дикт:
>>> pointer[0]['__dict__'] = {}
>>> s.__dict__['tar'] = "may there be light"
Видим, что отображение дикта в атрибуты больше не работает, чтд
>>> s.tar
AttributeError: 'SomeClass' object has no attribute 'tar'
Рассмотренное выше является лишь каплей в море, сами зацените остальные magic`и. Я понимаю, что эти технические подробности вряд ли будут полезны сами по себе. Задача этой секции заключалась в том, чтобы дать ощущение некой нелокальности CPython. Так, мы увидели, что попросить атрибут у объекта в питоне совсем не то же самое, что достать поле из сишного структа, где этот процесс очень прямолинейный. Напротив, даже простые действия в CPython разбиты на множество независимых ступеней, относящихся при этом к разным слоям абстракций, на каждом из которых реализацию можно подогнать под свои нужды.

Функции как дескрипторы
Рассмотрим еще более жизненный пример использования дескрипторов в CPython. Вы, должно быть, знаете о том, что PyFunc_Type (в питоне types.FunctionType - класс функций) реализует свойства дескрипторов. Нужно это для того, чтобы красиво вклинить реализацию методов объектов, методов классов, статических методов и пропертей в общую канву, наряду с простыми функциями. Вот отрывок из реализации класса функций
PyTypeObject PyFunction_Type = {
    PyVarObject_HEAD_INIT(&amp;PyType_Type, 0)
    "function",
    ...
    func_descr_get,             /* tp_descr_get */
    0,                          /* tp_descr_set */
    ...
};
Видно, что реализована только часть протокола дескриптора - отсутствует set. Это значит, что в инстансе класса можно перекрыть метод:
class SomeClass:
    def foo(self):
        return "InClass implementation"
 
s = SomeClass()
s.foo = lambda self: "InInstance implementation"
 
>>> s.foo(s)
InInstance implementation
Обратите внимание, что s.foo баунд методом так и не стала - ей нужно явным образом передавать self. Теперь, когда мы знаем как работает PyObject_GenericGetAttr такое поведение становится самоочевидным. Идем дальше, заглянем в tp_descr_get функции.
static PyObject *
func_descr_get(PyObject *func, PyObject *obj, PyObject *type)
{
    if (obj == Py_None || obj == NULL) {
        Py_INCREF(func);
        return func;
    }
    return PyMethod_New(func, obj);
}
Иными словами, если функцию попросили у типа (т.е. obj == Py_None), то в ответ мы получим её саму в первозданном виде. Именно поэтому методы в тройке (щас доберемся до модифицирующих декораторов) при лукапе из класса выглядят обычными функциями - они и есть обычные функции. Если же obj != Py_None, то возвращается PyMethodObject, его собирает PyMethod_New из функции func и инстанса obj. Слот tp_call типа метода оборочивает вызов фунции func, автоматически подставляя туда первым аргументом obj. Как только мы осознаем всё это, вопросы об устройстве декораторов вроде classmethod и пр. отпадают сами собой (ну мне так кажется). И да, classmethod - это класс, можете наследоваться от него. Более того, само его наличие в стандартной поставке решает, скорее, вопрос производительности, вы можете реализовать все фичи classmethod и компании на чистом питоне.
Теперь убедимся, что любая функция может быть использована как метод любого класса или объекта, например "приделаем" к десятке метод factorial
def factorial(self):
    return 1 if self == 1 else factorial(self-1) * self
 
ten_factorial = factorial.__get__(10, int)
Несмотря на всю нелепость происходящего, ten_factorial - это вполне себе нормальный метод объекта 10:
>>> ten_factorial
<bound method int.factorial of 10>
 
>>> ten_factorial()
3628800

Подведем итоги
1. Классы и их инстансы - это структы, расширяющие PyObject.
2. В результате этого все объекты питона без исключения имеют тип (класс).
3. Поведение любого объекта определяется его типом, один и тот же объект будет вести себя по-разному в зависимости от навязанного типа (это можно сделать в gdb).
4. Высокоуровневое поведение объектов весьма неявно мапится в их низкоуровневую реализацию. Вы никогда не видите объекты такими, какие они есть на самом деле, любое представление объекта в питоне - это результат работы какого то кода (возможно очень развесистого).
5. Большую роль в жизни объектов играют дескрипторы, которым делегируется взаимодействие с атрибутами.

В следующие разы, мы обсудим работу наследования типов, работу метаклассов, super, флаги классов - tp_flags, роль класса object и мб что-то ещё, пишите в коментариях. Надеюсь это было кому то полезно, однако тут без личного погружения в пучины вод не разобраться. Спасибо за ваше время.