为Python编写扩展

Python开发效率极高,生态系统异常强大,各种功能包下载即用,在AI领域大放异彩。

然而,Python作为解释型编程语言,运行效率低下是其最大短板。为了兼顾应用的开发效率和运行性能,通常使用其它高级语言(C、C++、Rust等)编写性能敏感的功能,然后再Python中进行调用。

这就是所谓的Python扩展,目前很多三方包都采用扩展的方式进行开发。

Rust(Pyo3)

前提

  • 全局安装maturin工具:uv tool install maturin

创建Rust库项目

  • 新建项目
1
cargo new rust_end --lib
  • Cargo.toml
1
2
3
4
5
6
7
8
9
10
11
12
13
[package]
edition = "2024"
name = "rust_end"
version = "0.1.0"

[lib]
crate-type = ["cdylib"]

[dependencies]
pyo3 = {version = "0.29", features = ["extension-module"]}

[package.metadata.maturin]
bindings = "pyo3"
  • pyproject.toml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[build-system]
build-backend = "maturin"
requires = ["maturin>=1.7,<2.0"]

[project]
description = "pyo3 test demo for testpypi"
keywords = ["rust", "pyo3", "maturin"]
license = "MIT"
name = "rust_end"
readme = "README.md"
requires-python = ">=3.8"
version = "0.1.0"

[tool.maturin]
bindings = "pyo3"
manylinux = "2_28"
universal2 = true
zig = true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
use pyo3::{
Bound, PyResult, Python, exceptions::PyValueError, prelude::*, pyclass, pyfunction, pymodule,
types::PyModule,
};

#[pyfunction]
fn add(a: i64, b: i64) -> i64 {
a + b
}

#[pyfunction]
fn div(a: f64, b: f64) -> PyResult<f64> {
if b == 0.0 {
return Err(PyValueError::new_err("divide by zero"));
}
Ok(a / b)
}

#[pyclass]
struct Counter {
count: u64,
}

#[pymethods]
impl Counter {
#[new]
#[pyo3(signature = (init = None))]
fn new(init: Option<u64>) -> Self {
Counter {
count: init.unwrap_or(0),
}
}

fn inc(&mut self, step: u64) {
self.count += step;
}

fn get(&self) -> u64 {
self.count
}

fn reset(&mut self) {
self.count = 0;
}
}

