如何选择 Python 与 C++ 之间的胶水

Python 作为一门胶水语言,它与 C/C++ 之间的兼容性(Interoperability)我认为是它相比其他动态语言脱颖而出的最大原因。Python 原生支持的是与 C 语言的接口,Python 的发行版自带有 Python.h 头文件,里面提供了在 C 中调用 Python 和反过来在 Python 中调用 C 的接口定义。但是 C++ 就不一样了,虽然 C++ ⇔ C ⇔ Python 的通道是可行的,但是想要完整兼容 C++ 的特性的话需要很多额外的重复代码(boilerplate)。因此相应针对 Python/C++ 绑定的库也就应运而生了,我所了解的库主要有四个:Boost.PythonCythonpybind11SWIG。虽然网上也有不少比较三者的页面,但是我觉得都不够详细,这篇博客就介绍一下我基于使用这几个库的经验比较。

上面说到的这些库我基本都有接触过,其中用过的有 pybind11 和 Cython,分别用在了我正在写的 CGALPCL 的绑定上。另外二者则是在其他库的代码中有读过(如 Caffe 和 CGAL 的官方绑定)。总的来说,Boost.Python 和 pybind11 主要用于给现有 C++ 代码提供 Python 绑定,并且不用学习新的语法;SWIG 提供一个给 C++ 代码编写多种语言绑定的框架,它本质上是一种代码生成器,基于 SWIG 自定义的语法;Cython 则是基于 Python 的 C/C++ 代码封装器,其本质也是代码生成器,但是 Cython 的语法是 Python 的超集,也就是说 Python 的代码可以零成本移植到 Cython 中。

Boost.Python vs pybind11

Boost.Python 是一个 Boost 框架中封装 C++ 代码的工具,通过宏定义和元编程来简化 Python 的 API 调用,消灭 bolierplate。Boost.Python 还提供对 Numpy 底层 API 的封装,因此适用性很强,能满足 Python 绑定的绝大多数需求。而 pybind11 则是受 Boost.Python 启发的一套类似的 API,其目标是提供 Header-only 的易用的 Python 接口。由于 pybind11 脱胎于 Boost,因此它们的接口非常相似,例如最简单的封装一个函数,Boost.Python 代码如下

1
2
3
4
5
6
7
8
9
10
11
#include <boost/python.hpp>

int add(int i, int j) {
return i + j;
}

BOOST_PYTHON_MODULE(example)
{
using namespace boost::python;
def("add", add);
}

而对应的 pybind11 代码则是

1
2
3
4
5
6
7
8
9
#include <pybind11/pybind11.h>

int add(int i, int j) {
return i + j;
}

PYBIND11_MODULE(example, m) {
m.def("add", &add);
}

因此熟练掌握这两者之一的开发者能很快上手另一个库的使用。他们的编译方式也是相似的,只需添加一个工程,写好对应的封装代码,然后利用他们的 CMake 模块进行编译,生成的动态链接库只要文件名正确就可以直接从 Python 进行 import 了。他们二者的区别主要有以下几个方面:

  1. pybind11 是 Header-only 的,因此只需把它的头文件添加到 include 目录就算安装好了。而 Boost.Python 则是需要先编译安装才能使用,需要处理其依赖。
  2. pybind11 的社区更加活跃,Boost.Python 则受限于 Boost 的更新周期,回应反馈可能会比较慢。
  3. pybind11 的易用性更好,文档齐全且友善,由于没有依赖问题,编译方便上手也快。
  4. Boost.Python 兼容旧特性的 C++,也兼容 Boost 自定义的类型(如 smartptr),因此如果需要封装的代码是基于 Boost 的,那可能 Boost.Python 会比 pybind11 合适。pybind11 针对的环境则是 C++1x,并且只支持标准 C++ 库。
  5. Boost.Python 对 Numpy 的支持比较完备,例如 Boost.Python 支持自定义 numpy.dtype,而 pybind11 对 Numpy 的支持主要基于 Python 的 buffer 协议。
    因此基本上如果封装不基于 Boost 的库的话可以先考虑 pybind11,而如果是封装基于 Boost 的库(如 PCL),或者深度操作 Numpy,那还是直接上 Boost.Python 吧~

