Python/C/C++混合项目
Python/C/C++混合项目
引言
Python是动态语言,不需要复杂的编译链接等过程,因此开发调试的速度非常快,适合快速搭建起一个软件的原型。当程序耗时的主要瓶颈在IO的时候,动态解释器产生的开销是微不足道的,因此Python非常适合爬虫这类的工作。但是对于一些计算密集型程序,Python的执行速度就不是太能接受了。因此,一个常见的思路就是将计算密集的部分用C/C++来实现,之后把库包装出一个Python接口,嵌入到整个项目中。
cython和pybind11是常用的两种解决方案。cython是一种集成了C的Python编译器,它的大致思路是将已有的Python代码编译成C的扩展模块。cython的语法类似于加上了类型注解的Python,因此原本用Python编写的代码可以比较容易修改并通过cython编译成C语言的扩展;pybind11则是一个轻量级的头文件库,它的大致思路是将C++代码封装后暴露给Python模块,和cython是反过来的过程。pybind11允许直接用C++编写单元,之后通过接口将这些暴露给Python。
当然,这两种方案也可以同时集成在同一个项目中。接下来通过一个简单的示例,梳理一下构建一个同时包含Python模块、cython模块和pybind11模块混合项目的大致思路。
项目示例
目录结构
1 | phono/ |
编写一个非常简单的示例,整个包的名字叫作phono,包含三个子包,cyth、py、cpp。在Python中,包就是一个有__init__.py文件的目录,模块就是一个.py文件,一个包可以包含多个模块。如果目录中没有__init__.py文件,解释器就无法识别这个目录是一个包,就没法用import ...命令导入。在这里,每个子包均包含一个mymath模块,每个模块实现各自的函数。cpp的mymath模块实现一个乘法函数my_multiply,cyth的mymath模块实现一个加法函数my_add,py中的模块调用这两个函数,实现一个加乘法函数my_add_multiply。
phono/:
__init__.py: 标识phono为一个Python包。setup.py: Python项目的安装脚本,定义了如何构建和安装整个项目。CMakeLists.txt: 主目录的CMake文件,做一些项目的基本配置,引导向各子目录的CMakeLists.txt。scripts.py: 可以定义一些脚本函数,在setup.py中进行注册,可以直接在命令行调用某些命令。
py/:
__init__.py: 标识py为一个子包。mymath.pyx: 一个Python文件,包含用Python编写的函数。
cyth/:
__init__.py: 标识cython为一个子包。mymath.pyx: 一个cython文件,包含用cython编写的函数。
cpp/:
include/: 包含C++头文件的目录,放置函数或类的声明。mymath.h: C++头文件,声明了在mymath.cpp中实现的函数。
src/: 包含C++源文件的目录。这里实现通过pybind11暴露给Python的C++函数。mymath.cpp: C++源文件,包含C++的函数实现。math_wrapper.cpp: 通过pybind11将C++实现封装为Python模块。将C++函数实现与封装隔离,方便后期可能将全部Python代码移植为C++版本。
CMakeLists.txt: CMake构建脚本,用于配置和编译C++代码,生成可供Python调用的共享库(.so或.pyd文件)。
cython包
__init__.py文件留空,仅用于标识这是一个包。mymath.pyx中导入cython库,定义一个简单的加法函数。cdef的函数只能在.pyx文件内部使用,cpdef的函数可以同时在.pyx文件和Python环境中调用,cython会对结果做一层包装。
1 | # mymath.pyx |
pybind11包
三个文件的内容分别如下所示。在mymath_wrapper.cpp中,调用PYBIND11_MODULE,将my_multiply函数注册到mymath模块中。
1 | // src/math.cpp |
cpp目录下的CMakeLists.txt如下所示:
1 | # cpp/CMakeLists.txt |
在这里,为了方便在不同主机上进行调试,可以在cmake中查询电脑的名字,根据不同的名字,指向不同的Pybind目录。注意这里的目录不是pybind11的根目录,而是包含了cmake模块文件(Find<PackageName>.cmake)或配置文件(<PackageName>Config.cmake)的目录。根据这些文件,find_package会进行相应的配置,设置一些变量、获得一些函数等等。比如pybind11_add_module函数可以帮助创建Python模块。它会设置所有必要的编译选项、链接库和目标属性,以确保生成的模块可以正确地导入到Python中。
Python包
__init__.py文件内容留空,mymath.py中采用相对导入同时调用pybind的包和cython的包:
1 | from ..cyth import mymath as cyth_mymath |
在phono根目录的setup.py中,可以把py包中的所有模块都导入进来。这样实际上就没有py的这一层命名空间了,py包的划分对外可以变成不可见的:
1 | from .py.mymath import my_add_multiply |
集成与调用
在phono根目录下的CMakeLists.txt中,设置cmake的入口:
1 | # CMakeLists.txt |
add_subdirectory指定了cmake的子目录,cmake会递归地执行子目录中的CMakeLists.txt。之后,设置setup.py,以便于在Python环境中构建并安装相应的包:
1 | # setup.py |
这里改写了自带的build_ext类,完全由命令行来生成对应的库,这需要我们对setup()的运行逻辑有大致的了解。总的来说,setup() 函数运行的流程就是根据用户输入的配置参数,构建和初始化一系列内部对象,并调用这些对象的方法来完成打包、构建和安装等操作,大体上包含以下几个步骤:
- 读取参数: 调用
setup()时,接收用户提供的关键字参数,这些参数用于描述包的元数据、包的结构、依赖关系、扩展模块配置等。 - 构建配置对象:
setup()用这些参数,创建并初始化一个Distribution对象。self.distribution负责存储所有与打包和分发相关的信息。 - 初始化和配置:其他类(尤其是
Command子类)会通过self.distribution访问和使用这些信息,进行一些其他的初始化,并保存这些配置信息。每个Command子类对应一个setup.py支持的命令(例如build、install、sdist等)。 - 调用方法执行操作: 配置对象初始化后,
setup()会根据命令行输入(如python setup.py install或python setup.py build_ext),选择并调用相应的Command对象的方法。这些方法负责执行具体的操作,如构建二进制文件、生成分发包、安装包等。 - 完成构建/安装流程: 在整个过程中,这些对象的方法会处理文件、编译代码、生成元数据文件、执行钩子函数等。
setup()的主要工作就是协调这些对象之间的调用,确保每个步骤都按顺序正确执行。
在这里,具体指定的参数说明如下:
name:项目的名称,这个名称只在安装或分发时会用到,与包的名称没有关系。比如在使用pip install安装包时,name参数就是安装的包名。在生成的dist-info目录中,这个名称也会作为目录的一部分。version:指定项目的版本号。在dist-info目录中,这个版本号同样会出现在目录名称中,并且在安装时pip会根据版本号来决定是否需要更新或替换现有版本。
比如对于在我的电脑的Anaconda的PhononMC环境中安装的0.1版本的phono包,就会在D:\Anaconda3\envs\PhononMC\Lib\site-packages下生成一个phono-0.1.dist-info目录,里面储存了项目信息。pip实际上就是根据这些信息,进行包的依赖管理、升级和删除等等。
packages: 一个包含包名的列表,用来告诉setuptools哪些包(即哪些目录)需要被打包并包含在最终的分发包中。setup.py不会自动包含任何子包或模块,导致安装时只会包含那些显式指定的模块,而其他模块则不会被打包和安装。setuptools模块提供了一个find_packages()函数,它会则会自动扫描和包含所有符合条件的包(包含__init__.py的目录)。package_data:packages默认只会打包和复制包目录中的Python文件,其他类型的文件,如文本文件、配置文件、图片等,可以通过packages_data参数来指定。它的格式是一个字典,键是包的名称,值是一个文件模式列表。在这里,包含了编译出来的*.pyd库文件。采用package_data的好处首先是setup会帮我们打包和复制,另外是它会注册到dist-info目录中的RECORD记录文件中,这样pip uninstall phono的时候可以正确删除这些文件。ext_modules:指定setuptools如何编译 C 或 C++ 编写的扩展模块,并将其包含在最终的 Python 包中。ext_modules参数接受一个包含Extension对象的列表。每个Extension对象代表一个扩展模块,包含模块的名称、源文件、依赖库和其他构建信息。在这里,我们为了统一处理pybind和cython的包,采用cmake自行编译,因此这里就不需要这个参数了,只需要把编译出来的包放到package_data中。cmdclass:用于指定自定义的命令类,上面我们说的,setup()中会生成一系列Command类对象来执行构建、安装等操作。我们可以重写这些类,以扩展或替代setuptools默认的命令行为。比如我们这里自定义了一个CustomBuildExt类,来替换原有的build_ext类行为。此时,我们运行python setup.py build_ext时就会执行我们自定义的CustomBuildExt类中的逻辑。在这里,我们在
CustomBuildExt中定义了两个函数,build_cmake调用cmake和ninja生成pybind的库文件,并把生成目录中的.pyd文件复制到源文件目录中。build_cython调用cythonize,生成cython的库文件。entry_points:用于定义项目的可执行入口点、插件机制和其他扩展点。它允许我们将 Python 函数或类注册为命令行工具、插件或其他可由外部调用的入口。比如在这里,我们把script.py中定义的say_hello函数设置为入口点。在安装phono包后,在命令行中输入say_hello,即可调用该函数。
1 | # phono/scripts.py |
在phono根目录执行pip install .成功安装phono包后,即可成功导入该项目。