PyO3基础入门:创建第一个Rust扩展模块

好,咱们正式开始动手了。这一章,我会带你从零搭建一个Rust扩展模块,然后在Python里调用它。说白了,就是让Rust给Python打工——而且干得又快又好。

我个人习惯先把环境搭好,再写代码。这样心里踏实。

环境准备:你需要什么

先确认你装了这些东西:

  • Rust工具链(rustc + cargo)——版本1.56以上,我建议用最新的stable
  • Python 3.7+——最好带venv或conda
  • maturin——这是构建工具,装一次就行

安装maturin很简单:

pip install maturin

嗯,这里要注意:别用pipx装,直接pip装到你的虚拟环境里。我在项目中遇到过有人用pipx装完找不到命令,折腾了半天。

创建项目:cargo + maturin 两步走

打开终端,执行:

maturin init --bindings pyo3

它会问你项目名,我一般取个有意义的,比如 rust_math。然后你会看到这样的目录结构:

rust_math/
├── Cargo.toml
├── pyproject.toml
└── src/
    └── lib.rs

打开 Cargo.toml,确认里面有这一行:

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

为什么?因为Python加载的是动态链接库,不是Rust的rlib。这个坑我踩过——第一次写的时候忘了加,编译出来Python说找不到模块。

写第一个Rust函数:加法

打开 src/lib.rs,把默认代码替换成:

use pyo3::prelude::*;

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

#[pymodule]
fn rust_math(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(add, m)?)?;
    Ok(())
}

这段代码干了三件事:

  1. #[pyfunction] 标记一个Rust函数,告诉PyO3“这个函数要暴露给Python”
  2. #[pymodule] 定义模块入口,名字必须和项目名一致
  3. 在模块初始化时,把函数注册进去

你想想看,这其实就是一个“桥接”的过程。Rust函数本身是普通的Rust代码,加上宏之后,PyO3自动帮你生成了Python能调用的C接口。

编译并测试

在项目根目录执行:

maturin develop

它会编译Rust代码,然后把生成的 .so.pyd 文件安装到当前Python环境。我第一次跑的时候,看到编译成功那一瞬间,心里还是挺爽的。

然后打开Python测试:

import rust_math
print(rust_math.add(3, 5))  # 输出 8

搞定。你已经在Python里调用了Rust函数。

核心要点:整个流程就是“写Rust函数 → 加宏标记 → 注册到模块 → 编译 → Python调用”。记住这个流程,后面所有扩展模块都是这个套路。

理解Python对象与Rust类型的转换

刚才的例子用了 i64,这是Rust类型。PyO3会自动把Python的 int 转成 i64。但并不是所有类型都能自动转。

我整理了一张常用映射表:

Python类型 Rust类型 说明
int i32, i64, u32, u64 自动转换,注意溢出
float f32, f64 自动转换
bool bool 直接对应
str String, &str 注意生命周期
list Vec<T> 需要T也支持转换
dict HashMap<K, V> 键值都需要可转换
None Option<T> 用Some/None处理

为什么会这样?因为PyO3在底层实现了 FromPyObjectIntoPy 这两个trait。每种类型只要实现了这两个trait,就能在Python和Rust之间自由穿梭。

举个例子,如果你想接收一个Python列表,返回它们的和:

#[pyfunction]
fn sum_list(numbers: Vec<i64>) -> i64 {
    numbers.iter().sum()
}

Python调用:

print(rust_math.sum_list([1, 2, 3, 4, 5]))  # 输出 15

你看,PyO3自动帮你把Python的 list 转成了Rust的 Vec<i64>。但这里有个坑——如果列表里混了字符串,运行时会直接panic。我曾经在生产环境遇到过这个问题,用户传了个混合类型的列表,程序直接崩溃了。

避坑指南:我曾经在解析配置文件时,用 Vec<HashMap<String, String>> 接收数据。结果用户传了个嵌套结构,某个字段是整数不是字符串,PyO3直接报类型错误。后来我改用 serde_json::Value 做中转,才彻底解决。

处理可选值和默认值

Python函数经常有可选参数。在Rust里,用 Option<T> 表示:

#[pyfunction]
fn greet(name: Option<String>) -> String {
    match name {
        Some(n) => format!("Hello, {}!", n),
        None => "Hello, world!".to_string(),
    }
}

Python调用:

print(rust_math.greet())           # Hello, world!
print(rust_math.greet("Rust"))     # Hello, Rust!

这个模式很实用。我写CLI工具时经常用,用户不传参数就给默认行为,传了就定制化。

返回复杂类型

Rust函数也可以返回Python对象。比如返回一个字典:

use std::collections::HashMap;

#[pyfunction]
fn get_scores() -> HashMap<String, i64> {
    let mut map = HashMap::new();
    map.insert("Alice".to_string(), 95);
    map.insert("Bob".to_string(), 87);
    map
}

Python调用:

scores = rust_math.get_scores()
print(scores)  # {'Alice': 95, 'Bob': 87}

PyO3会把 HashMap 自动转成Python的 dict。但要注意,如果key不是字符串,或者value类型不匹配,转换会失败。

小技巧:如果你不确定某个类型能不能自动转换,可以查PyO3官方文档的“Type Conversion”章节。或者更简单——写个测试跑一下,看报不报错。

错误处理:从Rust抛异常到Python

Rust用 Result 处理错误,PyO3支持把Rust错误转成Python异常:

#[pyfunction]
fn divide(a: f64, b: f64) -> PyResult<f64> {
    if b == 0.0 {
        return Err(pyo3::exceptions::PyZeroDivisionError::new_err("除数不能为零"));
    }
    Ok(a / b)
}

Python调用:

try:
    print(rust_math.divide(10, 0))
except ZeroDivisionError as e:
    print(e)  # 除数不能为零

你看,Rust的 Err 变成了Python的 ZeroDivisionError。这种错误映射非常自然,Python开发者几乎无感。

我个人习惯把所有可能失败的函数都返回 PyResult,这样错误处理更统一。别偷懒用 unwrap(),一旦panic,整个Python进程都会挂掉。

本章知识体系

下面这张图帮你理清本章的核心逻辑:

PyO3基础入门:核心流程 Python 调用方 调用 PyO3 自动转换 (FromPyObject / IntoPy) 调用 Rust 函数实现 类型转换示例 Python int → Rust i64 Python str → Rust String Python list → Rust Vec<T> Python dict → Rust HashMap<K,V> Python None → Rust Option<T> ← 自动转换 ← 注意生命周期 ← 元素类型需匹配 ← 键值类型需匹配 ← 处理空值

这张图展示了整个调用链路:Python发起调用 → PyO3自动做类型转换 → Rust函数执行 → 结果再转换回去。你只要写好中间那部分Rust代码,剩下的PyO3帮你搞定。

好了,这一章的内容就到这里。你学会了创建项目、写第一个函数、理解类型转换、处理错误。这些是PyO3最基础也最核心的知识点。下一章我们会深入更复杂的场景,比如操作Python对象、调用Python函数等。


专注资料整理