Rust 是如何判断对象是否相等的?一起来聊一聊 PartialEq 与 Eq
在开发过程中,我们总会遇到"判断两个对象是否相等"的场景,比如比较两个变量的值、在集合中查找目标元素、去重等。与其他编程语言不同,默认就支持对象的相等判断,Rust 需要用到 PartialEq 与 Eq 这两个特征来判断是否相等。为什么 Rust 要搞这么复杂?今天我们一起来聊一聊,Rust 判断对象相等的底层逻辑,以及这两个核心特征的使用方法,帮你一次性搞懂。
为什么 Rust 不默认实现"对象相等"?
为什么其他语言能默认实现相等判断,Rust 却不行?答案很简单,那就是:"相等"的语义,从来都不是固定的。
如果 Rust 像其他语言那样,默认实现"所有字段都相等才算对象相等",那在只需要比较部分字段的场景里,我们就得额外写代码覆盖默认逻辑,反而更麻烦。所以 Rust 选择了更严谨的方式:不提供默认的相等判断,而是通过 PartialEq 和 Eq 两个特征,让我们根据自己的业务场景自定义"相等"的规则。
PartialEq 与 Eq 有什么区别?
这两个特征是 Rust 判断对象相等的核心,二者是"继承关系",但语义上有明显区别。我们一个个来聊,先从最常用的 PartialEq 开始。
PartialEq:"部分相等",最常用的相等判断
PartialEq 翻译过来是"部分相等",它的作用很简单:定义两个对象"在某种程度上"是否相等,支持我们使用 == 和 != 这两个运算符进行比较。以下是它的简化定义(忽略泛型):
rust
pub trait PartialEq {
// 判断 self 和 other 是否相等
fn eq(&self, other: &Self) -> bool;
// 判断是否不相等,默认是 eq 方法取反(可选方法)
fn ne(&self, other: &Self) -> bool {
!self.eq(other)
}
}
从定义能看出来,只要我们实现了 eq 方法,就可以直接用 ==(本质是调用 eq)和 !=(本质是调用 ne)来比较对象了。
那为什么叫"部分相等"呢?关键在于它不要求满足"自反性" 。简单说,就是存在某个值 a,使得 a == a 的结果是 false。
最典型的例子,就是 Rust 中的浮点数类型 f32 和 f64。根据 IEEE 754 标准,NaN(非数字)和任何值都不相等,包括它自己。我们可以写一段简单的代码验证一下:
rust
fn main() {
let nan = f32::NAN;
println!("{}", nan == nan); // 输出:false
}
正因为浮点数存在这种"自己不等于自己"的情况,所以 Rust 只为浮点数默认实现了 PartialEq,而没有实现 Eq。这也正是"部分相等"的核心含义:不是所有值都能和自己相等。
Eq:"完全相等",更强的契约约束
聊完了 PartialEq,再看 Eq。它翻译过来是"完全相等",是在 PartialEq 的基础上,增加了更强的契约约束。它的定义更简单,甚至没有额外的方法,只是继承了PartialEq:
rust
pub trait Eq: PartialEq {
// 没有额外方法,仅仅是一个"契约标记"
}
虽然没有额外方法,但 Eq 有三个必须满足的契约(也是它和 PartialEq 的核心区别):
- 自反性:对于任何值
a,a == a必须恒为true; - 对称性:如果
a == b,那么b == a也必须为true; - 传递性:如果
a == b且b == c,那么a == c也必须为true。
哪些类型实现了 Eq 呢?我们平时使用的基础类型,比如 i32、bool、String、Vec 等,都实现了 Eq,因为它们均满足上面的三个契约。而浮点数(f32、f64)因为存在 NaN,无法满足自反性,所以不能实现 Eq。
给自定义类型实现相等判断
自动派生
如果自定义类型中,所有字段都实现了 PartialEq 或 Eq,那我们根本不用手动写代码,只需要在类型定义前添加上派生宏,Rust 就会自动帮我们实现相等判断逻辑。这种方式适合大多数场景,简单又不容易出错。
rust
// 自动派生 PartialEq 和 Eq
#[derive(PartialEq, Eq, Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 1, y: 2 };
let p2 = Point { x: 1, y: 2 };
let p3 = Point { x: 3, y: 4 };
println!("p1 == p2: {}", p1 == p2); // 输出:true
println!("p1 == p3: {}", p1 == p3); // 输出:false
}
手动实现
如果自动派生的逻辑不符合我们的需求,那就需要手动实现 PartialEq(必要时实现 Eq),自己定义 eq 方法的判断逻辑。
这里举一个例子,有一个 Circle 结构体,半径相等,就视为两个圆相等,示例代码如下所示:
rust
#[derive(Debug)]
struct Circle {
radius: f64,
x: f64,
y: f64,
}
// 手动实现 PartialEq
// 注意:Circle 包含 f64,仅可实现 PartialEq,无法实现 Eq
impl PartialEq for Circle {
fn eq(&self, other: &Self) -> bool {
// 只比较半径,忽略圆心坐标
self.radius == other.radius
}
}
fn main() {
let c1 = Circle { radius: 10.0, x: 0.0, y: 0.0 };
let c2 = Circle { radius: 10.0, x: 5.0, y: 5.0 };
println!("c1 == c2: {}", c1 == c2); // 输出:true
}
容易混淆的点:值相等 vs 引用相等
聊完了 PartialEq 和 Eq,还有一个新手很容易踩坑的点:Rust 中的"值相等"和"引用相等",到底不一样在哪?简单来说,就是:
- 值相等:通过
PartialEq/Eq判断,比较的是两个对象的"内容"是否相等; - 引用相等:判断两个引用是否指向同一个内存地址,和内容无关。
我们平时用 == 比较的,都是值相等;而要判断引用相等,需要用到 std::ptr::eq 函数。如下所示:
rust
use std::ptr;
fn main() {
let a = 5;
let b = 5;
let ref_a = &a; // 指向 a 的引用
let ref_b = &b; // 指向 b 的引用
let ref_a2 = &a; // 指向 a 的引用
// 值相等:ref_a 和 ref_b 指向的内容都是 5,所以相等
println!("ref_a == ref_b: {}", ref_a == ref_b); // 输出:true
// 引用相等:ref_a 指向 a,ref_b 指向 b,内存地址不同,所以不相等
println!("ptr::eq(ref_a, ref_b): {}", ptr::eq(ref_a, ref_b)); // 输出:false
// ref_a 和 ref_a2 都指向 a,内存地址相同,引用相等
println!("ptr::eq(ref_a, ref_a2): {}", ptr::eq(ref_a, ref_a2)); // 输出:true
}
另外,对于 Arc、Rc 这类智能指针,还有一个专门的 Arc::ptr_eq 方法,用于判断两个智能指针是否指向同一个堆内存分配(也就是同一个引用计数对象),和 ptr::eq 略有区别,感兴趣的同学可以自行尝试。
避坑指南
最后,我们聊一聊实际开发中,关于 PartialEq 和 Eq 一些容易踩的坑。
误区一:用 PartialEq 替代 Eq,随便用
虽然 PartialEq 更通用,但有些场景必须用 Eq,最典型的就是 HashMap<K, V> 的键类型 K,它必须实现 Eq。
原因很简单:HashMap 需要通过"键相等"来定位元素,如果键类型不满足自反性(比如浮点数),会导致查找、删除等操作出现异常。比如我们尝试用 f32 作为 HashMap 的键,会直接编译报错:
rust
use std::collections::HashMap;
fn main() {
let mut map: HashMap<f32, &str> = HashMap::new();
map.insert(1.0, "one"); // 编译报错:f32 未实现 Eq
}
误区二:手动实现 PartialEq 时,违背契约
手动实现 PartialEq 时,一定要遵守契约:对称性和传递性。如果违背了,编译器不会报错,但会导致逻辑错误。
rust
#[derive(Debug)]
struct A(i32);
#[derive(Debug)]
struct B(i32);
// 只实现了 A == B,没有实现 B == A
impl PartialEq<B> for A {
fn eq(&self, other: &B) -> bool {
self.0 == other.0 + 1
}
}
fn main() {
let a = A(3);
let b = B(2);
println!("a == b: {}", a == b); // 输出:true
// println!("b == a: {}", b == a); // 编译报错:没有 B == A 的实现
}
误区三:派生 Eq 时,忽略字段的 Eq 实现
当我们用 #[derive(Eq)] 自动派生时,Rust 会要求类型的所有字段都实现 Eq。如果有任何一个字段只实现了 PartialEq(比如 f64),派生会直接失败。
rust
// 编译报错:f64 未实现 Eq,无法派生 Eq
#[derive(PartialEq, Eq, Debug)]
struct Circle {
radius: f64,
x: i32,
y: i32,
}
总结
其实 Rust 判断对象相等的逻辑,核心就是 PartialEq 和 Eq 两个特征,没有想象中那么复杂。记住这一句就够了:PartialEq 适用于"可能有值不等于自己"的场景,Eq 适用于"所有值都等于自己"的场景。