C++ 是编译型语言,Python 为解释型语言,二者有各自的特点。有时需要将 C++ 与 Python 混合使用,以发挥二者各自的优点。通常认为,C++ 的优点是有明确的类型声明,更加接近底层,适合开发大型项目;而 Python 的优点是写法更方便简洁,可以“黏合”多种语言,适合由用户更灵活地调用。多数情况下,是在 C++ 写好函数、类等代码,然后封装起来,由用户在 Python 中调用。事实上,Python 用户经常使用的 numpy pytorch 等库的大多数源代码都是通过 C++ 来实现的。

将 C++ 封装为 Python 可以调用的库一般有两种方法。一种是使用 CPython 提供的原生 API 。这里简单解释 CPython:Python 是一种编程语言,而想要实现这种语言,需要对应的解释器。CPython 就是使用 C 语言所编写的 Python 解释器,也是我们目前最常用的 Python 解释器。对应地,还有使用 Java 开发的 JPython ,使用 .NET 开发的 IronPython 等,这里就不过多解释了。 CPython 的原生 API 更加接近解释器的底层逻辑,但是封装起来更加繁琐。在此背景下,诞生了第二种方式,即使用 Pybind11 。 Pybind11 的前身是 Boost.Python ,它将一些 CPython 的原生 API 进一步地封装,形成了一套对用户更加友好的逻辑。

在我的另一篇博文 加快 Python 调用 OpenSees 的速度 中,介绍了使用原生 API 在 Linux 平台封装 C++ 库的方法 (OpenSees是一个地震工程领域使用的 C++ 库)。与之区别地,本文介绍使用 Pybind11 在 Windows 平台封装 C++ 库的方法。

值得一提的是,除了以上两种写 C++ 代码,编译成 Python 库的方法外,还可以通过 Python 中的 ctypes 库直接调用已有的动态链接库文件( Win 下为 .dll,Linux 下为 .so )。这种方式可以不写 C++ 代码,只写 Python 代码。本文就不做过多介绍了。

下载安装 Pybind11

Pybind11 本质上是一系列的头文件,其代码在 Github 上维护,仓库链接在这里。仓库提供了非常完善的官方文档,希望系统性学习 Pybind11 使用方法的读者可以在读完本文后继续阅读文档中的内容。本文的主要内容也来自这个文档。

文档中提供了很多安装方法,都是比较“程序员”化的安装方式。这里介绍一个最简单的方式,其实 Pybind11 并不需要过多的安装和配置,下载就可以直接使用。由于代码库还在开发中,所以最好将代码回退到一个打好标签的版本:在左上角的显示有 master 的版本选框中点开下拉菜单,切换到 Tags 选项卡,并选择里面的最高版本。我目前所选的版本是 v2.11.1 ,如下图所示。然后直接在 Github 的仓库页面中点击绿色的 Code 按钮,然后选择 Download zip ,将代码仓库作为一个压缩文件下载。然后到下载文件夹中找到 zip 文件解压缩即可。我这里提到的文件夹名称为 pybind11-2.11.1

pybind11-github

使用 Visual Studio 创建 C++ 项目

因为是在 Windows 中使用,我们就用最常见的 IDE ,也就是 Visual Studio (VS) 来演示。如果习惯使用 CMake , Pybind11 已经提供了非常丰富的辅助函数帮助编译。它的使用方法这里就不过多介绍了。我们回到 Visual Studio。

我目前使用的 VS 是 2019 的 Community 版本。打开之后,在欢迎页面中点击 Create a new project ,开创建一个新项目。在弹出如下图所示的页面中,选择第一个 Empty project (空项目)。

vs-welcome

然后输入项目名称,这里命名为 TestPybind 。保存在合适的位置,这里我保存在桌面上。这时,可以看到在桌面上出现了一个名为 TestPybind 的文件夹,这个文件夹里面包含了一个 Solution (解决方案),即 TestPybind.sln ,以后可以通过双击打开这个项目。

现在来到了项目的编辑页面,左上角有一个 Solution Explorer (解决方案浏览器),如下图所示。最外一层是名为 TestPybind 的 Solution (解决方案),向下一层是一个 Project (项目),也是同名为 TestPybind 的,后面很多操作就是在这里点击右键的。在这个 Project 中,有 Reference (引用),和一些右下角画着一个小筛选器的文件夹。

vs-solution-explorer

我们先来创建一个 Hello, world 程序。在 TestPybind 的 Project 位置点击右键,在出现在下拉菜单中选择 Add (添加),再选择 New Item (新项目),弹出如图所示的对话框。

