用Python绑定调用C/C++/Rust库

在《让你的Python程序像C语言一样快》我们学习了如何利用Python API来用C语言编写Python模块,通过将核心功能或性能敏感运算用C语言实现,Python程序可以运行地像C语言一样快。然而,很多时候我们需要的功能已经有人实现了,我们并不需要从头再实现一遍,只需要调用封装好的库即可,此时就需要用到Python绑定。Python绑定可以让Python程序调用C/C++/Rust编译的库函数,从而让我们在不重复造轮子的前提下,兼具Python和C/C++二者的优点。

Python 安装 rust python+rust_Python 安装 rust


文章目录

  • Python绑定概述
  • 数据类型转换
  • 理解可变性和不可变性
  • 内存管理
  • 开发环境
  • invoke
  • ctypes
  • 安装
  • 调用函数
  • 加载库
  • 调用函数
  • 优缺点
  • Cython
  • 安装
  • 调用函数
  • 编写绑定
  • 编译绑定
  • 调用函数
  • 优缺点
  • 总结


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-devpython3-devel(取决于所使用的Linux发行版)
  • Windows请参考这里
  • 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绑定,需要如下步骤:

  1. 加载库
  2. 封装输入参数
  3. 告诉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代码中Python 安装 rust python+rust_rust_02,这显然是错的!

造成这个问题的原因与输入参数非常类似,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绑定,其中最常用的方法是使用distutilssetup

安装

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个工具外,还有CFFIPyBind11PyBindGenBoost.PythonSIPcppyyShibokenSWIG等,这些工具原理大同小异,大家可以自行去学习了解。