cpython c++ binding
文章目录
TL;DR
cpython官方提供了一套接口,用于在c++层定义一个python类型、实现一个函数,并导出给python脚本用,但是这套接口直接用于开发还是比较复杂,开发效率较低。所以考虑实现一个binding,以方便把c++代码导出给python用,并尽可能减少对原生c++代码的调整
python脚本 <—> cpython类 <—> c++类
binding的本质就是自动实现cpython类,利用c++的宏和模板,输入一个原生的c++类,直接生成对应的cpython类(也就是binding类),从而屏蔽掉手写cpython类引入的复杂度。
用例
|
|
要让一个c++导出给python使用,需要做如下改动:
- [.h] 继承BindObject
- [.h] 使用
DECLEAR_PYCXX_OBJECT_TYPE宏声明binding需要的成员 - [.cpp] 根据需要使用
BIND_CLS_FUNC_DEFINE宏声明需要导出到python的c++成员函数,配套的BIND_CLS_FUNC_NAME用于构造方法列表 - [.cpp] 使用
DEFINE_PYCXX_OBJECT_TYPE_BASE宏,声明对应的python类名,方法列表和构造函数参数类型
实现原理
这里讨论仅单继承、无可选参数的情况
binding的cpython类生命期
binding的生命期选择跟业务需求有关,这里不展开讨论了。我们使用带引用计数的c++对象为核心进行维护,python对象持有一份c++对象的引用,在python对象销毁时,c++对象可能依旧存在(被c++代码引用)。
python对象就是纯粹的一层胶水,需要的时候创建,不需要的时候销毁。(有些坑,后面讨论)
原生cpython类定义流程
定义一个新的python类型,本质上就是填充一个类型结构体,结构体关键成员包括:
- 构造函数
int (*)(PyObject *self, PyObject *args, PyObject *kwds) - 析构函数
void (*)(PyObject *self) - 各成员函数
PyObject *(*)(PyObject *self, PyObject *args)
定义好以上几个函数之后,python里面就可以直接用了(这里定义的是新的python类的类型类,新的python类本身的结构很简单,只包含一个指针,指向对应的c++类)。
这里析构函数比较简单,没有特殊参数,只需要调用对应c++对象的析构(减引用)就行了。
对于成员函数和构造函数,需要解析参数列表,这里就需要用上c++的模板了:输入一个c++类成员函数,输出一个对应的 PyObject *(*)(PyObject *self, PyObject *args) 函数,前面 BIND_CLS_FUNC_DEFINE 宏做的就是这个事情。
BIND_CLS_FUNC_DEFINE
Binding的核心就是 BIND_CLS_FUNC_DEFINE 宏,这个宏接收两个参数,一个是类名字,一个是成员函数名,宏展开后就是一个签名为 PyObject *(*)(PyObject *self, PyObject *args) 的函数。
-
构造存放参数的临时变量
python传进来的参数是放到
PyObject *args中存放的,它是一个Python的Tuple对象,与c++成员函数一一对应,args里面的参数会被依次传到c++函数中,为了方便做这个事情,我们首先构造一个c++结构存下这些参数:1 2 3 4 5 6 7 8 9 10 11template<typename... Res> struct ParamList; template<> struct ParamList<> {}; template<typename T, typename... Res> struct ParamList<T, Res...> : ParamList<Res...> { using base_type = typename std::remove_cv<typename std::remove_reference<T>::type>::type; PyObject* data; };ParamList是一个递归继承的结构体,继承链的每一层对应了一个args参数,并把args中对应的参数和参数c++类型(通过模板拿到)存起来 -
Boxing/Unboxing
上一步中我们存的参数还是python的cpython对象,也就是
PyObject,我们需要对其解包,把python对象转换为c++对象,这个过程就是unboxing。反过来,在c++函数返回给python的时候,需要把c++返回值类型转换为python类型,这个过程叫做boxing。这里的类型主要有简单类型、容器类型、binding类型和指针类型,需要我们依次实现转换规则
- 简单类型
直接调用python接口转,例如
PyAPI_FUNC(PyObject *) PyLong_FromLong(long)和PyAPI_FUNC(long) PyLong_AsLong(PyObject *)这一组api就是用来转换整数的 对于每一种简单类型,我们手动添加转换代码,需要时候加就好 - 容器类型
例如数组,对应到c++的
std::vector,迭代python容器,并递归调用boxing/unboxing过程 - 指针类型 只允许处理pybinding类型的指针,转换为做binding类型的转换
- binding类型 对于unboxing,就是从cpython对象中把c++指针拿出来就行,返回引用;对于boxing,就是构建一个新的cpython胶水层对象(c++对象中可以缓存cpython对象指针,在cpython对象析构函数中清理掉这个指针)
最后我们用一个大的外层函数封装起来,根据条件选择分支,比如unboxing长这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23template <typename T> decltype(auto) unboxing(PyObject *obj) { typedef std::remove_reference_t<T> type_base; typedef std::remove_const_t<type_base> type_nc; typedef std::remove_pointer_t<type_nc> type_ncp; typedef std::remove_reference_t<type_nc> type_ncr; typedef std::remove_reference_t<type_ncp> type_ncrp; typedef std::conditional_t< // check pointer std::is_same_v<type_nc, type_ncp>, std::conditional_t< // check bind obj std::is_base_of_v<BindObject, type_ncrp>, obj_box_struct<type_ncrp>, std::conditional_t< // check vector is_std_vector<type_ncrp>::value, vector_box_struct<type_ncrp>, base_box_struct<type_ncrp>>>, ptr_box_struct<type_nc>> box_type; return box_type::unboxing(obj); } - 简单类型
直接调用python接口转,例如
-
取出参数列表并调用c++函数
我们刚刚得到了
ParamList结构用来存放类型为PyObject的参数,在调用的时候,我们要做的就是依次从ParamList结构中取出参数,调用unboxing转换python对象为c++对象,然后作为函数参数传递给c++函数,这里需要一个取值器:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15template<int N> struct ParamGet { template<typename T, typename... Res> static decltype(auto) get(ParamList<T, Res...>* param) { return ParamGet<N - 1>::get((ParamList<Res...>*)param); } }; template<> struct ParamGet<0> { template<typename T, typename... Res> static decltype(auto) get(ParamList<T, Res...>* param) { return detail::unboxing<T>(param->data); } };用的时候:
1 2 3// template <typename Res, typename ...Args, int... S> // ParamList<Args...> params; decltype(auto) res = fn(ParamGet<S>::get(¶ms)...);ParamList总体跟c++ std::tuple的实现类似 - 函数结果boxing之后返回给cpython
作为
PyObject *(*)(PyObject *self, PyObject *args)的返回值
构造函数
刚刚我们对成员函数实现binding的时候,需要手动用 BIND_CLS_FUNC_DEFINE 宏来定义,然后再用 BIND_CLS_FUNC_NAME 宏获取刚刚定义的函数的名字,填入python方法列表结构中
这里可能会考虑合在一起,通过单一的宏返回一个匿名函数,传递给cpython。然而,cpython接口是c函数指针,不能接受包含状态的匿名函数。为了解决这个问题,一些实现是统一传一个dispatch函数给cpython,然后在dispatch函数里面再来查找真实的匿名函数并调用。
这里考虑拆开一方面为了性能,避免转发;另一方面为了代码清晰,明确地定义cpython函数。
然而对于构造函数就不一样了,同一个签名的构造函数是唯一的,也就是所生成的匿名函数是无状态的,所以考虑在 DEFINE_PYCXX_OBJECT_TYPE_BASE 宏中传入构造函数的签名,直接生成cpython类的构造函数
继承
DECLEAR_PYCXX_OBJECT_TYPE 宏定义了一个 GetType 函数,用来获取当前c++对象对应的cpython对象类型。对于继承需要处理的地方有:
- 在python类的类型结构体构造中,
DEFINE_PYCXX_OBJECT_TYPE_BASE宏需要传入基类,这样才能在python层实现继承 - 在生成cpython胶水对象的时候,需要调用
GetType函数获取真实的python类型(因为拿到的可能是个基类指针)
需要考虑的问题
- 生命期问题 以上实现会遇到一个问题,如果python脚本中,一个脚本类继承了binding导出的类,当脚本层对象销毁之后(c++对象还在),再次从c++层获取脚本对象会出问题。因为c++层完全不知道脚本对象的存在,所以c++只能构造出这个脚本对象的基类对象上去 这里就需要思考下生命期管理了,根据业务需要,简单调整下就能解决。
-
副本问题
对于python层,拷贝binding对象仅仅是给python对象增加一个引用,没有问题;对于c++对象,这里直接禁止对binding的c++对象赋值。
在boxing和unboxing过程中需要考虑对象复制问题,针对每种类型来看:
- 简单类型 这种直接复制
- 容器类型 容器类型需要完全拷贝,如果容器里面存的是binding对象,只允许存指针
- 指针类型 对应地按照binding类型引用处理
- binding类型 引用binding对象中的c++对象,不允许传值调用
总结
以上对基于cpython的python binding实现做了一个记录,做的过程中遇到很多奇怪的坑,后面有空再总结了,参考文档:
文章作者 ya0db9
上次更新 2021-10-04