用Python绑定调用C/C++/Rust库
在《让你的Python程序像C语言一样快》我们学习了如何利用Python API来用C语言编写Python模块,通过将核心功能或性能敏感运算用C语言实现,Python程序可以运行地像C语言一样快。然而,很多时候我们需要的功能已经有人实现了,我们并不需要从头再实现一遍,只需要调用封装好的库即可,此时就需要用到Python绑定。Python绑定可以让Python程序调用C/C++/Rust编译的库函数,从而让我们在不重复造轮子的前提下,兼具Python和C/C++二者的优点。
Python绑定概述
Python绑定适用于如下场景和用例:
- 已有C/C++/Rust编写的库,为了让库调用更加方便,使其具有Python语言的优势,于是通过Python绑定提供Python调用接口
- 加速Python代码,将关键代码或性能敏感操作转换为C/C++/Rust实现,从而提升程序性能
- 使用Python测试工具做大规模测试
数据类型转换
如果你学习过《让你的Python程序像C语言一样快》或者你曾经用C语言写过Python模块,那么你应该体会到,Python调用C需要做的主要工作就是数据的封装和转换。这是因为Python和C的数据存储方式不同——C语言的数据存储更加紧凑。例如,C语言中的uint8_t
在内存中只占8位;而Python一切皆对象,即便是最简单的整数,在Python中也要用更多的字节来存储。Python数据在内存中的存储结构跟操作系统、Python版本等诸多因素有关。因此,要想Python与C互相通信,必须对数据进行封装和转换。
我们看一下常见的数据类型
整数: Python可以存储任意精度的整数,在Python中我们可以存储任意大小的整数,无需担心溢出问题。而C语言是有存储上下限的,因此从Python传递整数到C需要格外留意数据大小,谨防C语言整型溢出。
浮点数: Python比C的精度更高,也就是说Python可以存储更大/更小的浮点数,这意味着Python传递浮点数给C需要留意失精度问题。
复数: Python内置复数类型,而C语言在ISO 99
中引入了复数类型,它是通过complex.h
头文件定义的。C语言的复数其实就是一个数组,数组中有两个元素,一个表示复数的实部,一个表示复数的虚部。因此没有内置的方法可以直接转换Python的复数和C的复数,一般需要定义结构体或类来完成复数的传递和转换。
字符串: 在Python绑定中传递字符串是件棘手的事情,因为Python和C以完全不同的格式存储字符串(与其他数据类型不同,C和C++对字符串的存储也不同)。因此如何处理字符串将是我们后面的重点。
布尔值: 布尔值由于非常简单,因此在转换和传递也都十分简单。
理解可变性和不可变性
除了上面这些基本数据类型外,我们还要了解Python对象是可变的还是不可变的。C函数传参有类似的概念,即传值和传址。在C语言中,所有参数都是传值的。如果希望函数可以修改调用者的变量,则需要传递指向该变量的指针。
您可能会想,是否可以通过使用指针将不可变对象传递给C来绕过不可变限制。正常情况下Python不会给你一个指向对象的指针,所以上面的方法是行不通的。(除非你深入到解释器的底层,用暴力的方式暴露对象指针,这样做不但代码丑陋,还会丧失代码的可移植性和安全性。)如果想用C语言修改Python对象,那么需要采取额外的步骤来实现这一点。后面会详细介绍如何实现。
因此,在创建Python绑定时,不变性是需要重点关注的。归根到底我们要处理的是Python与C语言内存管理方式的不同。
内存管理
C语言和Python管理内存的方式非常不同。在C语言中,开发人员需要自己手动管理所有内存的分配与释放,要确保不用的内存及时释放且还不能多次释放。而Python采用垃圾收集器自动管理内存。两种内存管理方式各有千秋,但客观上确实为创建Python绑定增添了额外的麻烦。我们需要知道每个对象的内存分配位置,并确保它只在同一语言环境下释放。
例如,在Python中,当执行x = 3
时,会创建一个对象并且由Python的垃圾收集器来管理,对应C语言的代码为:
int* iPtr = (int*)malloc(sizeof(int));
上面的代码在堆上开辟了一块整型大小的空间,并将该空间的地址赋给了iPtr
,这里的指针在C语言中需要手动释放。
开发环境
开发Python绑定对开发环境有一定要求,由于需要与外部C/C++/Rust库通信,因此需要一些额外的工具,总体上需要如下部分:
- Python ≥ 3.6
- Python开发者工具
- Linux安装
python3-dev
或python3-devel
(取决于所使用的Linux发行版) - Windows请参考这里
- Linux安装
invoke
工具- 要绑定的C/C++/Rust库
- 虚拟环境(可选,推荐)
这里唯一一个没见过的东西就是invoke,下面会单独介绍invoke的安装和使用。
invoke
invoke用于构建和测试Python绑定,它跟make
非常相似,只不过用Python脚本替代了Makefiles。
安装invoke非常简单,用pip即可安装
$ pip install invoke
安装完成后,在控制台输入invoke
加要执行的任务即可运行指定任务
$ invoke build-cmult
==================================================
= Building C Library
* Complete
我们可以通过--list
选项查看当前都支持哪些任务
$ invoke --list
Available tasks:
all Build and run all tests
build-cffi Build the CFFI Python bindings
build-cmult Build the shared library for the sample C code
build-cppmult Build the shared library for the sample C++ code
build-cython Build the cython extension module
build-pybind11 Build the pybind11 wrapper library
clean Remove any built objects
test-cffi Run the script to test CFFI
test-ctypes Run the script to test ctypes
test-cython Run the script to test Cython
test-pybind11 Run the script to test PyBind11
从上面的输出可以看到,对于每一个绑定,都有一个以build-
开头的构建任务和一个以test-
开头的测试任务。此外还有两个特别任务:
- invoke all 运行所有的构建和测试任务
- invoke clean 清楚所有生成的文件
了解了invoke的基本用法后,我们就可以着手开始构建我们的Python绑定了。
ctypes
我们首先学习用ctypes构建Python绑定。ctypes是Python的标准库,它提供了一组底层工具集用于加载共享库,并在Python和C之间编排数据。
安装
ctypes最大的优势是它是标准库的一部分,自Python 2.5开始ctypes随Python一起发行,不需要额外安装。使用时直接import
进来即可。
调用函数
加载C库并调用函数的所有代码都在Python程序中,这个过程中没有额外的步骤。要在ctypes中创建Python绑定,需要如下步骤:
- 加载库
- 封装输入参数
- 告诉ctypes函数返回类型
加载库
ctypes提供多种方法加载共享库,其中有些是平台特有的。例如,我们可以通过传入所需共享库的完整路径来直接创建一个ctypes.CDLL对象。
# ctypes_test.py
import ctypes
import pathlib
if __name__ == "__main__":
# 加载共享库
libname = pathlib.Path().absolute() / "libcmult.so"
c_lib = ctypes.CDLL(libname)
当共享库与Python脚本位于同一目录中时,这将起作用,但当您尝试加载来自Python绑定以外的包的库时,请小心。
调用函数
共享库的函数定义如下:
// cmult.h
float cmult(int int_param, float float_param);
我们需要传递一个整型一个浮点型,并返回浮点型。整形和浮点型在Python和C中都是原生支持的。
一旦将库加载到Python绑定中,库中函数会成为c_lib
的属性,c_lib
是我们上一步创建的CDLL对象。我们可以这样调用库中函数:
x, y = 6, 2.3
answer = c_lib.cmult(x, y)
上面的代码看似合理,但执行会有报错:
$ invoke test-ctypes
Traceback (most recent call last):
File "ctypes_test.py", line 16, in <module>
answer = c_lib.cmult(x, y)
ctypes.ArgumentError: argument 2: <class 'TypeError'>: Don't know how to convert parameter 2
从报错信息可以看出我们需要告诉ctypes哪些非整型的参数,如果我们不明确告诉ctypes,否则ctypes不知道函数的这些信息。任何未标记的参数都假定为整数。ctyps不知道如何将存储在y中的值2.3转换为整数,因此它失败了。
要解决这个问题,我们需要将数字明确声明成一个c_float
。我们可以在调用函数时执行此操作:
# ctypes_test.py
answer = c_lib.cmult(x, ctypes.c_float(y))
print(f"In Python: int: {x} float {y:.1f} return val {answer:.1f}")
现在再运行,将返回传入的两个数字的乘积:
$ invoke test-ctypes
In cmult : int: 6 float 2.3 returning 13.8
In Python: int: 6 float 2.3 return val 48.0
等等,Python代码中$6 \times 2.3=48.0$,这显然是错的!
造成这个问题的原因与输入参数非常类似,ctypes假设函数返回int。实际上,函数返回了一个浮点值,但它的编排转换方式不正确。就像输入参数一样,您需要告诉ctypes使用不同的类型。这里的语法稍稍不同:
# ctypes_test.py
c_lib.cmult.restype = ctypes.c_float
answer = c_lib.cmult(x, ctypes.c_float(y))
print(f"In Python: int: {x} float {y:.1f} return val {answer:.1f}")
修改后代码就运行正常了:
$ invoke test-ctypes
==================================================
= Building C Library
* Complete
==================================================
= Testing ctypes Module
In cmult : int: 6 float 2.3 returning 13.8
In Python: int: 6 float 2.3 return val 48.0
In cmult : int: 6 float 2.3 returning 13.8
In Python: int: 6 float 2.3 return val 13.8
优缺点
ctypes最大的优势是它内置在Python标准库中。我们不需要额外安装它,所有功能都内置在Python中。但是ctypes缺乏自动化,开发稍微复杂的项目时会变得非常麻烦。
Cython
Cython用类似Python的语法来创建Python绑定,然后生成可以编译到模块中的C或C++代码。Cython中有多种方法可以创建Python绑定,其中最常用的方法是使用distutils
的setup
。
安装
Cython可以通过pip直接安装:
$ pip install cython
调用函数
要效用共享库函数,我们需要编写绑定,编译绑定,最后在Python代码中调用绑定。Cython支持C和C++。
编写绑定
在Cython中声明模块的最常见形式是使用.pyx文件:
# cython_example.pyx
""" Example cython interface definition """
cdef extern from "cppmult.hpp":
float cppmult(int int_param, float float_param)
def pymult(int_param, float_param ):
return cppmult(int_param, float_param )
上面的代码分为2部分:
- 4-5行告诉Cython我们会调用
cppmult.hpp
中的cppmult
函数 - 7-8行将
cppmult
函数封装成Python函数
Cython的语法是C、C++和Python的特殊组合。不过,Python开发者对它会很熟悉,因为Cython主体吸收了Python语法。
第一部分cdef extern
告诉Cython下面定义的函数也出现在cppmult.hpp
文件中。这样确保Python绑定的函数接口跟C/C++的声明一致。第二部分看上去跟正常的Python函数一样(实际上它就是一个常规的Python函数),这部分创建一个可以访问C++函数cppmult
的Python函数。
编译绑定
要编译绑定,首先在.pyx文件上运行Cython以生成.cpp文件。完成后,使用g++对.cpp文件进行编译:
# tasks.py
def compile_python_module(cpp_name, extension_name):
invoke.run(
"g++ -O3 -Wall -Werror -shared -std=c++11 -fPIC "
"`python3 -m pybind11 --includes` "
"-I /usr/include/python3.7 -I . "
"{0} "
"-o {1}`python3.7-config --extension-suffix` "
"-L. -lcppmult -Wl,-rpath,.".format(cpp_name, extension_name)
)
def build_cython(c):
""" 编译cython扩展模块 """
print_banner("Building Cython Module")
# 在.pyx文件上运行Cython以生成.cpp文件
invoke.run("cython --cplus -3 cython_example.pyx -o cython_wrapper.cpp")
# 编译并连接cython封装库
compile_python_module("cython_wrapper.cpp", "cython_example")
print("* Complete")
上面的代码首先在.pyx文件上运行cython。这里用了几个选项:
- --cplus告诉Cython生成C++代码
- -3告诉Cython生成Python3代码
- -o cython_wrapper.cpp指定生成文件名称
生成C++文件后,就可以使用C++编译器生成Python绑定。用invoke
运行上面定义的任务:
$ invoke build-cython
==================================================
= Building C++ Library
* Complete
==================================================
= Building Cython Module
* Complete
从输出可以看到,运行上面的任务会先构建cppmult
库,然后再构建cython
模块封装该库。完成构建后我们就得到Cython版Python绑定了。
调用函数
在Python中调用Python绑定函数跟调用常规Python函数一样:
# cython_test.py
import cython_example
x, y = 6, 2.3
answer = cython_example.pymult(x, y)
print(f"In Python: int: {x} float {y:.1f} return val {answer:.1f}")
这里第二行引入的就是新构建的Python绑定模块,第7行调用模块中的pymult()
方法。pymult()
是.pyx文件提供的cppmult()
的Python封装,并将其重命名为pymult()
。我们用invoke运行测试:
$ invoke test-cython
==================================================
= Testing Cython Module
In cppmul: int: 6 float 2.3 returning 13.8
In Python: int: 6 float 2.3 return val 13.8
优缺点
Cython是一个相对复杂的工具,它可以在用C或C++创建Python绑定时为提供深层次的控制。虽然这里没有深入介绍,但它提供了一种Python风格的方法来编写手动控制GIL的代码,这可以显著加速某些问题。
然而,尽管Cython像Python但并不完全是Python,因此,当你使用Cython时,会有一个轻微的学习曲线。
总结
Python中创建Python绑定的工具还有很多,上面只是给大家介绍了最具代表性的2个工具:ctypes
是Python自带的,Cython
可以用类似Python语法的cython语言构建Python绑定。除了这2个工具外,还有CFFI
、PyBind11
、PyBindGen
、Boost.Python
、SIP
、cppyy
、Shiboken
、SWIG
等,这些工具原理大同小异,大家可以自行去学习了解。