суббота, апреля 04, 2015

Немного метамагии, или как работает class statement.

Говоря о метапрограммировании в python чаще всего подразумевают использование декораторов или метаклассов, иногда сюда приплетают импорт хуки и даже генерацию питоновского кода с последующим execом его в целевом неймспейсе (классе, например). Все это - вполне себе production ready приемы, так, например, реализацию питоновских ORM движков сложно себе представить без метаклассов и дескрипторов. Такие библиотеки как pytest вообще невозбранно хачат весь ваш код, что особенно радует разработчиков плагинов, когда дело доходит до дебага.


Собирай это!

Давайте спустимся с небес на землю. Скажите, знаете ли вы, что делает компилятор, когда видит class statement? Для простоты дизассемблируем объявление пустого класса:
>>> import dis
... dis.dis('class MyClass: pass')
  1           0 LOAD_BUILD_CLASS
              1 LOAD_CONST               0 (<code object MyClass at ...>)
              4 LOAD_CONST               1 ('MyClass')
              7 MAKE_FUNCTION            0
             10 LOAD_CONST               1 ('MyClass')
             13 CALL_FUNCTION            2 (2 positional, 0 keyword pair)
             16 STORE_NAME               0 (MyClass)
             19 LOAD_CONST               2 (None)
             22 RETURN_VALUE
Первым выполняется некий не имеющий аргументов опкод LOAD_BUILD_CLASS. Затем создается функция с именем MyClass и кодом <code object MyClass at ...>. Дальше небольшая тонкость: опкод со сдвигом тринадцать (вызов функции) не может быть связан с функцией MyClass, ведь ей доступен только один аргумент – строчка 'MyClass', в то время как дизассемблер утверждает, что вызов произойдет с двумя аргументами. Так что единственный вариант, который мы можем предположить – это то, что LOAD_BUILD_CLASS грузит на стек некую специальную функцию, которая принимает в качестве первого аргумента функцию, идейно эквивалентную телу класса и строку – название класса (и, возможно, что то еще). Результат вызова этой функции и есть готовый класс, который мы заботливо кладем в переменную MyClass (опкод со сдвигом 16). Все здорово, но что это за таинственная функция, создающая классы? Это не может быть type, не та сигнатура.
Разрешить эту загадку в общем-то довольно легко, достаточно заглянуть в исходники интерпретатора. В файле Python/ceval.c лежит функция PyEval_EvalFrameEx – сердце рантайма питона. Именно тут происходит выполнение тех инструкций, которые сгенерились из вашего питоновского кода в процессе компиляции. Я немного подкоротил код обработчика LOAD_BUILD_CLASS, он выглядит так:
TARGET(LOAD_BUILD_CLASS) {
    PyObject *bc;
    bc = _PyDict_GetItemId(f->f_builtins, &PyId___build_class__);
    PUSH(bc);
    DISPATCH();
}
Или, говоря по-русски – возьми из builtins функцию __build_class__ и положи на value stack. Скажите честно, кто из вас знал о том, что в билтинах есть такая функция? Я так и думал :-)
Ок, теперь зная куда копать, мы можем вдоволь повеселиться. Давайте ее немного перегрузим, ну например так
>>> __builtins__['__build_class__'] = lambda *a, **kwa: print(a, kwa)
... class MyClass(int, str, metaclass=type, foo='bar'):
...     def __init__(self):
...         pass
(<function MyClass at ...>, 'MyClass', <class 'int'>, <class 'str'>)
{'metaclass': <class 'type'>, 'foo': 'bar'}
Таким образом справедлива такая распаковка:
func_object, class_name, *bases = args    (1)
class_kwargs = kwargs                     (2)
где class_kwargs – это все именованные аргументы в объявлении класса, значит даже metaclass – не более чем очередной kwarg и мы вправе обработать его как нам угодно.

Use cases.

Для дальнейших манипуляций нам понадобится простенькая инфраструктура, я подготовил такую тут. В простейшем виде наш тестовый cтенд будет выглядеть так:
def callback(builder, *args, **kwargs):
    return builder(*args, **kwargs)

with wonderland(callback):
    class MyClass:
        pass
Внутри блока with будем объявлять классы, задача по их сборке будет делегирована функции callback, возвращать она должна уже готовый класс. Аргумент builder – это оригинальный __build_class__, *args и **kwargs – это все, что этому билдеру было бы передано в ванильном случае, так что builder(*args, **kwargs) – это и есть класс, собранный обычным образом.
Перед нами открылся невиданный простор для экспериментов. Давайте, например, посмотрим на func_object из (1).Функция эта сама по себе никому не нужна, она лишь служит оберткой для кода. Что за код такой? Заведем callback:
def callback(builder, *args, **kwargs):
    func, *_ = args
    print(func.__code__.co_consts)
    return builder(*args, **kwargs)
Добавьте в MyClass пару методов и запустите. Оказывается, что методы, объявленные в классе, попадают в func.__code__.co_consts, причем сами они хранятся там в виде соответсвующих объектов кода. К нашему сожалению CodeType иммутабелен, так что кастомизировать класс путем изменения его кода мы не можем (ну ладно, можем, но это совсем неудобно). Максимум что можно сделать на этом этапе – это изменить список его базовых классов, имя, полностью пересадить у func ее код, воспользовавшись конструктором из types, либо навесить на класс декоратор. Также можно играться с уже готовым классом, просто сохранив выхлоп builder(*args, **kwargs) в локальную переменную.
Следующий шаг, сильно упрощающий нам жизнь – это прописывание классу нашего кастомного метакласса, мы ведь помним, что именованный аргумент metaclass ничем не выделяется из собратьев. Сделать это очень просто:
class MyMeta(type):
    def __new__(cls, name, bases, attrs):
        print('hello from metaclass %s' % cls.__name__)
        return super().__new__(cls, name, bases, attrs)

def callback(builder, *args, **kwargs):
    kwargs['metaclass'] = MyMeta
    return builder(*args, **kwargs)
Вот, это уже действительно что то. Думаю объяснять, что может нам дать свой, написанный с определенной целью, метакласс не нужно (или нужно? марш к Бизли!).
Тут есть как минимум две проблемы:
1. пусть в kwargs был-таки непустой метакласс, если просто заменить его своим метаклассом то мы сломаем логику приложения, так что придется динамически строить третий метакласс, сочетающий возможности обоих.
2. пусть kwargs даже пуст, метакласс отличный от type может быть у одного из родителей класса, что приведет к metaclass conflict. Это тоже лечится, но как-то фу.
Это грустно и я ничего хорошего посоветовать не могу. Механизм, который смог бы рулить всем этим добром в общем виде представляется мне довольно сложным.
К чему я это все? А к тому, что это еще один подход к метапрограммированию на питоне, ориентированный на классы. Его огромный плюс – код находящийся в блоке with не знает о том, что с ним будут что-то делать, значит это может быть чей-то чужой код. Ну и понятно, что внутри with классы можно просто импортировать из внешнего модуля, так что инструментировать код того же Django или другой большой библиотеки можно не поменяв в ней ни строчки кода.
Надеюсь, что это было кому-то интересно, теперь можете смело выпендриваться перед коллегами. Спасибо за ваше время.