Как привести в порядок / исправить создание PyCXX нового класса расширения Python?

Я почти закончил переписывать оболочку C++ Python (PyCXX).

Оригинал позволяет использовать классы расширения старого и нового стилей, но также позволяет создавать производные от классов нового стиля:

import test

// ok
a = test.new_style_class();

// also ok
class Derived( test.new_style_class() ):
    def __init__( self ):
        test_funcmapper.new_style_class.__init__( self )

    def derived_func( self ):
        print( 'derived_func' )
        super().func_noargs()

    def func_noargs( self ):
        print( 'derived func_noargs' )

d = Derived()

Код запутан и, кажется, содержит ошибки ( Почему PyCXX обрабатывает классы нового стиля так же, как и он?)

Мой вопрос: каково обоснование / обоснование извилистого механизма PyCXX? Есть ли более чистая альтернатива?

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


Когда среда выполнения Python встречает d = Derived(), оно делает PyObject_Call( ob ) where ob is the PyTypeObject for NewStyleClass . I will write О.Б. as NewStyleClass_PyTypeObject`.

Этот PyTypeObject был построен в C++ и зарегистрирован с использованием PyType_Ready

PyObject_Call вызовет type_call(PyTypeObject *type, PyObject *args, PyObject *kwds), возвращая инициализированный производный экземпляр, т.е.

PyObject* derived_instance = type_call(NewStyleClass_PyTypeObject, NULL, NULL)

Что-то вроде этого.

(Все это из ( http://eli.thegreenplace.net/2012/04/16/python-object-creation-sequence, кстати, спасибо, Элай!)

type_call делает по существу:

type->tp_new(type, args, kwds);
type->tp_init(obj, args, kwds);

И наша оболочка C++ вставила функции в tp_new а также tp_init слоты NewStyleClass_PyTypeObject что-то вроде этого:

typeobject.set_tp_new( extension_object_new );
typeobject.set_tp_init( extension_object_init );

:
    static PyObject* extension_object_new( PyTypeObject* subtype, 
                                              PyObject* args, PyObject* kwds )
    {
        PyObject* pyob = subtype->tp_alloc(subtype,0);

        Bridge* o = reinterpret_cast<Bridge *>( pyob );

        o->m_pycxx_object = nullptr;

        return pyob;
    }

    static int extension_object_init( PyObject* _self, 
                                            PyObject* args, PyObject* kwds )
    {
        Bridge* self{ reinterpret_cast<Bridge*>(_self) };

        // NOTE: observe this is where we invoke the constructor, 
        //       but indirectly (i.e. through final)
        self->m_pycxx_object = new FinalClass{ self, args, kwds };

        return 0;
    }

Обратите внимание, что нам нужно связать вместе экземпляр Python Derived и его соответствующий экземпляр класса C++. (Почему? Объяснено ниже, см. "Х"). Для этого мы используем:

struct Bridge
{
    PyObject_HEAD // <-- a PyObject
    ExtObjBase* m_pycxx_object;
}

Теперь этот мост поднимает вопрос. Я очень подозрительно отношусь к этому дизайну.

Обратите внимание, как память была выделена для этого нового PyObject:

        PyObject* pyob = subtype->tp_alloc(subtype,0);

И затем мы приведем этот указатель к Bridge и используйте 4 или 8 (sizeof(void*) байт сразу после PyObject указать на соответствующий экземпляр класса C++ (это подключается в extension_object_init как видно выше).

Теперь, чтобы это работало, нам нужно:

а) subtype->tp_alloc(subtype,0) должен выделять дополнительный sizeof(void*) байт б) PyObject не требует никакой памяти за sizeof(PyObject_HEAD), потому что если бы это было так, то это будет конфликтовать с указателем выше

На данный момент у меня есть один важный вопрос: можем ли мы гарантировать, что PyObject что среда исполнения Python создала для нашего derived_instance не пересекается с бриджем ExtObjBase* m_pycxx_object поле?

Я попытаюсь ответить на него: именно США определяют, сколько памяти будет выделено. Когда мы создаем NewStyleClass_PyTypeObject мы кормим, сколько памяти мы хотим, чтобы это PyTypeObject выделить для нового экземпляра этого типа:

template< TEMPLATE_TYPENAME FinalClass >
class ExtObjBase : public FuncMapper<FinalClass> , public ExtObjBase_noTemplate
{
protected:
    static TypeObject& typeobject()
    {
        static TypeObject* t{ nullptr };
        if( ! t )
            t = new TypeObject{ sizeof(FinalClass), typeid(FinalClass).name() };
                   /*           ^^^^^^^^^^^^^^^^^ this is the bug BTW!
                        The C++ Derived class instance never gets deposited
                        In the memory allocated by the Python runtime
                        (controlled by this parameter)

                        This value should be sizeof(Bridge) -- as pointed out
                        in the answer to the question linked above

        return *t;
    }
:
}

class TypeObject
{
private:
    PyTypeObject* table;

    // these tables fit into the main table via pointers
    PySequenceMethods*       sequence_table;
    PyMappingMethods*        mapping_table;
    PyNumberMethods*         number_table;
    PyBufferProcs*           buffer_table;

public:
    PyTypeObject* type_object() const
    {
        return table;
    }

    // NOTE: if you define one sequence method you must define all of them except the assigns

    TypeObject( size_t size_bytes, const char* default_name )
        : table{ new PyTypeObject{} }  // {} sets to 0
        , sequence_table{}
        , mapping_table{}
        , number_table{}
        , buffer_table{}
    {
        PyObject* table_as_object = reinterpret_cast<PyObject* >( table );

        *table_as_object = PyObject{ _PyObject_EXTRA_INIT  1, NULL }; 
        // ^ py_object_initializer -- NULL because type must be init'd by user

        table_as_object->ob_type = _Type_Type();

        // QQQ table->ob_size = 0;
        table->tp_name              = const_cast<char *>( default_name );
        table->tp_basicsize         = size_bytes;
        table->tp_itemsize          = 0; // sizeof(void*); // so as to store extra pointer

        table->tp_dealloc           = ...

Вы можете видеть, что это происходит как table->tp_basicsize

Но теперь мне кажется, что PyObject-ы генерируются из NewStyleClass_PyTypeObject никогда не потребует дополнительной выделенной памяти.

Что означает, что это целое Bridge Механизм не нужен.

И оригинальный метод PyCXX для использования PyObject в качестве базового класса NewStyleClassCXXClass и инициализировать эту базу так, чтобы PyObject среды выполнения Python для d = Derived() на самом деле эта база, эта техника выглядит хорошо. Потому что это позволяет бесшовную типизацию.

Всякий раз, когда среда выполнения Python вызывает слот из NewStyleClass_PyTypeObject, он будет передавать указатель на PyObject d в качестве первого параметра, и мы можем просто ввести обратно NewStyleClassCXXClass, <- 'X' (ссылка выше)

Поэтому на самом деле мой вопрос: почему бы нам просто не сделать это? Есть ли что-то особенное в выводе из NewStyleClass что вызывает дополнительное выделение для PyObject?

Я понимаю, что не понимаю последовательность создания в случае производного класса. Сообщение Элая не покрывало это.

Я подозреваю, что это может быть связано с тем, что

    static PyObject* extension_object_new( PyTypeObject* subtype, ...

^ это имя переменной 'subtype'. Я не понимаю этого, и мне интересно, может ли это содержать ключ.

РЕДАКТИРОВАТЬ: я подумал об одном возможном объяснении того, почему PyCXX использует sizeof(FinalClass) для инициализации. Это может быть пережитком идеи, которую попробовали и отбросили. То есть, если вызов Python tp_new выделяет достаточно места для FinalClass (который имеет PyObject в качестве базы), возможно, новый FinalClass может быть сгенерирован в этом точном месте с использованием 'размещения нового' или некоторого хитрого бизнеса reinterpret_cast. Я предполагаю, что это можно было попытаться найти, создать какую-то проблему, обойти, и реликвия осталась позади.

2 ответа

Решение

PyCXX не запутан. У него есть две ошибки, но их можно легко исправить, не требуя значительных изменений в коде.

При создании оболочки C++ для Python API возникает проблема. Объектная модель C++ и объектная модель нового стиля Python сильно различаются. Одно принципиальное отличие состоит в том, что в C++ есть один конструктор, который создает и инициализирует объект. Пока Python имеет два этапа; tp_new создает объект и выполняет минимальную инициализацию (или просто возвращает существующий объект) и tp_init выполняет остальную часть инициализации.

PEP 253, который вы, вероятно, должны прочитать полностью, говорит:

Разница в обязанностях между слотом tp_new() и слотом tp_init() заключается в инвариантах, которые они обеспечивают. Слот tp_new() должен обеспечивать только самые существенные инварианты, без которых код C, который реализует объекты, сломался бы. Слот tp_init() должен использоваться для перезаписываемых пользовательских инициализаций. Взять, к примеру, тип словаря. Реализация имеет внутренний указатель на хеш-таблицу, которая никогда не должна быть NULL. Об этом инварианте заботится слот tp_new() для словарей. Слот словаря tp_init(), с другой стороны, можно использовать для предоставления словарю начального набора ключей и значений на основе переданных аргументов.

...

Вы можете задаться вопросом, почему слот tp_new() не должен вызывать сам слот tp_init(). Причина в том, что в определенных обстоятельствах (например, поддержка постоянных объектов) важно иметь возможность создавать объект определенного типа, не инициализируя его дальше, чем это необходимо. Это удобно сделать, вызвав слот tp_new() без вызова tp_init(). Также возможно, что tp_init() не вызывается или вызывается более одного раза - его работа должна быть надежной даже в этих аномальных случаях.

Весь смысл оболочки C++ состоит в том, чтобы дать вам возможность написать хороший код на C++. Скажем, например, что вы хотите, чтобы у вашего объекта был элемент данных, который можно инициализировать только во время его создания. Если вы создаете объект во время tp_newто вы не сможете повторно инициализировать этот элемент данных во время tp_init, Это, вероятно, заставит вас удерживать этот элемент данных с помощью умного указателя и создавать его во время tp_new, Это делает код ужасным.

Подход PyCXX состоит в том, чтобы разделить конструкцию объекта на две части:

  • tp_new создает фиктивный объект только с указателем на объект C++, который создается tp_init, Этот указатель изначально нулевой.

  • tp_init выделяет и конструирует фактический объект C++, затем обновляет указатель в фиктивном объекте, созданном в tp_new указать на это. Если tp_init вызывается более одного раза и вызывает исключение Python.

Я лично считаю, что издержки этого подхода для моих собственных приложений слишком высоки, но это законный подход. У меня есть собственная оболочка C++ вокруг Python C/API, которая выполняет всю инициализацию в tp_new, что тоже ошибочно. Кажется, для этого нет хорошего решения.

Вот небольшой пример C, который показывает, как Python выделяет память для объектов классов, производных от типов C:

typedef struct
{
    PyObject_HEAD
    int dummy[100];
} xxx_obj;

Также необходим объект типа:

static PyTypeObject xxx_type = 
{
    PyObject_HEAD_INIT(NULL)
};

И функция инициализации модуля, которая инициализирует этот тип:

extern "C"
void init_xxx(void)
{
    PyObject* m;

    xxx_type.tp_name = "_xxx.xxx";
    xxx_type.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE;

    xxx_type.tp_new = tp_new; // IMPORTANT
    xxx_type.tp_basicsize = sizeof(xxx_obj); // IMPORTANT

    if (PyType_Ready(&xxx_type) < 0)
        return;

    m = Py_InitModule3("_xxx", NULL, "");

    Py_INCREF(&xxx_type);
    PyModule_AddObject(m, "xxx", (PyObject *)&xxx_type);
}

Чего не хватает, так это реализации tp_new: Документы Python требуют, чтобы:

tp_new функция должна вызывать subtype->tp_alloc(subtype, nitems) выделить место для объекта

Итак, давайте сделаем это и добавим несколько распечаток.

static
PyObject *tp_new(PyTypeObject *subtype, PyObject *args, PyObject *kwds)
{
    printf("xxx.tp_new():\n\n");

    printf("\t subtype=%s\n", subtype->tp_name);
    printf("\t subtype->tp_base=%s\n", subtype->tp_base->tp_name);
    printf("\t subtype->tp_base->tp_base=%s\n", subtype->tp_base->tp_base->tp_name);

    printf("\n");

    printf("\t subtype->tp_basicsize=%ld\n", subtype->tp_basicsize);
    printf("\t subtype->tp_base->tp_basicsize=%ld\n", subtype->tp_base->tp_basicsize);
    printf("\t subtype->tp_base->tp_base->tp_basicsize=%ld\n", subtype->tp_base->tp_base->tp_basicsize);

    return subtype->tp_alloc(subtype, 0); // IMPORTANT: memory allocation is done here!
}

Теперь запустите очень простую программу на Python, чтобы протестировать ее. Эта программа создает новый класс, производный от xxx, а затем создает объект типа derived,

import _xxx

class derived(_xxx.xxx):
    def __init__(self):
        super(derived, self).__init__()

d = derived()

Чтобы создать объект производного типа, Python вызовет его tp_new который в свою очередь назовет свой базовый класс '(xxx) tp_new, Этот вызов генерирует следующий вывод (точные числа зависят от архитектуры машины):

xxx.tp_new():

    subtype=derived
    subtype->tp_base=_xxx.xxx
    subtype->tp_base->tp_base=object

    subtype->tp_basicsize=432
    subtype->tp_base->tp_basicsize=416
    subtype->tp_base->tp_base->tp_basicsize=16

subtype аргумент tp_new тип создаваемого объекта (derived), это происходит от нашего типа C (_xxx.xxx), который в свою очередь происходит от object, База object имеет размер 16, который просто PyObject_HEAD, xxx тип имеет дополнительные 400 байтов для его dummy член в общей сложности 416 байтов и derived Класс Python добавляет дополнительные 16 байтов.

Так как subtype->tp_basicsize учитывает размеры всех трех уровней иерархии (object, xxx а также derived) в общей сложности 432 байта выделяется правильный объем памяти.

Другие вопросы по тегам