使用pybind11为C++提供python接口

简介 这篇文章主要介绍了基于pybind11为C++提供Python接口以及相关的经验技巧,文章约28320字。

每种编程语言都有其擅长的应用领域,使用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命令以此为依据.(Linux下大同小异)

示例动态库

这里首先提供一个示例用的动态库来模拟常规的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的使用方法,留待后续展开.

参考:caimouse主页

Guess you like

Origin blog.csdn.net/wq_0708/article/details/121214825