数据类型转换(上):基本类型在 Rust 与 Python 间的双向传递

说实话,跨语言调用时最让人头疼的,往往不是业务逻辑,而是数据类型怎么对齐。我刚开始做 Rust 和 Python 混合编程时,就踩过不少坑——明明 Python 那边传过来一个整数,Rust 这边死活不认。说白了,两套类型系统各有各的脾气,得摸透了才能让它们好好配合。

这一章,咱们就专门聊聊基本类型:intfloatboolstr。别看它们基础,实际项目中 80% 的数据传递都离不开它们。我会结合 PyO3 这个工具,带你走一遍双向转换的完整流程。

核心要点:Rust 和 Python 的类型不是一一对应的。转换时,你得考虑精度、内存布局、所有权这些细节。别怕,咱们一个一个来。

1. 整体架构:数据是怎么跨过语言边界的?

先看一张图,帮你建立整体认知。我习惯把跨语言调用想象成「两个国家之间的海关」——数据要过境,必须经过检查、打包、再拆包的过程。

Python 世界 int, float, bool, str 动态类型,运行时检查 Rust 世界 i32, f64, bool, String 静态类型,编译期确定 PyO3 转换层 提取 包装 类型检查 Python → Rust:提取(.extract()) Rust → Python:包装(.into_py()) 双向箭头表示数据流方向,中间是 PyO3 的自动/手动转换层

你看,Python 那边是动态类型,Rust 这边是静态类型。PyO3 就像海关检查员,负责把 Python 的 PyAny 对象「拆包」成 Rust 的具体类型,反过来把 Rust 的值「打包」成 Python 对象。

我的习惯:在写跨语言接口时,我会先在纸上画一遍类型映射图。别嫌麻烦,画清楚了,后面编码基本不会出类型相关的 bug。

2. 整数类型:精度和范围是第一道坎

先看整数。Python 的 int 是任意精度的,想多大就多大。Rust 的整数类型可就多了:i32i64u32u64……每个都有固定范围。

举个例子,Python 里 2**100 轻轻松松,但你要把它传给 Rust 的 i32,那肯定炸锅。为什么会这样?因为 i32 最大只能表示 2,147,483,647。

2.1 Python → Rust:用 .extract() 提取

use pyo3::prelude::*;

#[pyfunction]
fn add_one(x: i32) -> i32 {
    x + 1
}

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

这段代码里,x: i32 就是告诉 PyO3:「请把 Python 传进来的值转成 i32」。如果 Python 那边传了个超大整数,PyO3 会直接抛 OverflowError。嗯,这里要注意:不是所有 Python int 都能安全落地。

我曾经踩过的坑:有一次,Python 端传了一个 numpy.int64 类型的值,表面上看是整数,但 PyO3 的自动转换没处理这个类型。结果程序跑着跑着就 panic 了。后来我加了一层手动检查,用 obj.extract::<i64>() 之前先判断类型,才彻底解决。

2.2 Rust → Python:用 .into_py() 包装

use pyo3::prelude::*;

#[pyfunction]
fn get_large_number() -> PyResult<i64> {
    Ok(1_000_000_000_000)  // 1万亿
}

Rust 的 i64 转成 Python 的 int,基本是零开销的。Python 内部用 30 位或 15 位 digit 存储小整数,超过这个范围会自动升级为任意精度。所以 Rust 的 i64 过去,Python 那边完全接得住。

Rust 类型 Python 类型 注意事项
i8 / u8 int 范围小,一般够用
i32 / u32 int 最常用,性能好
i64 / u64 int 注意 Python 端无符号概念
isize / usize int 平台相关,32位/64位不同
我个人的建议:如果拿不准用哪个整数类型,就选 i64。它覆盖了绝大多数场景,而且 PyO3 对它的支持最成熟。

3. 浮点数类型:精度损失是隐形杀手

浮点数这块,Python 的 float 对应的是 C 的 double,也就是 Rust 的 f64。Python 没有 f32 的概念,所以 Rust 的 f32 传过去会被自动提升为 f64

你想想看,这其实有个隐患:Rust 的 f32 精度只有 7 位有效数字,Python 的 float 有 15 位。从 Rust 往 Python 传 f32 时,那些「多出来」的精度位其实是随机填充的,容易让人误解。

3.1 双向转换示例

use pyo3::prelude::*;

#[pyfunction]
fn calculate_distance(x: f64, y: f64) -> f64 {
    (x * x + y * y).sqrt()
}

#[pyfunction]
fn return_pi() -> f64 {
    std::f64::consts::PI
}

Python 那边调用:

import my_module