#[pymodule]
fn rust_end(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(pyo3::wrap_pyfunction!(add, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(div, m)?)?;
m.add_class::<Counter>()?;
Ok(())
}

创建Python测试项目

  • 新建项目
1
uv init python_end --no-package
  • 创建并激活Python虚拟环境
1
2
uv sync
& .venv\Scripts\activate
  • 切换到rust_end项目执行编译

运行以下命令后,Rust项目会自动编译,并在Python项目中安装这个扩展库

1
maturin develop --release
  • 回到python_end项目编写测试代码main.py
1
2
3
4
5
6
7
8
9
import rust_end

def main():
print(f"12+23={rust_end.add(12, 23)}")
print(f"12/23={rust_end.div(12, 23)}")
print(f"12/0={rust_end.div(12, 0)}")

if __name__ == "__main__":
main()
  • 运行测试文件
1
uv run python main.py

发布测试

  • 打包wheel文件:maturin build --release --strip

  • 在Rust项目中安装:uv pip install ..\rust_end\target\wheels\rust_end-0.1.0-cp312-cp312-win_amd64.whl

  • 发布到Test-PyPI:maturin publish --repository testpypi

  • 在Python项目中安装:uv pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple rust_end

nanobind / pybind11

接下来演示如何使用nanobind(pybind11的升级版)将既有C++ SDK包装成Python扩展,这在很多场景下非常有用。

前提

  • 安装nanobind:pip install nanobind

C++动态链接库项目

  • mathkit.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#pragma once

#include <string>
#include <vector>

// 动态链接库导出/导入宏:
// 构建库时 CMake 定义 MATHKIT_EXPORTS -> MATHKIT_API = dllexport
// 使用库时未定义该宏 -> MATHKIT_API = dllimport
#if defined(_MSC_VER)
#ifdef MATHKIT_EXPORTS
#define MATHKIT_API __declspec(dllexport)
#else
#define MATHKIT_API __declspec(dllimport)
#endif
#else
#define MATHKIT_API
#endif

// 一个最小但够“教学”的 C++ 库:包含枚举、类、自由函数。
namespace mathkit {

// 运算类型枚举
enum class Op { Add, Sub };

// 简易计算器:含公开成员、成员函数与默认参数
// 类整体标记 MATHKIT_API,导出其所有成员函数。
class MATHKIT_API Calculator {
public:
double last_result = 0.0;

// 按操作码执行运算,更新并返回 last_result
double apply(Op op, double a, double b);

// 重置结果;可指定初始值(默认 0.0)
double reset(double to = 0.0);
};

// 计算两个向量的点积;长度不一致时抛 std::length_error
MATHKIT_API double dot_product(const std::vector<double>& a, const std::vector<double>& b);

// 返回问候串;name 为空时抛 std::invalid_argument
MATHKIT_API std::string greet(const std::string& name);

} // namespace mathkit
  • mathkit.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include "mathkit.h"

#include <stdexcept>

namespace mathkit {

double Calculator::apply(Op op, double a, double b) {
switch (op) {
case Op::Add: last_result = a + b; break;
case Op::Sub: last_result = a - b; break;
}
return last_result;
}

double Calculator::reset(double to) {
last_result = to;
return last_result;
}

double dot_product(const std::vector<double>& a, const std::vector<double>& b) {
if (a.size() != b.size()) {
// 长度不一致时抛 C++ 异常
throw std::length_error("dot_product: vector size mismatch");
}
double s = 0.0;
for (size_t i = 0; i < a.size(); ++i) s += a[i] * b[i];
return s;
}

std::string greet(const std::string& name) {
if (name.empty()) {
throw std::invalid_argument("greet: name must not be empty");
}
return "Hello, " + name + "! (from C++)";
}

} // namespace mathkit
  • CMakeFiles.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
cmake_minimum_required(VERSION 3.18...4.0)
project(mathkit LANGUAGES CXX)

# C++17
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# 默认构建类型为 Release(仅对单配置生成器生效)。
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()

# 共享库 mathkit:产物为 mathkit.dll / libmathkit.so。
# 构建库时通过 MATHKIT_EXPORTS 宏让头文件中的符号标记为 dllexport。
add_library(mathkit SHARED
src/mathkit.cpp
)
target_include_directories(mathkit PUBLIC src)
target_compile_definitions(mathkit PRIVATE MATHKIT_EXPORTS)

# 源文件以 UTF-8(无 BOM)保存,告知 MSVC 按 UTF-8 解析源字符集,
# 避免中文注释在代码页 936 下被误读导致语法错误。
if(MSVC)
target_compile_options(mathkit PRIVATE /utf-8 /W3)
endif()
  • 编译
1
2
3
4
mkdir build
cd build
cmake .. -G "Visual Studio 17 2022" -A x64
cmake --build . --config Release

包装项目

  • 将C++ SDK赋值到项目下

  • mathkit_wrapper.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <nanobind/nanobind.h>
#include <nanobind/stl/string.h>
#include <nanobind/stl/vector.h>

#include "mathkit.h"

namespace nb = nanobind;
using namespace mathkit;

NB_MODULE(mathkit_wrapper, m) {
nb::enum_<Op>(m, "Op")
.value("Add", Op::Add)
.value("Sub", Op::Sub);

nb::class_<Calculator>(m, "Calculator")
.def(nb::init<>())
.def_rw("last_result", &Calculator::last_result)
.def("apply", &Calculator::apply,
nb::arg("op"), nb::arg("a"), nb::arg("b"))
.def("reset", &Calculator::reset, nb::arg("to") = 0.0);

m.def("dot_product", &dot_product, nb::arg("a"), nb::arg("b"));

m.def("greet", &greet, nb::arg("name"));
}
  • CMakeLists.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
cmake_minimum_required(VERSION 3.18...4.0)
project(mathkit_wrapper LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# ============================================================================
# 本文件是 mathkit_wrapper 的构建脚本:
# 把 C++ 源码编译成 Python 可导入的扩展模块(.pyd),并链接到 mathkit SDK。
# 整体流程:
# 定位 Python -> 定位 nanobind -> 声明扩展模块 -> 链接 SDK 头文件+导入库
# -> 编译成 mathkit_wrapper.pyd -> 拷贝 mathkit.dll 到 .pyd 同目录(运行时依赖)
# ============================================================================

# ---------------------------------------------------------------------------
# 1. 基础声明
# - cmake_minimum_required(3.18...4.0):CMake 版本范围,3.18 到 4.0 都接受。
# - project(... LANGUAGES CXX):声明项目名 mathkit_wrapper,只启用 C++。
# - CMAKE_CXX_STANDARD / _REQUIRED / _EXTENSIONS:
# 强制使用 C++17 标准,并关闭编译器扩展(保证代码可移植性)。
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# 2. 定位 Python
# - find_package(Python ...) 找到当前环境的 Python。
# - 3.8:最低版本要求 3.8。
# - COMPONENTS Interpreter Development.Module:
# 需要三个组件 —— 解释器、头文件、以及构建扩展模块所需的开发库(导入库)。
# - REQUIRED:找不到就直接失败。
# 这一步会导出 Python_EXECUTABLE(python.exe 路径)、Python_INCLUDE_DIRS(头文件)、
# Python_LIBRARIES(python3X.lib)等变量,决定 .pyd 为哪个 Python 版本编译。
# ---------------------------------------------------------------------------
find_package(Python 3.8 COMPONENTS Interpreter Development.Module REQUIRED)

# ---------------------------------------------------------------------------
# 3. 定位 nanobind
# - execute_process 运行一条 shell 命令:python -m nanobind --cmake_dir,
# 让 nanobind 自己报告它的 CMake 配置目录(nanobind 作为 pip 包安装后带 cmake/ 子目录)。
# - 结果存进变量 nanobind_ROOT,并去掉末尾换行。
# - COMMAND_ERROR_IS_FATAL ANY:命令返回非零就立即终止(避免继续用错误配置)。
# - find_package(nanobind CONFIG REQUIRED):基于上面的路径加载 nanobind 的 CMake 包,
# 之后 nanobind_add_module 等命令才可用。
# 顺序很关键:必须先 find_package(Python),因为要用 Python_EXECUTABLE 去调用 nanobind。
# ---------------------------------------------------------------------------
execute_process(
COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
OUTPUT_VARIABLE nanobind_ROOT
OUTPUT_STRIP_TRAILING_WHITESPACE
COMMAND_ERROR_IS_FATAL ANY)
find_package(nanobind CONFIG REQUIRED)

# ---------------------------------------------------------------------------
# 4. 声明扩展模块
# - nanobind_add_module 是 nanobind 提供的命令,把 src/mathkit_wrapper.cpp
# 编译成一个 Python 扩展模块。
# - 模块名 mathkit_wrapper 必须与源码里的 NB_MODULE(mathkit_wrapper, m) 一致,
# 否则 import 会失败。
# ---------------------------------------------------------------------------
nanobind_add_module(mathkit_wrapper
src/mathkit_wrapper.cpp
)

# ---------------------------------------------------------------------------
# 5. 链接 mathkit SDK
# - 第一段把 sdk/include 加进头文件搜索路径,这样 #include "mathkit.h" 才找得到。
# - 第二段链接导入库 mathkit.lib(这是 DLL 的"链接桩",编译链接时用)。
# - CMAKE_CURRENT_SOURCE_DIR:当前 CMakeLists 所在目录(即 mathkit_wrapper/)。
# - PRIVATE:这些路径只在编译本模块时用,不向外传播。
# ---------------------------------------------------------------------------
target_include_directories(mathkit_wrapper PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/sdk/include")
target_link_libraries(mathkit_wrapper PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/sdk/lib/win64/mathkit.lib")

# ---------------------------------------------------------------------------
# 6. MSVC 专有选项
# - if(MSVC):只在用微软编译器时生效。
# - /utf-8:告知 MSVC 源码按 UTF-8 解析
# (否则中文注释在代码页 936 下会乱码导致编译错误)。
# - /W3:开启第 3 级警告。
# ---------------------------------------------------------------------------
if(MSVC)
target_compile_options(mathkit_wrapper PRIVATE /utf-8 /W3)
endif()

# ---------------------------------------------------------------------------
# 7. 复制依赖 DLL(关键一步)
# - POST_BUILD:在扩展模块构建完成之后执行。
# - copy_if_different:把 mathkit.dll 拷贝到 .pyd 所在目录,内容没变则跳过。
# - $<TARGET_FILE_DIR:mathkit_wrapper>:生成器表达式,展开成 .pyd 的真实输出目录。
# 为什么需要这步:
# .pyd 运行时动态加载 mathkit.dll;Windows 加载 DLL 时默认只在同一目录
# (以及系统路径)查找。把 .dll 放到 .pyd 旁边,Python import mathkit_wrapper
# 时才能顺带找到 mathkit.dll,否则报 ImportError: DLL load failed。
# ---------------------------------------------------------------------------
add_custom_command(TARGET mathkit_wrapper POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/sdk/lib/win64/mathkit.dll"
"$<TARGET_FILE_DIR:mathkit_wrapper>"
COMMENT "Copying mathkit.dll next to the extension module")
  • 创建Python测试项目(包装项目的编译依赖特定的Python环境,并且需要在该环境中安装nanobind包)
    • 创建项目:uv init python_test --no-pakage
    • 安装nanobind:uv pip install nanobind
    • 安装虚拟环境:uv sync
    • 激活虚拟环境:& .\.venv\Scripts\activate
  • 编译
1
2
3
4
mkdir build
cd build
cmake .. -G "Visual Studio 17 2022" -A x64
cmake --build . --config Release

Python测试项目

  • 将mathkit.dll和生成的pyd文件拷贝到测试项目根目录下
  • main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import os
import sys

_HERE = os.path.dirname(os.path.abspath(__file__))
_WRAPPER_DIR = os.path.normpath(
os.path.join(_HERE, ".", "Lib")
)

sys.path.insert(0, _WRAPPER_DIR)
_dll_handle = os.add_dll_directory(_WRAPPER_DIR)

import mathkit_wrapper as mk

print(mk.greet("独立项目"))
print("dot_product([1,2],[3,4]) =", mk.dot_product([1.0, 2.0], [3.0, 4.0]))

c = mk.Calculator()
print("add(1.5, 2.5) =", c.apply(mk.Op.Add, 1.5, 2.5))
print("last_result =", c.last_result)

(转载本站文章请注明作者和出处lihaohello.top,请勿用于任何商业用途)