vs-new-item

选择 C++ 文件,名称保留默认的 Source.cpp ,点击 Create (创建)。则可以看到,在 Source Files 文件夹下,多了一个名为 Source.cpp 的文件。双击,加入如下代码:

1
2
3
4
5
6
7
#include <iostream>
using namespace std;

int main()
{
cout << "Hello, world!" << endl;
}

在工具栏中可以看到有两种配置,一个是 Debug (调试),一个是 Release (发行),可以根据不同的发布场景进行不同的配置。这对于大型项目而言很有好处,但是对于我们的小型项目只需要用其中之一即可,这里保持默认的 Debug 配置。旁边的 Platform 也有两种,分别对应 32 位和 64 位架构。当今我们一般使用 64 位。

选好配置后,在我们需要编译的 TestPybind Project 上点击右键,选择最上面一个 Build (构建),则可以开始构建,在 Output 中出现以下提示:

1
2
3
4
5
Build started...
1>------ Build started: Project: TestPybind, Configuration: Debug x64 ------
1>Source.cpp
1>TestPybind.vcxproj -> C:\Users\hanli\Desktop\TestPybind\x64\Debug\TestPybind.exe
========== Build: 1 succeeded, 0 failed, 0 up-to-date, 0 skipped ==========

出现 1 succeeded ,即说明编译成功,在提示的文件夹里面就可以找到 TestPybind.exe 这个可执行程序,如果双击的话没有显示任何东西,因为短暂的输出之后,窗口就自动关闭了。这时如果我们打开终端,切换工作目录到当前文件夹,输入

1
./TestPybind

则可以看到输出的 Hello, world! 字样。

引入 Pybind11 头文件

下面我们将 Pybind11 放到我们的项目中来。

首先,从我们下载的 Pybind11 文件夹中找到 include 文件夹,将这个文件夹复制到刚才创建的 TestPybind 解决方案文件夹中,以方便使用。 Pybind11 的本质是一系列的文件头,则我们只需要将这些文件头置入项目中就可以了。

下面我们将这些文件头导入到 TestPybind 文件中。在 Solution Explorer 的 Header Files 文件夹中,点击右键,选择 Add (添加),再选择 Existing Item (已有项目),进入 includes/pybind11 文件夹中,将所有文件选中,导入到 VS 的项目中。

下面我们来测试一下能否正常使用。

在刚才创建的 Source.cpp 文件中,加入 Pybind11 的头文件

1
#include "pybind11/pybind11.h"

这时会发现在 include 下方划了红色波浪线报错,提示找不到这个头文件。这是因为我们没有让编译器去找刚才复制过来的 include 这个文件夹。下面我们来配置一下。

在 TestPybind Project 上点右键,点击最下面的 Properties (属性)。然后在左侧选择 C/C++ 下面的 General (常规),在右侧第一行可以看到 Additional Include Directories ,就是用来指定编译器寻找头文件的位置。这里我们点击这一行右面的下拉箭头,选择 Edit (编辑),然后在出现的对话框中,添加刚才复制过来的 include 文件夹,如下图所示。

vs-additional-include

填加好后确定,可以看到, include 下面的报错已经没有了。

封装一个函数

下面我们写一个最简单的 C++ 函数,并将其封装起来。

这个函数实现两个整数相加功能,非常简单,直接上代码:

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

下面调用一个 Pybind11 中定义的宏,将 add() 函数封装起来:

1
2
3
4
PYBIND11_MODULE(TestPybind, m) {
m.doc() = "TestPybind plugin";
m.def("add", &add, "A function that adds two integers");
}

可以看到封装的过程非常简洁。首先,PYBIND11_MODULE 是一个宏,用于创建一个用 Pybind11 封装的 Python 库。提供两个参数,第一个是库的名称,这里注意不要用引号引起来。第二个则是代表这个库的变量。

在函数体中,先用 m.doc() 指定了这个库的帮助文档,实际中不需要的话也可以不写。然后用 m.def() 来封装了 add() 函数,其中第一个变量是 Python 中的函数名,第二个变量是 C++ 中的函数,通过引用来传递,第三个变量是函数的帮助文档,也可以不写。

使用过 CPython API 或者 ctypes 的用户都知道,Python 与 C++ 的数据类型是不同的,因此在封装和调用的时候,需要进行类型转换,把 C++ 的类型转换成 Python 的类型。但是这里并没有这一操作。这是因为, Pybind11 可以自动帮助我们推断想要的类型,如果用户不显式地定义,则使用自动推断的类型,大大简化了封装的代码量,提供了更好的编程灵活性。

