1

我几乎完成了对 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 thePyTypeObject forNewStyleClass . I will writeob asNewStyleClass_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,谢谢 Eli!)

type_call 本质上是:

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

我们的 C++ 包装器已将函数插入到类似这样的东西的tp_newtp_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++ 类实例。(为什么?解释如下,见“X”)。为此,我们使用:

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

现在这座桥提出了一个问题。我非常怀疑这种设计。

请注意如何为这个新的 PyObject 分配内存:

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

然后我们将 this 指针类型转换为,并使用紧随其后Bridge的 4 或 8 ( ) 个字节指向相应的 C++ 类实例(如上所示,这被连接起来了)。sizeof(void*)PyObjectextension_object_init

现在要让它工作,我们需要:

a)subtype->tp_alloc(subtype,0)必须分配一个额外的sizeof(void*)字节 b)PyObject不需要任何超出 的内存sizeof(PyObject_HEAD),因为如果它这样做了,那么这将与上述指针冲突

在这一点上我的一个主要问题是:我们能否保证PyObjectPython 运行时为我们创建的derived_instance不会与 Bridge 的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-sNewStyleClass_PyTypeObject永远不需要额外分配的内存。

这意味着整个Bridge机制是不必要的。

PyCXX 的原始技术使用 PyObject 作为 的基类NewStyleClassCXXClass,并初始化这个基类,以便 Python 运行时的 PyObjectd = Derived()实际上是这个基类,这种技术看起来不错。因为它允许无缝类型转换。

每当 Python 运行时NewStyleClass_PyTypeObjectNewStyleClassCXXClass. <-- 'X'(上面引用过)

所以真的我的问题是:我们为什么不这样做呢?从强制为 PyObject 进行额外分配的派生有什么特别之处NewStyleClass吗?

我意识到我不理解派生类的创建顺序。伊莱的帖子没有涵盖这一点。

我怀疑这可能与以下事实有关

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

^ 这个变量名是'subtype' 我不明白这个,我想知道这是否可能是关键。

编辑:我想到了为什么 PyCXX 使用 sizeof(FinalClass) 进行初始化的一种可能解释。它可能是一个经过尝试和放弃的想法的遗物。即,如果 Python 的 tp_new 调用为 FinalClass(它以 PyObject 作为基础)分配了足够的空间,也许可以使用“placement new”或一些巧妙的 reinterpret_cast 业务在该确切位置生成一个新的 FinalClass。我的猜测是这可能已经尝试过了,发现会造成一些问题,解决了问题,然后遗物就被抛在了后面。

4

2 回答 2

2

下面是一个小的 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

to的subtype参数tp_new是正在创建的对象的类型 ( derived),它派生自我们的 C 类型 ( _xxx.xxx),而 C 类型又派生自object。基数object的大小为 16,也就是PyObject_HEAD,该xxx类型为其dummy成员增加了 400 个字节,总共 416 个字节,derivedPython 类增加了 16 个字节。

因为subtype->tp_basicsize将层次结构的所有三个级别( 和 )的大小object合计xxxderived432 字节,所以分配了正确数量的内存。

于 2014-12-27T03:22:27.347 回答
1

PyCXX 不复杂。它确实有两个错误,但无需对代码进行重大更改即可轻松修复它们。

在为 Python API 创建 C++ 包装器时,会遇到一个问题。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 异常。

我个人认为这种方法对我自己的应用程序的开销太高,但这是一种合法的方法。我有我自己的围绕 Python C/API 的 C++ 包装器,它在 中进行所有初始化tp_new,这也是有缺陷的。似乎没有一个好的解决方案。

于 2014-12-27T03:18:32.167 回答