PyO3 基础对象定制指南:为 #[pyclass] 实现 repr、str、哈希、比较与布尔语义
【免费下载链接】pyo3Rust bindings for the Python interpreter项目地址: https://gitcode.com/gh_mirrors/py/pyo3
本篇指南以 PyO3 官方文档《Basic object customization》(guide/src/class/object.md)为骨架,系统讲解如何为 Rust 编写的#[pyclass]类型补齐 Python 基础对象协议:字符串表示(__repr__/__str__)、哈希(__hash__)、比较运算(__richcmp__/__eq__等)与真假值(__bool__)。读完本文,你将能够把一个只能被实例化的裸#[pyclass]类,打磨成行为与原生 Python 类一致的完整对象,并通过 PyO3 的编译期选项(str、eq、ord、hash)大幅简化样板代码。
起点:一个只支持实例化的 Number 类
回顾前一章(PyO3 class 基础)定义的Number类:它通过#[pyclass]声明为 Python 类,并在#[pymethods]中提供#[new]构造器,最后经#[pymodule]导出:
use pyo3::prelude::*; #[pyclass] struct Number(i32); #[pymethods] impl Number { #[new] fn new(value: i32) -> Self { Self(value) } } #[pymodule] mod my_module { #[pymodule_export] use super::Number; }此时 Python 代码已经可以导入模块、访问类并创建实例,但除此之外什么都做不了:
from my_module import Number n = Number(5) print(n)<builtins.Number object at 0x000002B4D185D7D0>输出的是 Python 默认的对象表示,没有任何可读信息。这正是本篇文章要解决的问题——通过实现 Python 的特殊方法(dunder 方法)让自定义类具备完整的对象语义。
字符串表示:repr与str
连一个可读的自我表示都打印不出来显然无法接受。修复方式是在#[pymethods]块内定义__repr__和__str__方法,并通过访问Number内部的字段来构造字符串:
#[pymethods] impl Number { // 对于 `__repr__`,我们希望返回一段 Python 代码可以直接用来重建 // `Number` 的字符串,例如 `Number(5)`。 fn __repr__(&self) -> String { // `format!` 宏的第一个参数是格式字符串,后面的参数会替换 // 格式串中的 `{}` 占位符。 // // 👇 Rust 中访问元组字段用点号 format!("Number({})", self.0) } // `__str__` 通常用于生成"非正式"的表示,这里直接转发给 // `i32` 的 `ToString` trait 实现,打印一个裸数字。 fn __str__(&self) -> String { self.0.to_string() } }两个方法的职责分工与 Python 语义完全一致:__repr__面向开发者,追求"无歧义、可重建";__str__面向用户,追求"可读、自然"。
用 pyclass(str) 自动生成str
如果想让__str__直接复用 Rust 的Displaytrait 实现,可以给#[pyclass]传str参数,PyO3 会自动生成__str__:
use std::fmt::{Display, Formatter}; use pyo3::prelude::*; #[pyclass(str)] struct Coordinate { x: i32, y: i32, z: i32, } impl Display for Coordinate { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "({}, {}, {})", self.x, self.y, self.z) } }这一选项在宏后端的参数解析中被定义为PyClassArgs的str: Option<StrFormatterAttribute>字段(见 pyo3-macros-backend/src/pyclass.rs#L92),既支持普通结构体,也支持枚举与复杂字段组合。仓库中的测试 tests/test_class_formatting.rs 演示了#[pyclass(str)]作用于Point2结构体、ComplexEnumWithStr枚举的完整用法:
#[pyclass(str)] #[derive(PartialEq, Eq, Clone, PartialOrd)] pub struct Point2 { x: i32, y: i32, z: i32, } impl Display for Point2 { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "({}, {}, {})", self.x, self.y, self.z) } }便捷的格式字符串简写:str=" "
为了进一步减少样板代码,str参数还可以直接接收一段格式字符串,仅适用于结构体(structs)。它会被展开并传入format!宏,展开规则如下:
"{x}"→"{}", self.x"{0}"→"{}", self.0"{x:?}"→"{:?}", self.x
#[pyclass(str="({x}, {y}, {z})")] struct Coordinate { x: i32, y: i32, z: i32, }[!NOTE] 取决于你使用的格式字符串,对应的 Rust 类型可能需要实现
Display或Debugtrait。pyclass 参数
name和rename_all与简写格式字符串不兼容,同时使用会触发编译期错误。
仓库测试 tests/test_class_formatting.rs 展示了简写格式的多种进阶玩法,验证了实际行为:
- 按位置引用字段:
#[pyclass(str = "{0}, {1}, {2}")]作用于元组结构体Coord(u32, u32, u32),str(var1) == '1, 2, 3'; - 转义花括号:
#[pyclass(str = "{{{0}, {1}, {2}}}")]输出'{1, 2, 3}'; - 字段混合与重复引用:
str = "name: {name}: {name}, idn: {idn:03} with message: {msg}"支持字段多次出现以及{idn:03}这类补零格式; - raw 标识符字段:
#[pyclass(str = "type: {r#type}")]可引用 Rust 的r#type字段。
这些用例均由测试断言(如py_assert!(py, var1, "str(var1) == 'X: 1, Y: 2, Z: 3'"))验证,读者可直接运行cargo test -p pyo3 --test test_class_formatting复现。
动态获取类名:使用 Bound 作为 self
在前面的__repr__中,我们硬编码了类名字符串"Number"。这有时并不理想——如果该类在 Python 中被继承,我们希望 repr 反映的是子类的名字。Python 中通常通过self.__class__.__name__实现这一点;而在 PyO3 中,若要同时访问 Python 类型信息和 Rust 结构体字段,需要把self参数声明为Bound:
use pyo3::prelude::*; use pyo3::types::PyString; #[pymethods] impl Number { fn __repr__(slf: &Bound<'_, Self>) -> PyResult<String> { // 等价于 Python 中的 `self.__class__.__name__`。 let class_name: Bound<'_, PyString> = slf.get_type().qualname()?; // 要访问 Rust 结构体字段,需要先从 Bound 对象借用。 Ok(format!("{}({})", class_name, slf.borrow().0)) } }这里slf.get_type()返回类的PyType,qualname()取得限定名(即子类名),而slf.borrow()则通过 PyO3 的借用机制获取 Rust 侧的可变/不可变访问。当Number被子类化后,repr会自动显示子类名称。
哈希:hash
接下来实现哈希。最简单的方式是对内部的i32进行哈希,需要使用std提供的Hashertrait;std自带的DefaultHasher基于 SipHash 算法:
use std::collections::hash_map::DefaultHasher; // 需要引入 trait 才能调用 `.hash` 和 `.finish` 方法。 use std::hash::{Hash, Hasher}; #[pymethods] impl Number { fn __hash__(&self) -> u64 { let mut hasher = DefaultHasher::new(); self.0.hash(&mut hasher); hasher.finish() } }编译期选项:pyclass(frozen, eq, hash)
如果希望直接复用 Rust 的Hashtrait 实现,可以使用hash选项。该选项只对frozen类开放,目的是防止对象在生命周期内哈希值因可变而改变;可变类需要哈希时,请使用上面手写__hash__的方式。此外,该选项强制要求同时指定eq——依据 Python 数据模型文档 的约定:"如果一个类没有定义__eq__(),它也不应该定义__hash__()操作":
#[pyclass(frozen, eq, hash)] #[derive(PartialEq, Hash)] struct Number(i32);哈希不变量与关闭哈希
[!NOTE] 实现
__hash__与比较运算时,以下性质必须成立:k1 == k2 -> hash(k1) == hash(k2)即:两个键相等,则它们的哈希值必须相等。此外,必须保证类实例的哈希值在生命周期内不变——本教程通过不让 Python 代码修改
Number(使其不可变)来达成这一点。默认情况下,所有
#[pyclass]类型都会继承 Python 的默认哈希实现。不希望可哈希的类型可以像纯 Python 类一样,把__hash__显式置为None:
#[pyclass] struct NotHashable {} #[pymethods] impl NotHashable { #[classattr] const __hash__: Option<Py<PyAny>> = None; }比较运算:richcmp与 CompareOp
PyO3 支持 Python 中所有常见的比较魔术方法(__eq__、__lt__等)。更高效的做法是用一个__richcmp__同时覆盖全部六种运算,该方法会根据具体操作收到一个CompareOp枚举值:
use pyo3::class::basic::CompareOp; #[pymethods] impl Number { fn __richcmp__(&self, other: &Self, op: CompareOp) -> PyResult<bool> { match op { CompareOp::Lt => Ok(self.0 < other.0), CompareOp::Le => Ok(self.0 <= other.0), CompareOp::Eq => Ok(self.0 == other.0), CompareOp::Ne => Ok(self.0 != other.0), CompareOp::Gt => Ok(self.0 > other.0), CompareOp::Ge => Ok(self.0 >= other.0), } } }CompareOp定义在 src/pyclass.rs#L33-L94,其六个变体(Lt/Le/Eq/Ne/Gt/Ge)直接映射到 CPython 的 C 枚举常量(ffi::Py_LT、ffi::Py_EQ等)。源码还提供了from_raw用于从 C 枚举转换,以及本文重点用到的matches方法。
用 CompareOp::matches 简化
如果比较结果来自两个 Rust 值的比较(像本例这样),可以借助CompareOp::matches一行完成:它负责检查 RustOrd得到的std::cmp::Ordering是否与给定的CompareOp匹配:
use pyo3::class::basic::CompareOp; #[pymethods] impl Number { fn __richcmp__(&self, other: &Self, op: CompareOp) -> bool { op.matches(self.0.cmp(&other.0)) } }对照源码 src/pyclass.rs#L84-L93 可以看到其判定逻辑:
Eq要求Ordering::Equal,Ne要求非Equal;Lt要求Less,Gt要求Greater;Le只要不是Greater,Ge只要不是Less。
单独实现eq
如果只需要相等性比较,实现__eq__即可:
#[pymethods] impl Number { fn __eq__(&self, other: &Self) -> bool { self.0 == other.0 } }编译期选项:eq 与 ord
与str/hash类似,PyO3 也提供了基于 Rust trait 自动生成比较方法的选项:
eq:基于 RustPartialEqtrait 实现__eq__:
#[pyclass(eq)] #[derive(PartialEq)] struct Number(i32);ord:基于 RustPartialOrdtrait 实现__lt__、__le__、__gt__与__ge__:
#[pyclass(eq, ord)] #[derive(PartialEq, PartialOrd)] struct Number(i32);[!NOTE]
ord依赖eq,必须同时指定。
仓库测试 tests/test_class_comparisons.rs 对eq/ord选项做了系统验证,可作为行为参考:
- 简单枚举
#[pyclass(eq)]:var1 == var2为True、var1 != other_var为True;与字符串'foo'比较返回False(test_enum_eq_incomparable); #[pyclass(eq, ord)]的结构体Point { x, y, z }:(var1 > var2) == True、(var3 < var2) == True、(var4 == var5) == True(test_struct_numeric_ord_comparable);- 需要自定义比较逻辑时,可手写
impl PartialOrd(如Record仅按idx比较,见test_struct_custom_ord_comparable); - 仅声明
eq而未声明ord的枚举,执行>会抛出PyTypeError(test_enum_ord_comparable_opt_in_only),说明排序比较是"opt-in"的,不会凭空出现。
真假值:bool
将Number定义为:非零即为True:
#[pymethods] impl Number { fn __bool__(&self) -> bool { self.0 != 0 } }定义__bool__后,if n:、bool(n)、and/or/not等 Python 真假判断都会按此语义执行;未定义时 Python 默认"对象非 None 即为真"。
完整示例:聚合所有基础协议
将以上所有实现整合到同一个类中,得到本文的最终代码:
use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use pyo3::prelude::*; use pyo3::class::basic::CompareOp; use pyo3::types::PyString; #[pyclass] struct Number(i32); #[pymethods] impl Number { #[new] fn new(value: i32) -> Self { Self(value) } fn __repr__(slf: &Bound<'_, Self>) -> PyResult<String> { let class_name: Bound<'_, PyString> = slf.get_type().qualname()?; Ok(format!("{}({})", class_name, slf.borrow().0)) } fn __str__(&self) -> String { self.0.to_string() } fn __hash__(&self) -> u64 { let mut hasher = DefaultHasher::new(); self.0.hash(&mut hasher); hasher.finish() } fn __richcmp__(&self, other: &Self, op: CompareOp) -> PyResult<bool> { match op { CompareOp::Lt => Ok(self.0 < other.0), CompareOp::Le => Ok(self.0 <= other.0), CompareOp::Eq => Ok(self.0 == other.0), CompareOp::Ne => Ok(self.0 != other.0), CompareOp::Gt => Ok(self.0 > other.0), CompareOp::Ge => Ok(self.0 >= other.0), } } fn __bool__(&self) -> bool { self.0 != 0 } } #[pymodule] mod my_module { #[pymodule_export] use super::Number; }构建并导入后,Number(5)将拥有完整的 Python 对象行为:
n = Number(5) repr(n) # 'Number(5)'(若被继承则显示子类名) str(n) # '5' hash(n) # 基于内部 i32 的 SipHash 值 n > Number(3) # True n == Number(5) # True bool(Number(0)) # False小结:手写与编译期选项如何取舍
回顾整篇文章,PyO3 为#[pyclass]的基础对象定制提供了两套互补的机制,可在 pyo3-macros-backend/src/pyclass.rs#L50-L114 的PyClassArgs中看到完整的参数清单:
| Python 协议 | 手写方式(#[pymethods] 内) | 编译期选项(#[pyclass(...)]) |
|---|---|---|
__repr__ | fn __repr__(&self) -> String(推荐用Bound获取类名) | —(str选项只生成__str__) |
__str__ | fn __str__(&self) -> String | str复用Display;str="<fmt>"结构体简写 |
__hash__ | fn __hash__(&self) -> u64(可变类必须手写) | frozen, eq, hash复用Hash/PartialEq |
__eq__ | fn __eq__(&self, other: &Self) -> bool | eq复用PartialEq |
__lt__/__le__/__gt__/__ge__ | __richcmp__(&self, other, op: CompareOp) | eq, ord复用PartialOrd |
| 关闭哈希 | #[classattr] const __hash__: Option<Py<PyAny>> = None | — |
__bool__ | fn __bool__(&self) -> bool | — |
选择建议:数据语义简单、字段布局稳定的"值类型"(如坐标、数值包装)优先使用编译期选项,代码量最少且与 Rust 标准 trait 保持一致性;需要动态类名、自定义哈希策略或复杂比较逻辑时再手写特殊方法。若要验证以上行为,可参考仓库中的 tests/test_class_formatting.rs 与 tests/test_class_comparisons.rs 测试用例,运行cargo test -p pyo3 --test test_class_formatting与cargo test -p pyo3 --test test_class_comparisons复现全部断言。
【免费下载链接】pyo3Rust bindings for the Python interpreter项目地址: https://gitcode.com/gh_mirrors/py/pyo3
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考