Boost Python编译Extension module

[[Boost]]是C++一个广泛使用的第三方库,虽然是第三方库,但是实际上已经成为一个不亚于标准库的标准实现,其中包含了容器、算法、日期、序列化等多种功能的子模块。<boost/python.hpp>是Boost提供的C++和Python的紧密互操作性的模块。

本文的示例代码可以在[]yangrq1018/boost-python-blog-demo (github.com)获得

众所周知,如今最流行的Python实现是CPython,即基于C语言实现的Python运行时环境。因为这个原因,只要遵守一系列CPython规定的接口的C或者C++动态库(DLL),其中的C/C++函数和类就可以被导入到Python代码中使用,以此解决纯Python代码无法满足的一些性能需求。

引出函数

举个例子,假如我们对Python里的加法的运行速度感到不满,想要实现自己的加法

1
2
3
int add(const int& a, const int& b) {
return a+b;
}

add暴露给Python在Boost里很简单,使用boost::python::def导出一个C++函数为Python函数,函数名为"add",声明了两个参数ab

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

using namespace boost::python;

int add(const int& a, const int& b) {
return a+b;
}

BOOST_PYTHON_MODULE(mymath) {
def("add", add, (arg("a"), arg("b")));
}

BOOST_PYTHON_MODULE的第一个参数定义了Python模块的名字mymath。Python对dynamic module定义的一组接口函数需要正确匹配模块的名字。如果你想要最终在Python里通过from mymath import XXX,则BOOST_PYTHON_MODULE需要得到正确的模块名以正确生成这些接口。

然后我们使用[[XMake]]编译项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- require boost and boost python
add_requires("boost", {
system = false,
configs = {
shared = false,
python = true,
pyver = "3.9"
}
})

-- define the python readable dll here
target("mymath")
set_kind("shared")
add_packages("boost")
set_filename("mymath.pyd")
add_files("./**.cpp")
target_end()

-- copy to project path
after_build( function(target)
local dst_dir = "$(projectdir)/"
os.cp(target:targetdir() .. '/' .. target:name() .. '.pyd', dst_dir)
end)

