基于pybind11为C++提供Python接口
每种编程语言都有其擅长的应用领域,使用C++
可以充分发挥性能优势,而Python
则对使用者更为友好."小朋友才做选择,我全都要!".开发者可以将性能关键的部分以C++
实现,并将其包装成Python
模块.这里基于pybind11
以下列顺序来展示如何实现:
-
示例动态库
-
pybind11
库依赖管理 -
Python
模块 -
语法提示
-
发布包支持
示例环境要求
-
由于
pybind11
使用C++11
,在Windows
环境下需要Visual Studio 2015
及以上版本. -
需要本机安装
Python
-
需要本机安装
CMake 3.15
及以上版本,可以通过Python
的包管理器pip
安装,命令如下:pip install cmake
这里假设使用了Visual Studio 2017
版本,以下的CMake
命令以此为依据.
示例动态库
这里首先提供一个示例用的动态库来模拟常规的C++
代码,其目录结构如下:
-
mylib.hpp
-
mylib.cpp
-
CMakeLists.txt
其中mylib.hpp
内容如下:
#pragma once
#include <string>
int add(int i = 1, int j = 2);
enum Kind {
Dog = 0,
Cat
};
struct Pet {
Pet(const std::string& name, Kind type) : name(name), type(type) { }
std::string name;
Kind type;
};
函数add
的实现在mylib.cpp
中:
#include "mylib.hpp"
int add(int i, int j)
{
return i+j;
}
CMakeLists.txt
的内容也较为简单:
cmake_minimum_required(VERSION 3.15)
#声明工程
project(example
LANGUAGES CXX
VERSION 1.0
)
#创建动态库模块
add_library(mylib SHARED)
target_sources(mylib
PRIVATE mylib.cpp
PUBLIC mylib.hpp
)
set_target_properties(mylib PROPERTIES
WINDOWS_EXPORT_ALL_SYMBOLS True ##自动导出符号
)
现在在源代码目录下执行如下命令可以生成Visual Studio
解决方案来构建:
cmake -S . -B build -G"Visual Studio 15 2017" -T v141 -A x64
解决方案为build/example.sln
.
pybind11
库依赖管理
这里使用CMake
的FetchContent
模块来管理pybind11
库,在CMakeLists.txt
相应位置添加如下内容:
#声明工程
project(example
LANGUAGES CXX
VERSION 1.0
)
#pybind11需要C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
#使用目录结构
set_property(GLOBAL PROPERTY USE_FOLDERS ON)
if(NOT DEFINED CMAKE_RUNTIME_OUTPUT_DIRECTORY)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/$<CONFIG>")
endif()
if(NOT DEFINED CMAKE_LIBRARY_OUTPUT_DIRECTORY)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/$<CONFIG>")
endif()
#下载pybind11并使能
include(FetchContent)
FetchContent_Declare(
pybind11
URL "https://github.com/pybind/pybind11/archive/v2.4.2.tar.gz"
URL_HASH SHA512=05a49f99c1dff8077b05536044244301fd1baff13faaa72c400eafe67d9cb2e4320c77ad02d1b389092df703cc585d17da0d1e936b06112e2c199f6c1a6eb3fc
DOWNLOAD_DIR ${CMAKE_SOURCE_DIR}/download/pybind11
)
FetchContent_MakeAvailable(pybind11)
这样,重新生成解决方案就可以完成pybind11
库的下载和使能了.
需要注意以下几点:
-
CMAKE_RUNTIME_OUTPUT_DIRECTORY
等变量需要统一设置一下,来确保动态库和Python
模块文件输出到相同位置,保证动态库正常加载. -
FetchContent_Declare
的DOWNLOAD_DIR
是可选的,但是由于github
访问不稳定,可以指定该路径位置,并手动下载文件到这里,从而避免每次构建都去访问github
.
Python
模块
向源代码目录添加example.cpp
,目录结构类似如下:
-
mylib.cpp
-
example.cpp
-
CMakeLists.txt
首先修改CMakeLists.txt
来添加Python
模块:
set_target_properties(mylib PROPERTIES
WINDOWS_EXPORT_ALL_SYMBOLS True ##自动导出符号
)
#创建python模块
pybind11_add_module(example)
target_sources(example
PRIVATE example.cpp
)
target_link_libraries(example
PRIVATE mylib
)
重新生成解决方案,则可以看到解决方案中已经有了example
工程,这时库依赖已经配置好,可以正常使用语法提示编写出以下example.cpp
内容:
#include <pybind11/pybind11.h>
#include "mylib.hpp"
namespace py = pybind11;
PYBIND11_MODULE(example, m)
{
m.doc() = "pybind11 example plugin";
//函数:注释、参数名及默认值
m.def("add", &add, "A function which adds two numbers",
py::arg("i") = 1, py::arg("j") = 2);
//导出变量
m.attr("the_answer") = 42;
py::object world = py::cast("World");
m.attr("what") = world;
//导出类型
py::class_<Pet> pet(m, "Pet");
//构造函数及成员变量(属性)
pet.def(py::init<const std::string &, Kind>())
.def_readwrite("name", &Pet::name)
.def_readwrite("type", &Pet::type);
//枚举定义
py::enum_<Kind>(m, "Kind")
.value("Dog", Kind::Dog)
.value("Cat", Kind::Cat)
.export_values();
}
生成解决方案后,在输出目录会出现类似以下内容:
-
mylib.dll
-
example.cp38-win_amd64.pyd
在输出目录启动命令行,进入Python
交互环境,执行如下指令:
import example
help(example.add)
会得到类似如下输出:
>>> help(example.add)
Help on built-in function add in module example:
add(...) method of builtins.PyCapsule instance
add(i: int = 1, j: int = 2) -> int
A function which adds two numbers
试着调用example.add
:
>>> print(example.add())
3
这时我们的Python
模块就开发完成,且能够正常运行了.Visual Studio
支持C++
与Python
联合调试,当遇到执行崩溃等问题时,可以搜索调试方式,在相应的C++
代码处断点查看问题所在.
不过这时生成的Python
模块在Visual Studio Code
等IDE
中并没有较好的语法提示,会导致使用者严重依赖文档,这个问题可以通过提供.pyi
文件来解决.
语法提示
C++
是强类型语言,导出的Python
模块对类型是有要求的,而Python
通过PEP 484 -- Type Hints
支持类型等语法提示,这里可以随着.pyd
提供.pyi
文件来包含Python
模块的各种定义、声明及文档.
在源代码目录添加example.pyi
文件,此时目录结构类似如下:
-
example.cpp
-
example.pyi
-
CMakeLists.txt
修改CMakeLists.txt
文件使得构建example
时自动复制example.pyi
到相同目录:
target_link_libraries(example
PRIVATE mylib
)
#拷贝类型提示文件
add_custom_command(TARGET example POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${CMAKE_CURRENT_SOURCE_DIR}/example.pyi $<TARGET_FILE_DIR:example>/
)
示例example.pyi
文件内容如下:
import enum
def add(i: int = 1, j: int = 2) -> int:
"""两个数值相加
Args:
i (int, optional): [数值1]. Defaults to 1.
j (int, optional): [数值2]. Defaults to 2.
Returns:
int: [相加的结果]
"""
pass
the_answer = 42
what = "World"
class Kind(enum.Enum):
Dog = 0
Cat = 1
class Pet:
name: str
type: Kind
def __init__(self, name: str, type: Kind):
self.name = name
self.type = type
注意,.pyi
文件只是用来作为语法提示,公开的类型、函数、变量不需要填写真实内容,譬如add
只是书写了函数声明,内容直接使用了pass
来略过.
重新生成解决方案后,在输出目录使用Visual Studio Code
创建使用示例,编码过程中就可以看到完整的语法提示和注释内容.
发布包支持
常规的Python
包都可以使用pip
安装,二进制包则一般被打包成.whl
格式并使用以下命令来安装:
pip install xxx.whl
如果希望支持这种方式,则需要提供setup.py
来支持发布包.
在源代码目录添加setup.py
,此时的目录结构类似如下:
-
example.cpp
-
CMakeLists.txt
-
setup.py
其中setup.py
内容如下:
import os
import sys
import subprocess
from setuptools import setup, Extension
from setuptools.command.build_ext import build_ext
# 转换windows平台到CMake的-A 参数
PLAT_TO_CMAKE = {
"win32": "Win32",
"win-amd64": "x64",
"win-arm32": "ARM",
"win-arm64": "ARM64",
}
def setup_init_py(dir):
all = []
for file in [file for file in os.listdir(dir) if file.endswith(".pyd")]:
all.append('"{}"'.format(file.split('.', 1)[0]))
with open(os.path.join(dir, "__init__.py"), "w") as file:
file.write('__all__=[{}]'.format(','.join(all)))
return
class CMakeExtension(Extension):
def __init__(self, name, sourcedir):
Extension.__init__(self, name, sources=[])
self.sourcedir = os.path.abspath(sourcedir)
class CMakeBuild(build_ext):
def build_extension(self, ext):
extdir = os.path.abspath(os.path.dirname(
self.get_ext_fullpath(ext.name)))
if not extdir.endswith(os.path.sep):
extdir += os.path.sep
# 配置生成配置
cfg = "Debug" if self.debug else "Release"
# CMake lets you override the generator - we need to check this.
# Can be set with Conda-Build, for example.
cmake_generator = os.environ.get("CMAKE_GENERATOR", "")
# 通过EXAMPLE_VERSION_INFO可以从python端传递信息到C++的CMakeLists中
# 从而用来控制版本号,也可以用作其它场景
cmake_args = [
"-DCMAKE_RUNTIME_OUTPUT_DIRECTORY={}".format(extdir),
"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={}".format(extdir),
"-DPYTHON_EXECUTABLE={}".format(sys.executable),
"-DEXAMPLE_VERSION_INFO={}".format(
self.distribution.get_version()),
# 在MSVC中没有作用,但是其它需要
"-DCMAKE_BUILD_TYPE={}".format(cfg),
]
build_args = []
if self.compiler.compiler_type != "msvc":
# Using Ninja-build since it a) is available as a wheel and b)
# multithreads automatically. MSVC would require all variables be
# exported for Ninja to pick it up, which is a little tricky to do.
# Users can override the generator with CMAKE_GENERATOR in CMake
# 3.15+.
if not cmake_generator:
cmake_args += ["-GNinja"]
else:
# 单个配置则正常处理
single_config = any(
x in cmake_generator for x in {"NMake", "Ninja"})
# CMake允许在生成器中直接配置架构,这里处理一下,保存后向兼容
contains_arch = any(x in cmake_generator for x in {"ARM", "Win64"})
# 指定MSVC生成器的架构Win32/x64
if not single_config and not contains_arch:
cmake_args += ["-A", PLAT_TO_CMAKE[self.plat_name]]
# 多配置生成器是通过别的方式来处理配置的,这里处理单配置场景
if not single_config:
cmake_args += [
"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{}={}".format(
cfg.upper(), extdir),
"-DCMAKE_RUNTIME_OUTPUT_DIRECTORY_{}={}".format(
cfg.upper(), extdir)
]
build_args += ["--config", cfg]
# 设置CMAKE_BUILD_PARALLEL_LEVEL来控制并行构建
if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ:
# self.parallel is a Python 3 only way to set parallel jobs by hand
# using -j in the build_ext call, not supported by pip.
if hasattr(self, "parallel") and self.parallel:
# CMake 3.12+ only.
build_args += ["-j{}".format(self.parallel)]
# 创建临时目录
if not os.path.exists(self.build_temp):
os.makedirs(self.build_temp)
# 执行配置动作
subprocess.check_call(
["cmake", ext.sourcedir] + cmake_args, cwd=self.build_temp
)
# 执行构建动作
subprocess.check_call(
["cmake", "--build", "."] + build_args, cwd=self.build_temp
)
# 生成__init__.py文件
setup_init_py(extdir)
sourcedir = os.path.dirname(os.path.realpath(__file__))
setup(
name="myexample",
version="0.0.1",
author="liff",
author_email="[email protected]",
description="A example project using pybind11 and CMake",
long_description="",
ext_modules=[CMakeExtension("myexample.impl", sourcedir=sourcedir)],
cmdclass={"build_ext": CMakeBuild},
zip_safe=False,
)
具体setup.py
如何编写可以查阅相关资料,以及代码中的注释,上述setup.py
的内容是可以直接使用的,需要修改的内容均在setup()
中:
setup(
name="myexample", #包名称
version="0.0.1", #包版本
author="liff", #作者
author_email="[email protected]",#作者邮箱
description="A example project using pybind11 and CMake", #包描述
long_description="",
ext_modules=[CMakeExtension("myexample.impl", sourcedir=sourcedir)],
cmdclass={"build_ext": CMakeBuild},
zip_safe=False,
)
声明CMakeExtension
时务必注意,在包名称myexample
后添加.xxxx
,否则生成的发布包内容无法形成文件夹,会被散落到Python
第三方库安装路径site-packages
下,带来不必要的困扰.
完成上述内容后,在源代码目录执行如下命令:
python setup.py bdist_wheel
即可生成相应的.whl
包,这时的目录结构类似以下形式:
-
build
-
dist
-
myexample-0.0.1-cp38-cp38-win_amd64.whl
-
download
-
myexample.egg-info
-
CMakeLists.txt
-
setup.py
其中dist\myexample-0.0.1-cp38-cp38-win_amd64.whl
即为发布包,通过pip
安装:
pip install myexample-0.0.1-cp38-cp38-win_amd64.whl
就可以正常使用myexample
这个Python
模块了.
这里提供一个应用示例共测试使用:
from myexample.example import Pet, Kind, the_answer, what, add
cat = Pet("mycat", Kind.Cat)
dog = Pet("mydog", Kind.Dog)
print(cat.name)
print(dog.name)
print(the_answer)
print(what)
print(add(3, 4))
总结
以上展示了基于pybind11
为C++
库提供Python
模块涉及到的方方面面,可以按照流程操作熟悉一下.这里并没有详细阐述pybind11
的使用方法,留待后续展开.