Boost.Python/pybind11 vs Cython

这两者的选用其实差别非常大,因为他们的代码逻辑都是不同的。而具体选择哪个库就纯粹是根据需求出发了。他们的区别如下(以下 pybind11 同时也代表了 Boost.Python)

  1. pybind11 基于 C++,更适合 C++ 工程师。Cython 则是基于 Python,写习惯的 Python 的人上手更快,并且能同时方便地兼容 Python 和 C++。
  2. Cython 相比 pybind11 的环境配置更加简单,用户只需通过 pip 安装 Cython 就可以利用 Cython 的功能了,也无需配置路径。
  3. Cython 封装 C++ 类会比 Boost.Python 更加繁杂,你需要先定义 C++ 类,再封装成 Python 类。相当于 Cython 还多一步翻译头文件的工作。
  4. Cython 支持模板(虽然是阉割版本)!这是 Cython 独家的一个 killer 特性,不过是与第 3 点相关联的。如果你已经翻译好了现有的模板代码,那么用户就可以用 Python 的语法来自行展开模板了!pybind11 需要在编译的时候实例化模板,因此一般只封装常用的实例,或者穷举所有实例化可能(这会导致生成的封装库尺寸爆炸)
  5. pybind11 封装重载函数比 Cython 要方便太多!Cython 封装重载函数的话一般需要定义大量的可选参数和类型判断。
  6. Cython 封装继承类就更加麻烦了,不仅要处理方法重载,还要复制继承关系,十分繁复。
  7. Cython 无法利用上 C++ 的宏定义,这对支持条件编译非常不利,很多时候还需要自己利用 Cython 的条件语句翻译一套条件编译的逻辑。
  8. Cython 似乎在封装上比 pybind11 性能好,参见 pybind11#1227pybind#2005。如果你的代码需要经常调用封装后的函数,那么选择 Cython 性能更好。

以前很多人使用 Cython 的原因是 Cython 可以很方便地加速 Python 代码,但是 numba.jit 的出现则让这个功能实际上成了鸡肋,因此 Cython 最近的使用率也是越来越低了。如果没有很强的对保留模板灵活性的需求,或者不是封装目标不是基于 C 语言的,那还是选择 pybind11 来的方便。如果封装接口只是一小部分需求的话也还是用 Cython 会更加一致,我在自己的 PCL 绑定项目中使用 Cython 的原因是有大量基于 Python 的扩展代码,因此使用 Cython 还是能更方便。

SWIG

SWIG 是个很神奇的东西,他能够将 C++ 代码封装成 Python/C#/Java/Ruby 等多种语言,但是也正因为这个灵活性,它对 C++ 的高级特性的支持就比较辣鸡了。在 CGAL 官方的绑定库中可以看到有不少代码需要针对 Python 和 Java 打补丁,因此如果没有多语言的需求的话 SWIG 应该是下下策了。这应该也是 SWIG 一直没啥发展的原因吧~


总而言之,如果有多语言绑定的需求可以选择SWIG,如果有以下需求可以选择Cython,其他情况选择pybind11即可 - 需要保留模板参数,让用户可以自行选择用什么类型展开,或者目标用户有继续使用和拓展C++ API的需求时,用Cython便于用户使用 - 有大量的封装函数调用时,Cython的性能最好 - 绑定的对象是C语言写的API或者不涉及面向对象的话,那么用Cython写封装会更方便(不用处理编译的问题)

本文介绍了 Boost.Python/pybin11/Cython/SWIG 之间的特性与区别,而具体用法则是一笔带过。如果大家对其中的某工具感兴趣的话可以直接去官网看教程~也欢迎参考我的 Cython 系列博客,以及我的一些 Github 项目如 pcl.pycgal.py