引入boost依赖时,shared配置为false会静态链接boost,否则默认为动态链接。动态链接情况下需要将boost的dll文件拷贝到python文件的统一目录下,否则import mymath会报“找不到指定的模块,静态链接情况下需要的依赖则都打包进.pyd`中,只需要拷贝这个扩展库就可以在python中引用。

在Python里导入的方式跟普通的Python模块一样

1
2
3
4
# ensure `mymath.pyd` is on the proper path relative to this script
from mymath import add
print(add(2, 3))
# Output: 5

可以看到add函数的文档也一并生成了

1
2
3
4
5
6
print(add.__doc__)
# Output
# add( (int)a, (int)b) -> int :

# C++ signature :
# int add(int,int)

Python是一门弱类型的语言而C++是一门强类型的语言(每个变量都必须声明唯一且不可变的类型),如果在Python中以错误的参数类型调用add,例如add("foo", "bar"),会触发ArgumentError

函数重载

C++中的函数/方法往往有多个重载,以支持对不同参数类型的灵活支持,而Python是没有函数重载这个特性的,因为参数本身并没有类型限制,那么我们如何把C++的一组函数重载导出到Python的同一个函数下,而且完美支持响应的函数类型调用呢?如果你尝试直接将重载函数名传给boost::python::def,会提示编译错误

1
2
3
4
5
6
7
8
9
10
11
12
13
int add(const int& a, const int& b) {
return a+b;
}

std::string add(const std::string& a, const std::string& b) {
return a+b;
}

BOOST_PYTHON_MODULE(mymath) {
def("add", add, (arg("a"), arg("b")));
}

// Error: error C2672: “def”: 未找到匹配的重载函数

原因是def并没有办法解析一个有多个版本的重载函数名,使之匹配到单独一个Python函数上,这是做不到的,必须手动地把每一个重载版本交给def

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int add(const int& a, const int& b) {
return a+b;
}

std::string add(const std::string& a, const std::string& b) {
return a+b;
}

// define function pointer to each version of the overloaded function
int (*add1)(const int& a, const int& b) = add;
std::string (*add2)(const std::string& a, const std::string& b) = add;

BOOST_PYTHON_MODULE(mymath) {
def("add", add1, (arg("a"), arg("b")));
def("add", add2, (arg("a"), arg("b")));
}

// Python
// add(2, 3) -> 5
// add('foo', 'bar') -> 'foobar'

引出类

假如我们在C++中写了一个Student

1
2
3
4
5
6
class Student {
public:
Student() : m_name("foo") {}
string m_name;
string name() {return m_name;}
};

使用boost::python::class_模板引出class Student

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <boost/python.hpp>
#include <string>

using namespace boost::python;
using std::string;

class Student {
public:
Student() : m_name("foo") {}
string name() {return m_name;}
private:
string m_name;
};


BOOST_PYTHON_MODULE(student) {
class_<Student>("Student", "代表学生的类", init<>()) // 引出默认的构造函数
.add_property("name", &Student::name, "名字")
;
}

通过init<>()我们绑定了Student的无参数构造函数(绑定到__init__方法)

属性Property

add_propertyname属性的getter绑定到Student::name方法上,注意我们传递的是函数(方法)指针。

修改xmake.lua增加一个新的target

1
2
3
4
5
6
7
8
-- ...
target("student")
set_kind("shared")
add_packages("boost")
set_filename("student.pyd")
add_files("./student.cpp")
target_end()
-- ...

在Python中使用Student就和普通的Python类型一样

1
2
3
4
from student import Student
s = Student()
print(s.name)
# Output: foo

add_property也支持接受一个方法作为属性的setter,和Python的property.setter装饰器有类似的作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Student {
public:
Student() : m_name("foo") {}
string name() {return m_name;}
void set_name(const string& name) {m_name = name;}
private:
string m_name;
};


BOOST_PYTHON_MODULE(student) {
class_<Student>("Student", "代表学生的类", init<>())
.add_property("name", &Student::name, &Student::set_name, "名字")
;
}

函数和Special methods (__XXX__)

接下来,绑定普通方法learn__str____repr__等magic method

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
29
30
31
32
33
34
35
36
class Student {
public:
Student() : m_name("foo"), m_score(100) {}
string name() const {return m_name;}
void set_name(const string& name) {m_name = name;}
int64_t score() const {
return m_score;
}
void learn() {
m_score++;
}
private:
string m_name;
uint64_t m_score;
};


// operator overloads
ostream &operator<<(ostream &os, const Student &s) {
os << "Student: " << s.name() << ", " << s.score();
return os;
}

Student operator+(const Student &a, const Student &b) {
return Student(a.name() + "-" + b.name(), a.score() + b.score());
}

BOOST_PYTHON_MODULE(student) {
class_<Student>("Student", "代表学生的类", init<>())
.def(self_ns::str(self))
.def(self_ns::repr(self))
.def(self+self)
.add_property("name", &Student::name, &Student::set_name, "名字")
.add_property("score", &Student::score, "得分")
.def("learn", &Student::learn, "学习")
}
  • self_ns::str(self)self_ns::repr(self)self+self这样的绑定看起来十分怪异,但其实他并没有什么复杂的东西,self_ns::str代表self_ns(self namespace)命名空间下的str operator,仅此而已,将其传递给def会使得boost寻找对应的operator overload来绑定到python相应的operator上
  • 其实上述方法不是绑定__str____repr__的唯一选择,也可以通过def("__str__", method, doc)这样的形式绑定,但这种情况下method需要是一个返回值是std::string方法,而非operator<<这样的函数

    构造函数和__init__

    C++中类有多个重载的构造函数很常见,
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class Student {
    public:
    Student() : m_name("foo"), m_score(100) {}
    Student(const string& name, int64_t score): m_name(name), m_score(score) {}
    //...
    };


    BOOST_PYTHON_MODULE(student) {
    class_<Student>("Student", "代表学生的类", init<>())
    .def(init<const string&, int64_t>())
    // ...
    ;
    }
    绑定多个构造函数后,在Python中可以通过__init__调用其任何一个 版本
    1
    2
    3
    student = Student()
    student = Student("bob", 80)
    student = Student("boom") # Error!

迭代器和__iter__

boost::python::iterator接受实现了beginend方法的类作为模板,可以绑定在__iter__方法上。这样,Student实例化的对象可以支持Python里实现了迭代器__iter__的对象的各种用法, 如list(student), for x in student

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
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <boost/python.hpp>
#include <string>
#include <iostream>
#include <vector>

using namespace boost::python;
using std::string;
using std::ostream;
using std::vector;

class Student {
public:
Student() : m_name("foo"), m_score(100) {
initFriends();
}
// ...
typedef vector<string>::const_iterator const_iterator;
const_iterator begin() const {
return m_friends.begin();
}
const_iterator end() const {
return m_friends.end();
}

private:
string m_name;
uint64_t m_score;
vector<string> m_friends;

void initFriends() {
m_friends.push_back("bar");
m_friends.push_back("baz");
}
};

BOOST_PYTHON_MODULE(student) {
class_<Student>("Student", "代表学生的类", init<>())
// ...
.def("__iter__", iterator<const Student>())
;
}

这里我们让Student内部维护一个m_friends数组(vector),将vector的迭代器返回。

Iterator无论是在python中还是C++中都是相当重要的概念,善用他们对于提高程序的速度和效率都很有帮助。通常我们希望在Python中调用C++代码的需求都是出于性能上的,所以有大量迭代和循环逻辑的时候,要用好这些绑定方法。

资源管理

引出的C++类型的object在其Python绑定的object被GC销毁(或手动销毁),会自动触发C++的析构函数,这点无需任何配置,为默认行为。我们在析构函数中加一条打印验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Student {
public:
Student() : m_name("foo"), m_score(100) {
initFriends();
}
Student(const string &name, int64_t score) : m_name(name), m_score(score) {
initFriends();
}
~Student() {
std::cout << "destructed" << std::endl;
}
// ...
private:
// ...
};
1
2
3
4
from student import Student
s = Student()
del s
# Ouput: destructed

虚函数Python继承实现

C++继承

我们定义一个C++的带有虚函数的类,代表一个Increment接口的约定

1
2
3
4
class IncBase {
public:
virtual int inc(int n) = 0;
};

注意这里inc方法为纯虚函数,无论我们在Python还是C++环境中,都必须继承并实现inc方法,否则无法直接实例化IncBase类。

在C++中继承这个Abstract class,实现inc方法

1
2
3
4
5
6
class IncImp : public IncBase {
public:
int inc(int n) override {
return n + 1;
}
};

为了展示,定义一个使用IncBase对象的函数

1
2
3
int callInc(IncBase& i, int n) {
return i.inc(n);
}

将以上导出

1
2
3
4
5
6
BOOST_PYTHON_MODULE(inc) {
// 仅仅导出,不在Python中使用,否则下面一行会报错
class_<IncBase, boost::noncopyable>("__IncBase", no_init);
class_<IncImp, bases<IncBase>>("IncImp").def(init<>());
def("callInc", callInc, (arg("imp"), arg("n")));
}

这里需要小心,有好几个技巧,我们逐个分析

  • IncBase在这里被导出,但是我们不会使用它,我们也无法使用它,作为abstract class它无法被实例化,无论在C++中还是Python中。它只作为基类的声明,如果这行,在python import时会报错extension class wrapper for base class class IncBase has not been created yet,所以为了让bases<IncBase>工作,我们把IncBase也导出
  • boost::noncopyable:防止binding生成copy constructor,抽象类无法实例化,会提示编译错误
  • boost::python::non_init:防止binding生成构造函数,抽象类无法实例化,会提示编译错误
  • bases<IncBase>:需要显示指定基类bases<IncBase>,否则callInc(IncImp(), 3)会提示参数类型不匹配,因为callInc声明接受IncBase类型而不是IncImp类型

普通的Python函数是不存在任何参数类型匹配的,duck typing原则下,传入任何对象都可以,任何类型不匹配导致的错误只在访问方法时抛出。

对于函数调用,Boost Python增加的参数类型检查在默认情况下不存在所谓的runtime polymorphism,callInc只接受IncBase对象(及其Python绑定的对象)。即使IncImp对象也有inc方法,符合duck typing,还是被认为是IncBase之外的其他类型,参数类型检查没有通过。显示声明bases<IncBase>后,我们恢复虚函数的运行时多态特性。

这是是IncBase的子类只有C++类型的情况。

Python类继承

我们希望在Python中继承抽象类并实现虚函数接口

1
2
3
4
5
6
from inc import IncBase, callInc, __IncBase

class MyInc(__IncBase):
def inc(self, n):
return n + 1
f = MyInc() # RuntimeError: This class cannot be instantiated from Python

抽象类即便以Python函数的形式实现了,还是无法实例化。我们需要额外增加一个wrapper类型来使得C++侧正确调用Python子类的方法实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// omitted ...
class IncBaseWrap : public IncBase, public wrapper<IncBase> {
public:
int inc(int n) override {
// calls python override
return this->get_override("inc")(n);
}
};

BOOST_PYTHON_MODULE(inc) {
class_<IncBase, boost::noncopyable>("__IncBase", no_init);
class_<IncBaseWrap, boost::noncopyable>("IncBase").def(init<>());
class_<IncImp, bases<IncBase>>("IncImp").def(init<>());
def("callInc", callInc, (arg("imp"), arg("n")));
}

Python中继承wapper类型,this->get_override("inc")会调用Python子类的方法实现

1
2
3
4
5
6
class MyInc(IncBase):
def inc(self, n):
return n + 1
f = MyInc()
assert callInc(f, 3) == 4
assert callInc(f, -1) == 0

XMake配置

Boost依赖的静态/动态链接

上文中我们在add_requires("boost", ...)时使用shared=False的方式静态链接boost,下面展示了如何动态链接boost,并且分发最后生成的扩展模块给python

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
29
30
31
32
33
34
-- require boost and boost python
add_requires("boost", {
system = false,
configs = {
shared = true,
python = true,
pyver = "3.9"
}
})

-- extension modules
target("mymath")
set_kind("shared")
add_packages("boost")
set_filename("mymath.pyd")
add_files("./add.cpp")
target_end()

-- copy to project path
after_build(function(target)
local dst_dir = "$(projectdir)/"
-- 将依赖的库拷贝至build的输出目录
local lib_suffix = ".dll"
local libdir = target:targetdir()
for libname, pkg in pairs(target:pkgs()) do
local pkg_path = pkg:installdir()
if pkg_path ~= nil then
print("copy dependents: " .. pkg_path)
os.trycp(pkg_path .. "/lib/*" .. lib_suffix, libdir)
end
end
os.cp(target:targetdir() .. '/' .. target:name() .. '.pyd', dst_dir)
os.cp(target:targetdir() .. '/*.dll', dst_dir)
end)

多出来的部分是通过target:pkgs()遍历依赖,将dll文件拷贝到build目录,再从build目录拷贝到python模块路径上,实现python import的运行时查找链接。这跟静态链接当然互有利弊,缺点是需要分发一连串dll文件给到python用户,不过其实这不是什么大问题,用户在pip install安装我们的python模块之后,dll文件存放在用户的site-packages目录下,用户并不会感知到我们具体是采用静态链接还是动态链接