TL;DR

cpython官方提供了一套接口,用于在c++层定义一个python类型、实现一个函数,并导出给python脚本用,但是这套接口直接用于开发还是比较复杂,开发效率较低。所以考虑实现一个binding,以方便把c++代码导出给python用,并尽可能减少对原生c++代码的调整

python脚本 <—> cpython类 <—> c++类

binding的本质就是自动实现cpython类,利用c++的宏和模板,输入一个原生的c++类,直接生成对应的cpython类(也就是binding类),从而屏蔽掉手写cpython类引入的复杂度。

用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  // 把一个叫System的c++类导出到python
  // system_scene.h
  class System : public BindObject { // 1. 继承BindObject
   public:
    DECLEAR_PYCXX_OBJECT_TYPE(System) // 2. 声明必要的成员变量
    System(int type) : _scene(nullptr), _type(type) {}

    void SetScene(Scene* scn) { _scene = scn; }
    Scene* GetScene() { return _scene; };

   protected:
    Scene *_scene;

   private:
    int _type;
  };

  // 
  // system_scene.cpp
  BIND_CLS_FUNC_DEFINE(System, GetScene); // 3. 定义需要导出到python的函数

  static PyMethodDef type_methods[] = {
    {"GetScene", BIND_CLS_FUNC_NAME(System, GetScene), METH_NOARGS, 0}, // 构造导出到python的列表
    {0, nullptr, 0, 0},
  };

  // 4. 定义python类
  DEFINE_PYCXX_OBJECT_TYPE_BASE(System, "System", type_methods, py_init_params<int>());

要让一个c++导出给python使用,需要做如下改动:

  1. [.h] 继承BindObject
  2. [.h] 使用 DECLEAR_PYCXX_OBJECT_TYPE 宏声明binding需要的成员
  3. [.cpp] 根据需要使用 BIND_CLS_FUNC_DEFINE 宏声明需要导出到python的c++成员函数,配套的 BIND_CLS_FUNC_NAME 用于构造方法列表
  4. [.cpp] 使用 DEFINE_PYCXX_OBJECT_TYPE_BASE 宏,声明对应的python类名,方法列表和构造函数参数类型

实现原理

这里讨论仅单继承、无可选参数的情况

binding的cpython类生命期

binding的生命期选择跟业务需求有关,这里不展开讨论了。我们使用带引用计数的c++对象为核心进行维护,python对象持有一份c++对象的引用,在python对象销毁时,c++对象可能依旧存在(被c++代码引用)。

python对象就是纯粹的一层胶水,需要的时候创建,不需要的时候销毁。(有些坑,后面讨论)

原生cpython类定义流程

定义一个新的python类型,本质上就是填充一个类型结构体,结构体关键成员包括:

  1. 构造函数 int (*)(PyObject *self, PyObject *args, PyObject *kwds)
  2. 析构函数 void (*)(PyObject *self)
  3. 各成员函数 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) 的函数。

  1. 构造存放参数的临时变量

    python传进来的参数是放到 PyObject *args 中存放的,它是一个Python的Tuple对象,与c++成员函数一一对应,args里面的参数会被依次传到c++函数中,为了方便做这个事情,我们首先构造一个c++结构存下这些参数:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
      template<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++类型(通过模板拿到)存起来

  2. 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
    23
    
      template <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);
      }
  3. 取出参数列表并调用c++函数

    我们刚刚得到了 ParamList 结构用来存放类型为 PyObject 的参数,在调用的时候,我们要做的就是依次从 ParamList 结构中取出参数,调用unboxing转换python对象为c++对象,然后作为函数参数传递给c++函数,这里需要一个取值器:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
      template<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(&params)...);

    ParamList 总体跟c++ std::tuple的实现类似

  4. 函数结果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对象类型。对于继承需要处理的地方有:

  1. 在python类的类型结构体构造中, DEFINE_PYCXX_OBJECT_TYPE_BASE 宏需要传入基类,这样才能在python层实现继承
  2. 在生成cpython胶水对象的时候,需要调用 GetType 函数获取真实的python类型(因为拿到的可能是个基类指针)

需要考虑的问题

  1. 生命期问题 以上实现会遇到一个问题,如果python脚本中,一个脚本类继承了binding导出的类,当脚本层对象销毁之后(c++对象还在),再次从c++层获取脚本对象会出问题。因为c++层完全不知道脚本对象的存在,所以c++只能构造出这个脚本对象的基类对象上去 这里就需要思考下生命期管理了,根据业务需要,简单调整下就能解决。
  2. 副本问题

    对于python层,拷贝binding对象仅仅是给python对象增加一个引用,没有问题;对于c++对象,这里直接禁止对binding的c++对象赋值。

    在boxing和unboxing过程中需要考虑对象复制问题,针对每种类型来看:

    1. 简单类型 这种直接复制
    2. 容器类型 容器类型需要完全拷贝,如果容器里面存的是binding对象,只允许存指针
    3. 指针类型 对应地按照binding类型引用处理
    4. binding类型 引用binding对象中的c++对象,不允许传值调用

总结

以上对基于cpython的python binding实现做了一个记录,做的过程中遇到很多奇怪的坑,后面有空再总结了,参考文档:

  1. python官方文档
  2. C++ Templates
  3. Effective Modern C++
  4. pybind11
  5. boost