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
包后,即可成功导入该项目。