编译成 .pyd 库

在官方文档中,介绍的是如何使用 Linux 的 g++ 来完成编译。这里我们使用 VS 来完成编译。

目前来看,宏 PYBIND11_MODULE 依然是处于报错的状态。下面我们来配置一下编译的属性。

在 TestPybind Project 中点击右键,选择 Properties ,在 General (通用)中,有一个 Configuration Type (配置种类),目前是 .exe ,也就是生成一个可执行的文件。刚才的 Hello, world! 程序需要这样的可执行文件。但是现在我们封装的 Python 库并不是一个可执行文件,而是一个动态链接库,因此我们把这个配置改为 Dynamic Library (.dll) (动态链接库)。

然后到 Advanced (高级) 中,有一个 Target File Extension (输入文件扩展名),这里面是默认的 .dll 。我们生成的 Python 库文件的扩展名为 .pyd ,因此这里我们把它改为 .pyd

由于不再是可执行文件,原来写的 main() 函数可以删掉。

下面我们尝试 Build ,发现在 Output 中出现以下提示

1
2
3
4
5
6
Build started...
1>------ Build started: Project: TestPybind, Configuration: Debug x64 ------
1>Source.cpp
1>C:\Users\hanli\Desktop\TestPybind\include\pybind11\detail\common.h(266,10): fatal error C1083: Cannot open include file: 'Python.h': No such file or directory
1>Done building project "TestPybind.vcxproj" -- FAILED.
========== Build: 0 succeeded, 1 failed, 0 up-to-date, 0 skipped ==========

说明 Build 失败了,看输出得知失败的原因,是没有 include Python 的头文件 Python.h 。因此,我们来修改这一错误。

重新打开 TestPybind 的 Properties ,找到前面添加头文件的位置(C/C++ 下的 General),在 Additional Include Directories 里面继续编辑,加入 Python.h 的所在位置。这取决于电脑中的 Python 是如何安装的。我电脑中的 Python 是通过 miniconda 来安装的,安装的位置使用默认的用户文件夹下的 miniconda3 。在这个文件夹下面就有一个 include 文件夹,即 Python 相关的 C++ 头文件的存放位置,将这一路径添加进来即可。我电脑中的绝对路径为 C:\Users\hanli\miniconda3\include 。

确认之后,可以发现,宏 PYBIND11_MODULE 不再报错了,说明找到了正确的定义。然后我们再次尝试 Build ,输出如下:

1
2
3
4
5
6
Build started...
1>------ Build started: Project: TestPybind, Configuration: Debug x64 ------
1>Source.cpp
1>LINK : fatal error LNK1104: cannot open file 'python38.lib'
1>Done building project "TestPybind.vcxproj" -- FAILED.
========== Build: 0 succeeded, 1 failed, 0 up-to-date, 0 skipped ==========

可见再一次失败了。这次失败的原因是在 LINK (链接)的时候,无法找到 python38.lib 这一文件。下面我们再来修复这一问题。

继续打开 TestPybind 的 Properties , 这次在左侧找到 Linker (链接器),然后在右边找到 Additional Library Directories ,(附加库文件夹),也就是链接器查找 .lib 文件的位置。在这里我们加入 Python 目录下的 libs 文件夹,在我电脑中的绝对路径为 C:\Users\hanli\miniconda3\libs 。

下面再来尝试一次 Build ,出现以下结果:

1
2
3
4
5
Build started...
1>------ Build started: Project: TestPybind, Configuration: Debug x64 ------
1> Creating library C:\Users\hanli\Desktop\TestPybind\x64\Debug\TestPybind.lib and object C:\Users\hanli\Desktop\TestPybind\x64\Debug\TestPybind.exp
1>TestPybind.vcxproj -> C:\Users\hanli\Desktop\TestPybind\x64\Debug\TestPybind.pyd
========== Build: 1 succeeded, 0 failed, 0 up-to-date, 0 skipped ==========

说明编译成功。这时再到 x64/debug 文件夹下可以找到名为 TestPybind.pyd 文件。将这个文件放在任意文件夹(放在原位也可),打开终端,切换到该文件所在的文件夹,就可以在 python 中测试使用了:

1
2
3
>>> import TestPybind as TP
>>> TP.add(1, 2)
3

参数类型转换的模式

如前文所说, Python 与 C++ 各自有自己的数据类型,申请内存的方式是不一样的,因此在通信的时候,需要进行一定的转化。这个转化有三种方式:第一种是两边都使用 C++ 的类型,第二种是两边都使用 Python 的类型。最后一种,也是最常见的一种,是在 C++ 一边使用 C++ 的类型,而在 Python 一边使用 Python 的类型,但是这就涉及到了类型转换,是通过 Copy 数据来实现的,这就意味着通信可能会变慢。下面分这三种类型一一讨论。

