vlambda博客
学习文章列表

基于pybind11为C++提供Python接口

每种编程语言都有其擅长的应用领域,使用C++可以充分发挥性能优势,而Python则对使用者更为友好."小朋友才做选择,我全都要!".开发者可以将性能关键的部分以C++实现,并将其包装成Python模块.这里基于pybind11以下列顺序来展示如何实现:

  • 示例动态库

  • pybind11库依赖管理

  • Python模块

  • 语法提示

  • 发布包支持

示例环境要求

  1. 由于pybind11使用C++11,在Windows环境下需要Visual Studio 2015及以上版本.

  2. 需要本机安装Python

  3. 需要本机安装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库依赖管理

这里使用CMakeFetchContent模块来管理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库的下载和使能了.

需要注意以下几点:

  1. CMAKE_RUNTIME_OUTPUT_DIRECTORY等变量需要统一设置一下,来确保动态库和 Python模块文件输出到相同位置,保证动态库正常加载.
  2. FetchContent_DeclareDOWNLOAD_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 CodeIDE中并没有较好的语法提示,会导致使用者严重依赖文档,这个问题可以通过提供.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))

总结

以上展示了基于pybind11C++库提供Python模块涉及到的方方面面,可以按照流程操作熟悉一下.这里并没有详细阐述pybind11的使用方法,留待后续展开.