result = my_module.calculate_distance(3.0, 4.0)
print(result)  # 5.0

pi = my_module.return_pi()
print(pi)  # 3.141592653589793

你看,浮点数的传递非常丝滑。但要注意:NaNInfinity 也是可以传递的。Python 那边能识别 float('nan')float('inf'),Rust 这边用 f64::NANf64::INFINITY 对应。

我曾经遇到的情况:有一次做科学计算,Rust 端算出了一个 NaN,传回 Python 后,Python 端没做 math.isnan() 检查,直接拿去绘图,结果图上一片空白。排查了半天才发现是 NaN 在作怪。所以,跨语言传递浮点数时,建议两端都做 NaN 检查。

4. 布尔类型:简单但别大意

布尔类型是最简单的。Python 的 True/False 对应 Rust 的 true/false,一一对应,没有歧义。

use pyo3::prelude::*;

#[pyfunction]
fn is_even(x: i32) -> bool {
    x % 2 == 0
}

#[pyfunction]
fn toggle(flag: bool) -> bool {
    !flag
}

Python 调用:

print(my_module.is_even(4))   # True
print(my_module.is_even(5))   # False
print(my_module.toggle(True)) # False

不过有一点要注意:Python 里 boolint 的子类。这意味着 True == 1 在 Python 里是成立的。但 Rust 这边,booli32 是彻底不同的类型,不能混用。如果你不小心把 1 传给了期望 bool 的 Rust 函数,PyO3 会报类型错误。

我的习惯:在 Rust 函数签名里,明确用 bool 而不是 i32 来表示布尔语义。这样代码自文档化,别人一看就知道这个参数是「是/否」的意思。

5. 字符串类型:所有权和编码是重头戏

字符串这块,水比较深。Python 的 str 是 Unicode 字符串,内部用 UTF-8 或 UTF-16 或 Latin-1 编码(取决于具体实现)。Rust 的 String&str 则是严格的 UTF-8 编码。

说白了,两边都是 Unicode,但内部表示不同。PyO3 在转换时会做编码转换,这个过程有开销,但保证了正确性。

5.1 Python → Rust:字符串提取

use pyo3::prelude::*;

#[pyfunction]
fn greet(name: &str) -> String {
    format!("你好,{}!", name)
}

#[pyfunction]
fn count_chars(text: String) -> usize {
    text.chars().count()  // 注意:不是 .len()
}

这里有个细节:&strString 都可以作为参数类型。用 &str 表示只借用,不获取所有权;用 String 表示获取所有权。我个人习惯用 &str,除非我需要修改或存储这个字符串。

5.2 Rust → Python:字符串返回

#[pyfunction]
fn get_hello() -> String {
    "Hello from Rust!".to_string()
}

#[pyfunction]
fn repeat_string(s: &str, n: usize) -> String {
    s.repeat(n)
}

Python 调用:

print(my_module.get_hello())        # Hello from Rust!
print(my_module.repeat_string("Hi ", 3))  # Hi Hi Hi 

字符串传递时,PyO3 会做一次内存拷贝。因为 Python 的字符串对象和 Rust 的字符串对象内存布局不同,必须复制。对于大字符串(比如几 MB 的文本),这个拷贝开销不可忽视。

我曾经踩过的坑:有一次处理一个 100MB 的 JSON 字符串,每次从 Python 传到 Rust 都要拷贝一次,性能直接崩了。后来我改用 PyBytes 传递原始字节,在 Rust 端自己解析,才把性能提上来。所以,超大字符串建议走字节流,别走 str 通道。

6. 综合对照表:一目了然

为了方便你查阅,我把四种基本类型的双向映射整理成了一张表。你写代码时可以随时回来翻看。

Python 类型 Rust 接收类型 Rust 返回类型 注意事项
int i32, i64, u32, u64, isize, usize i32, i64, u32, u64, isize, usize 注意溢出,Python int 无上限
float f64(推荐), f32 f64(推荐), f32 f32 传回 Python 会升为 f64
bool bool bool Python 中 bool 是 int 子类,注意区分
str &str, String, Py<PyString> String, &str, Py<PyString> 大字符串注意拷贝开销
一句话总结:基本类型的双向传递,PyO3 已经帮你做好了 90% 的工作。你只需要关注精度、范围、所有权和编码这四个维度,就能避开绝大多数坑。

好了,这一章的内容就到这里。下一章我们会继续聊更复杂的数据类型——列表、字典、元组这些容器类型怎么跨语言传递。到时候你会发现,基本类型是基础,容器类型才是真正的挑战。


公众号:蓝海资料掘金营,微信deep3321