对于原生使用 C++ 的数据类型,举例如下。现在有一个 C++ 的结构体,定义为:

1
2
3
4
5
6
7
8
9
using namespace std;

struct Pet {
Pet(const string& name) : name(name) { }
void setName(const string& name_) { name = name_; }
const string& getName() const { return name; }

string name;
}

这是一个原生的 struct 类型,名为 Pet ,有一个构造函数,一个属性 name 和它相应的 setter getter 。要把这个原生 C++ 的数据类型封装到 Python ,则可以使用如下代码:

1
2
3
4
5
6
7
8
9
10
#include "pybind11/pybind11.h"

namespace py = pybind11;

PYBIND11_MODULE(example, m) {
py::class_<Pet>(m, "Pet")
.def(py::init<const std::string&>())
.def("setName", &Pet::setName)
.def("getName", &Pet::getName);
}

这里的 class_ 将 C++ 的 structclass 绑定为 Python 的类型。init() 用于处理构造函数。

下面举例介绍另外一种相反的实现方法,即以 Python 的类型为主。 Python 中比较有特点的数据类型是 tuple list 等。这时可以使用 py::object 直接将这些数据类型传入 C++ 函数,如以下代码所示:

1
2
3
4
void print_list(py::list my_list) {
for (auto item : my_list)
std::cout << item << " ";
}

此时 Python 的 list 类型没有被转换成 C++ 的数据类型,只是用一个 py::list 类给包起来,它的本质还是一个 Python 的对象。

最后一种混合的方式,是将两种类型相互转化,代码如下:

1
2
3
4
void print_vector(const std::vector<int>& v) {
for (auto item : v)
std::cout << item << "\n";
}

在这里, Pybind11 会构造一个新的 C++ 类型 vector ,并把 Python 的 list 中的所有元素 Copy 进来。然后再把这个新构造的 vector 类型传入函数。类似的转换方式有很多,可以开箱即用,它们的优点是很方便,但是缺点是 Copy 数据可能会耗费时间,尤其是在数据量比较大的情况下。

字符串在两语言间的转化

Python 的字符串处理能力很强,帮我们节省了很多工作 C++ 标准库中的 string 类型也有比较强大的功能,但是早期的 C 语言字符串处理需要将其视为一个字符的数组,用指针来完成,是比较繁琐的。

字符串有两个比较重要的操作,是 encodedecode 。这两个词在很多语境下都会用到,其中 encode 主要表示将数据转换为方便传输或存储的格式。

这里就要提到字符串在计算机中的表示了。最早期使用 ASCII 码,使用一个字节来存储一个英文字符。它无法表示其他语言。对于汉字,单字有成千上万个,使用一个字节是不够的,至少需要两个字节,所以中国制定了 GB2312 编码方案,也就是在仿宋、楷体等字体后面的那个 GB2312 。但是中文、韩文、日文等都有自己的编码方案,就出现了冲突,所以最初经常在计算机上看到乱码。为了解决这个问题, Unicode 编码应运而生,就是创建一个足够大的编码集,把所有国家的编码都加进来。但是它有些浪费空间,因此每个字符都使用相同长度的空间。后来出现了 UTF-8 就是 Unicode 的一种可变长度的实现方式。

Python3 内部解释器的默认编码为 Unicode ,可以通过指定 UTF-8 GB2312 CJK 等编码集的方式进行 encode ,从 str 类型转换到 byte 类型。

在 Pybind11 中,可以自动将 Python 中的 str 类型 encodeUTF-8 。所以可以直接将 str 类型传递到 C++ 的函数中,既可以使用 std::string 类型,也可以使用 char* 类型来接收。

1
2
3
4
5
6
7
8
9
10
11
12
using namespace std;

void utf8_test(const string& s) {
cout << s;
}

void utf8_charptr(const char *s) {
cout << s;
}

m.def("utf8_test", &utf8_test);
m.def("utf8_charptr", &utf8_charptr);

反向地,将字符串从 C++ 传回 Python 时,也可以使用 std::stringchar* 类型。 Pybind11 会假设它是使用 UTF-8 编码的字符串,并 decode 处理,转化为 Python 中的 str 类型。如:

1
2
3
4
5
void std_string_return() {
return std::string("This string needs to be UTF-8 encoded");
}

m.def("std_string_return", &std_string_return);