Rust一文讲透Rust中的PartialEq和Eq

Posted 城市里的元

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Rust一文讲透Rust中的PartialEq和Eq相关的知识,希望对你有一定的参考价值。

前言

本文将围绕对象:PartialEq和Eq,以及PartialOrd和Ord,即四个Rust中重点的Compare Trait进行讨论并解释其中的细节,内容涵盖理论以及代码实现。

在正式介绍PartialEq和Eq、以及PartialOrd和Ord之前,本文会首先介绍它们所遵循的数学理论,也就是相等关系
文章主要分三大部分,第一部分是第1节,讨论的是数学中的相等关系;第二部分是第2~5节,主要讨论PartialEq和Eq;第三部分是第6节,主要讨论PartialOrd和Ord。内容描述可能具有先后顺序,建议按章节顺序阅读。

声明

本文内容来自作者个人的学习成果总结及整理,可能会存在因个人水平导致的表述错误,欢迎并感谢读者指正!

  • 作者:Leigg
  • 首发地址:https://github.com/chaseSpace/rust_practices/blob/main/blogs/about_eq_ord_trait.md
  • CSDN:https://blog.csdn.net/sc_lilei/article/details/129322616
  • 发布时间:2023年03月03日
  • License:CC-BY-NC-SA 4.0 (转载请注明作者及来源)

1. 数学中的相等关系

在初中数学中,会介绍到什么是相等关系(也叫等价关系),相等关系是一种基本的二元关系,它描述了两个对象之间的相等性质。它必须满足如下三个性质:

  • 自反性(反身性):自己一定等于自己,即a=a
  • 对称性:若有a=b,则有b=a
  • 传递性:若有a=bb=c,则有a=c

也就是说,满足这三个性质才叫满足(完全)相等关系。这很容易理解,就不过多解释。

1.1 部分相等关系

对于简单的整数类型、字符串类型,我们可以说它们具有完全相等关系,因为它们可以全方位比较(包含两个维度,第一个是类型空间中的任意值,第二个是每个值的任意成员属性), 但是对于某些类型就不行了,这些类型总是不满足其中一个维度
。下面一起来看看:

以字符串为例,全方位比较的是它的每个字节值以及整个字符串的长度。

0. 浮点数类型

在浮点数类型中有个特殊的值是NaN(Not-a-number),这个值与任何值都不等(包括自己),它直接违背了自反性。这个时候,我们就需要为浮点数定义一种部分相等关系,这主要是为了比较非NaN浮点数。

NaN定义于IEEE 754-2008标准的5.2节“特殊值”(Special Values)中,除了NaN,另外两个特殊值是正无穷大(+infinity)、负无穷大(-infinity),不过这两个值满足自反性。

除了浮点数类型,数学中还有其他类型也不具有通常意义上的全等关系,比如集合类型、函数类型。

1. 集合类型

假设有集合A=1,2,3、B=1,3,2,那么此时A和B是相等还是不相等呢?这就需要在不同角度去看待,当我们只关注集合中是否包含相同的元素时, 可以说它们相等,当我们还要严格要求元素顺序一致时,它们就不相等。

在实际应用中,由我们定义(Impl)了一种集合中的特殊相等关系,称为"集合的相等",这个特殊关系(实现逻辑)中,我们只要求两个集合的元素相同,不要求其他。

2. 函数类型

首先从浮点数的NaN角度来看函数,假设有函数A=f(x)、B=f(y),若x=y,那显然A的值也等于B,但是如果存在一个参数z是无意义的呢,意思是f(z)是无结果的或结果非法,那么此时可以说f(z)等于自身吗?
那显然是不行的。这个例子和浮点数的例子是一个意思。

然后从集合类型的角度再来看一次函数,假设有函数A=f(x)、B=g(x),注意是两个不同的函数,当二者给定一个相同输入x产生相同结果时,此时f(x)和g(x)是相等还是不等呢?
与集合类似,实际应用中,这里也是由我们定义(Impl)了一种函数中的特殊相等关系,称为函数的相等。这个特殊关系(实现逻辑)中,我们只要求两个函数执行结果的值相同,不要求函数执行过程相同。

1.2 部分相等与全相等的关系

部分相等是全相等关系的子集,也就是说,如果两个元素具有相等关系,那它们之间也一定有部分相等关系。这在编程语言中的实现也是同样遵循的规则。

1.3 小结

数学中定义了(全)相等关系(等价关系)的三大性质,分别是自反性、对称性和传递性;但某些数据类型中的值或属性违背了三大性质,就不能叫做满足全相等关系, 此时只能为该类型实现部分相等关系。

在部分相等关系中,用于比较的值也是满足三大性质的,因为此时我们排除了那些特殊值。另外,部分相等是全相等关系的子集。

2. 编程与数学的关系

数学是一门研究数据、空间和变化的庞大学科,它提供了一种严谨的描述和处理问题的方式,而编程则是将问题的解决方法转化为计算机程序的过程,可以说,数学是问题的理论形式, 编程则是问题的代码形式,编程解决问题的依据来自数学。

所以说,编程语言的设计中也是大量运用了数学概念与模型的,本文关注的相等关系就是一个例子。

在Rust库中的PartialEq的注释文档中提到了partial equivalence relations 即部分相等关系这一概念,并且同样使用了浮点数的特殊值NaN来举例说明。

Eq的注释文档则是提到了equivalence relations,并且明确说明了,对于满足Eqtrait的类型,是一定满足相等关系的三大性质的。

3. PartialEq

3.1 trait定义

Rust中的PartialEq的命名明确地表达了它的含义,但如果我们忘记了数学中的相等关系,就肯定会对此感到疑惑。先来看看它的定义:

pub trait PartialEq<Rhs: ?Sized = Self> 
    fn eq(&self, other: &Rhs) -> bool;
    fn ne(&self, other: &Rhs) -> bool 
        !self.eq(other)
    

在这个定义中,可以得到三个基本信息:

  1. 这个trait包含2个方法,eq和ne,且ne具有默认实现,使用时开发者只需要实现eq方法即可(库文档也特别说明,若没有更好的理由,则不应该手动实现ne方法);
  2. PartialEq绑定的Rhs参数类型是?Size,即包括动态大小类型(DST)和固定大小类型(ST)类型(Rhs是主类型用来比较的类型);
  3. Rhs参数提供了默认类型即Self(和主类型一致),但也可以是其他类型,也就是说,实践中你甚至可以将i32与struct进行比较,只要实现了对应的PartialEq

Rust中的lhs和rhs指的是,“left-hand side”(左手边) 和 “right-hand side”(右手边)的参数。

3.2 对应操作符

这个比较简单,PartialEq和Eq一致,拥有的eq和ne方法分别对应==!=两个操作符。Rust的大部分基本类型如整型、浮点数、字符串等都实现了PartialEq, 所以它们可以使用==!=进行相等性比较。

3.3 可派生

英文描述为Derivable,即通过derive宏可以为自定义复合类型(struct/enum/union类型)自动实现PartialEq,用法如下:

#[derive(PartialEq)]
struct Book 
    name: String,


#[derive(PartialEq)]
enum BookFormat  Paperback, Hardback, Ebook 

#[derive(PartialEq)]
union T 
    a: u32,
    b: f32,
    c: f64,

需要注意的是,可派生的前提是这个复合类型下的所有成员字段都是支持PartialEq的,下面的代码说明了这种情况:

// #[derive(PartialEq)]  // 取消注释即可编译通过
enum BookFormat  Paperback, Hardback, Ebook 

// 无法编译!!!
#[derive(PartialEq)]
struct Book 
    name: String,
    format: BookFormat, // 未实现PartialEq

扩展:使用cargo expand命令可以打印出宏为类型实现的PartialEq代码。

3.4 手动实现PartialEq

以上一段代码为例,我们假设BookFormat是引用其他crate下的代码,无法为其添加derive语句(不能修改它),此时就需要手动为Book手动实现PartialEq,代码如下:

enum BookFormat  Paperback, Hardback, Ebook 

struct Book 
    name: String,
    format: BookFormat,


// 要求只要name相等则Book相等(假设format无法进行相等比较)
impl PartialEq for Book 
    fn eq(&self, other: &Self) -> bool 
        self.name == other.name
    


fn main() 
    let bk = Book  name: "x".to_string(), format: BookFormat::Ebook ;
    let bk2 = Book  name: "x".to_string(), format: BookFormat::Paperback ;
    assert!(bk == bk2); // 因为Book实现了PartialEq,所以可以比较相等性

3.5 比较不同的类型

根据上面的trait定义中,我们知道了只要在实现PartialEq时关联不同类型的Rhs参数,就能比较不同类型的相等性。示例代码如下:

#[derive(PartialEq)]
enum WheelBrand 
    Bmw,
    Benz,
    Michelin,


struct Car 
    brand: WheelBrand,
    price: i32,


impl PartialEq<WheelBrand> for Car 
    fn eq(&self, other: &WheelBrand) -> bool 
        self.brand == *other
    


fn main() 
    let car = Car  brand: WheelBrand::Benz, price: 10000 ;
    let wheel = WheelBrand::Benz;
    // 比较 struct和enum
    assert!(car == wheel);
    // assert!(wheel == car);  // 无法反过来比较

需要注意的是,代码片段中仅实现了Car与Wheel的相等性比较,若要反过来比较,还得提供反向的实现,如下:

impl PartialEq<Car> for WheelBrand 
    fn eq(&self, other: &Car) -> bool 
        *self == other.brand
    

3.6 Rust基本类型如何实现PartialEq

上文说过,Rust的基本类型都实现了PartialEq,那具体是怎么实现的呢?是为每个类型都写一套impl代码吗?代码在哪呢?

如果你使用IDE,可以通过在任意位置按住ctrl键(视IDE而定)点击代码中的PartialEq以打开其在标准库中的代码文件cmp.rs,相对路径是RUST_LIB_DIR/core/src/cmp.rs
在该文件中可以找到如下宏代码:

mod impls 
    // ...
    macro_rules! partial_eq_impl 
        ($($t:ty)*) => ($(
            #[stable(feature = "rust1", since = "1.0.0")]
            #[rustc_const_unstable(feature = "const_cmp", issue = "92391")]
            impl const PartialEq for $t 
                #[inline]
                fn eq(&self, other: &$t) -> bool  (*self) == (*other) 
                #[inline]
                fn ne(&self, other: &$t) -> bool  (*self) != (*other) 
            
        )*)
    
    partial_eq_impl! 
        bool char usize u8 u16 u32 u64 u128 isize i8 i16 i32 i64 i128 f32 f64
        
    // ...

这里使用了Rust强大的宏特性(此处使用的是声明宏,还算简单),来为Rust的众多基本类型快速实现了PartialEq trait。如果你还不了解宏,可以暂且理解其是一种编写重复模式代码规则的编程特性,它可以减少大量重复代码。

4. Eq

理解了PartialEq,那Eq理解起来就非常简单了,本节的内容主体与PartialEq基本一致,所以相对简明。

4.1 trait定义

如下:

pub trait Eq: PartialEq<Self> 
    fn assert_receiver_is_total_eq(&self) 

根据代码可以得到两个重要信息:

  1. Eq是继承自PartialEq的;
  2. Eq相对PartialEq只多了一个方法assert_receiver_is_total_eq(),并且有默认实现;

第一个,既然Eq继承自PartialEq,说明想要实现Eq,必先实现PartialEq。第二个是这个assert_receiver_is_total_eq()
方法了,简单来说,它是被derive语法内部使用的,用来断言类型的每个属性都实现了Eq特性,对于使用者的我们来说, 其实不用过多关注。

4.2 对应操作符

与PartialEq无差别,略。

4.3 可派生

与PartialEq的使用相似,只是要注意派生时,由于继承关系,Eq和PartialEq必须同时存在。

#[derive(PartialEq, Eq)] // 顺序无关
struct Book 
    name: String,

4.4 手动实现Eq

直接看代码:

enum BookFormat  Paperback, Hardback, Ebook 

struct Book 
    name: String,
    format: BookFormat,


// 要求只要name相等则Book相等(假设format无法进行相等比较)
impl PartialEq for Book 
    fn eq(&self, other: &Self) -> bool 
        self.name == other.name
    


impl Eq for Book 

fn main() 
    let bk = Book  name: "x".to_string(), format: BookFormat::Ebook ;
    let bk2 = Book  name: "x".to_string(), format: BookFormat::Paperback ;
    assert!(bk == bk2);

需要注意的是,必须先实现PartialEq,再实现Eq。另外,这里能看出的是,在比较相等性方面,Eq和PartialEq都是使用==!=操作符,无差别感知。

4.5 比较不同的类型

与PartialEq无差别,略。

4.6 Rust基本类型如何实现Eq

与PartialEq一样,在相对路径为RUST_LIB_DIR/core/src/cmp.rs的文件中,存在如下宏代码:

mod impls 
    /*
        ... (先实现PartialEq)
        
    */

    // 再实现Eq
    macro_rules! eq_impl 
        ($($t:ty)*) => ($(
            #[stable(feature = "rust1", since = "1.0.0")]
            impl Eq for $t 
        )*)
    

    eq_impl!  () bool char usize u8 u16 u32 u64 u128 isize i8 i16 i32 i64 i128 

5. 对浮点数的测试

目前在标准库中,笔者只发现有浮点数是只实现了PartialEq的(以及包含浮点数的复合类型),下面是浮点数的测试代码:

fn main() 
    fn check_eq_impl<I: Eq>(typ: I) 
    // check_eq_impl(0.1f32); // 编译错误
    // check_eq_impl(0.1f64); // 编译错误

    let nan = f32::NAN;
    let infinity = f32::INFINITY;
    let neg_infinity = f32::NEG_INFINITY;
    assert_ne!(nan, nan); // 不等!
    assert_eq!(infinity, infinity); // 相等!
    assert_eq!(neg_infinity, neg_infinity);  // 相等!

6. PartialOrd和Ord

6.1 与PartialEq和Eq的关系

很多时候,当我们谈到PartialEq和Eq时,PartialOrd和Ord总是不能脱离的话题,因为它们都是一种二元比较关系,前两者是相等性比较,后两者是有序性(也可称大小性)比较。 前两者使用的操作符是==!=
,后两者使用的操作符是>=
<,没错,PartialOrd和Ord的比较结果是包含等于的,然后我们可以基于这个有序关系来对数据进行排序(sort)。

重点:有序性包含相等性。

与PartialEq存在的原因一样,PartialOrd的存在的理由也是因为有一些类型是不具有有序性关系的(无法比较),比如浮点数、Bool、Option、函数、闭包等类型。

PartialEq和Eq、PartialOrd和Ord共同描述了Rust中任意类型的二元比较关系,包含相等性、有序性。 所以在上文中,你可能也观察到PartialOrd和Ord的定义也位于cmp.rs文件中。

我们可以将PartialOrd和Ord直译为偏序和全序关系,因为这确实是它们要表达的含义。偏序和全序的概念来自离散数学,下文详解。

6.2 基本性质

PartialOrd和Ord也是满足一定的基本性质的,PartialOrd满足:

  • 传递性:若有a<bb<c,则a<c。且>==也是一样的;
  • 对立性:若有a<b,则b>a

Ord基于PartialOrd,自然遵循传递性和对立性,另外对于任意两个元素,还满足如下性质:

  • 确定性:必定存在>==<其中的一个关系;

6.3 trait定义

1. PartialOrd trait

// 二元关系定义(<,==,>)
pub enum Ordering 
    Less = -1,
    Equal = 0,
    Greater = 1,


pub trait PartialOrd<Rhs: ?Sized = Self>: PartialEq<Rhs> 
    fn partial_cmp(&self, other: &Rhs) -> Option<Ordering>;
    fn lt(&self, other: &Rhs) -> bool 
        matches!(self.partial_cmp(other), Some(Less))
    
    fn le(&self, other: &Rhs) -> bool 
        matches!(self.partial_cmp(other), Some(Less | Equal))
    
    fn gt(&self, other: &Rhs) -> bool 
        matches!(self.partial_cmp(other), Some(Greater))
    
    fn ge(&self, other: &Rhs) -> bool 
        matches!(self.partial_cmp(other), Some(Greater | Equal))
    

基本信息:

  1. PartialOrd继承自PartialEq,这很好理解,无法比较大小的类型也一定不能进行相等性比较;
  2. 提供partial_cmp()方法用于主类型和可以是其他类型的参数比较,返回的Option<Ordering>,表示两者关系可以是无法比较的(None),那么这里我们就可以联想到Ord
    trait返回的肯定是Ordering(因为具有全序的类型不会存在无法比较的情况);
  3. 另外四个方法分别实现了对应的操作符:<, <=, >, >=,即实现了PartialOrd的类型可以使用这些操作符进行比较;除此之外,由于继承了PartialEq,所以还允许使用==,!=

请再次记住,不管是PartialOrd还是Ord,都包含相等关系。

2. Ord trait

pub trait Ord: Eq + PartialOrd<Self> 
    // 方法1
    fn cmp(&self, other: &Self) -> Ordering;

    // 方法2
    fn max(self, other: Self) -> Self
        where
            Self: Sized,
            Self: ~ const Destruct,
    
        // HACK(fee1-dead): go back to using `self.max_by(other, Ord::cmp)`
        // when trait methods are allowed to be used when a const closure is
        // expected.
        match self.cmp(&other) 
            Ordering::Less | Ordering::Equal => other,
            Ordering::Greater => self,
        
    

    // 方法3
    fn min(self, other: Self) -> Self
        where
            Self: Sized,
            Self: ~ const Destruct,
    
        // HACK(fee1-dead): go back to using `self.min_by(other, Ord::cmp)`
        // when trait methods are allowed to be used when a const closure is
        // expected.
        match self.cmp(&other) 
            Ordering::Less | Ordering::Equal => self,
            Ordering::Greater => other,
        
    

    // 方法4
    fn clamp(self, min: Self, max: Self) -> Self
        where
            Self: Sized,
            Self: ~ const Destruct,
            Self: ~ const PartialOrd,
    
        assert!(min <= max);
        if self < min 
            min
         else if self > max 
            max
         else 
            self
        
    

基本信息:

  1. cmp方法用于比较self与参数other的二元关系,返回Ordering类型(区别于PartialOrd.partial_cmp()返回的Option<Ordering>);
  2. Ord继承自Eq+PartialOrd,这也很好理解,具有全序关系的类型自然具有偏序关系;
  3. 提供min/max()方法以返回self与参数other之间的较小值/较大值;
  4. 额外提供clamp()方法返回输入的参数区间内的值;
  5. 显然,由于继承了PartialOrd,所以实现了Ord的类型可以使用操作符<, <=, >, >=, ==, !=

Self: ~ const Destruct的解释:位于where后即是类型约束,这里约束了Self类型必须是实现了Destructtrait的一个指向常量的裸指针。

全序和偏序的概念(来自离散数学)

  • 全序:即全序关系,自然也是一种二元关系。全序是指,集合中的任两个元素之间都可以比较的关系。比如实数中的任两个数都可以比较大小,那么“大小”就是实数集的一个全序关系。
  • 偏序:集合中只有部分元素之间可以比较的关系。比如复数集中并不是所有的数都可以比较大小,那么“大小”就是复数集的一个偏序关系。
  • 显然,全序关系必是偏序关系。反之不成立。

6.4 可派生

1. PartialOrd derive

PartialOrd和Ord也是可以使用derive宏进行自动实现的,代码如下:

#[derive(PartialOrd, PartialEq)]
struct Book 
    name: String,


#[derive(PartialOrd, PartialEq)]
enum BookFormat  Paperback, Hardback, Ebook 

这里有几点需要注意:

  1. 由于继承关系,所以必须同时派生PartialEq;
  2. 与PartialEq相比,不支持为union类型派生;
  3. 对struct进行派生时,大小顺序依据的是成员字段的字典序(字母表中的顺序,数字与字母比较则根据ASCII表编码,数字编码<字母编码;若比较多字节字符如中文,则转Unicode编码后再比较;
    实际上ASCII表中的字符编码与对应Unicode编码一致);
  4. 对enum进行派生时,大小顺序依据的是枚举类型的值大小,默认情况下,第一个枚举类型的值是1,向

    将结构与 rust 中的浮点数进行比较

    【中文标题】将结构与 rust 中的浮点数进行比较【英文标题】:Comparing Structs with floating point numbers in rust 【发布时间】:2021-08-29 06:20:17 【问题描述】:

    由于精度错误,使用浮点数 f64 时我的测试失败。

    Playground:

    use std::ops::Sub;
    
    #[derive(Debug, PartialEq, Clone, Copy)]
    struct Audio 
        amp: f64,
    
    
    impl Sub for Audio 
        type Output = Self;
    
        fn sub(self, other: Self) -> Self::Output 
            Self 
                amp: self.amp - other.amp,
            
        
    
    
    #[test]
    fn subtract_audio() 
        let audio1 = Audio  amp: 0.9 ;
        let audio2 = Audio  amp: 0.3 ;
    
        assert_eq!(audio1 - audio2, Audio  amp: 0.6 );
        assert_ne!(audio1 - audio2, Audio  amp: 1.2 );
        assert_ne!(audio1 - audio2, Audio  amp: 0.3 );
    
    

    我收到以下错误:

    ---- subtract_audio stdout ----
    thread 'subtract_audio' panicked at 'assertion failed: `(left == right)`
      left: `Audio  amp: 0.6000000000000001 `,
     right: `Audio  amp: 0.6 `', src/lib.rs:23:5
    

    如何测试带有f64 之类的浮点数的结构?

    【问题讨论】:

    这能回答你的问题吗? Is floating point math broken? 您的问题不在于您的Sub 实现,而在于PartialEq 的派生实现。最好手动实现,测试该值是否在您期望的公差范围内。 @eggyal 我了解浮点数,谢谢。你会说实施PartialEq 比我发布的答案更好吗?谢谢。 派生的PartialEq 实现对于包含浮点数的结构来说是毫无用处的,并且可能会导致意外且难以追踪的错误——所以我绝对建议删除它。如果由于其他原因该结构仍然需要实现PartialEq,那么无论如何您都需要手动执行...之后您的原始assert_eq 将按预期工作。如果您没有任何其他理由实施PartialEq,那么我想这取决于您使用哪种方法,但我认为实施该特征可以更清楚地捕捉意图。 当然,如果您在比较过程中的容差取决于上下文,那么实现PartialEq 可能是个坏主意。 【参考方案1】:

    如果用没有结构的数字进行比较,

    let a: f64 = 0.9;
    let b: f64 = 0.6;
    
    assert!(a - b < f64:EPSILON);
    

    但是对于结构,我们需要采取额外的措施。 首先需要使用PartialOrd 进行派生,以便与其他结构进行比较。

    #[derive(Debug, PartialEq, PartialOrd)]
    struct Audio ...
    

    接下来创建一个结构体进行比较

    let audio_epsilon = Audio  amp: f64:EPSILON ;
    

    现在我可以定期比较(assert! 而不是assert_eq!

    assert!(c - d < audio_epsilon)
    

    另一种解决方案是手动实现PartialEq

    impl PartialEq for Audio 
        fn eq(&self, other: &Self) -> bool 
            (self.amp - other.amp).abs() < f64::EPSILON
        
    
    

    【讨论】:

    Nb,除非您知道 LHS 始终大于 RHS(因此减法始终为正),否则您需要在与您的比较之前取其 绝对值所需的公差。 @eggyal -5--5 == 0,除非你在这里谈论可能的溢出是可能的,但我选择保持简单,因为 f64 永远不会发生这种情况。但是是的,生产代码可能希望避免任何风险。 @Stargateur:如果c(或self.amp)小于d(或other.amp),那么c - d(或self.amp - other.amp)将是负数,因此必然小于与f64::EPSILON 相比,无论它们有多大差异。 @Stargateur:仅供参考,我回滚了您的最新编辑,因为.abs() 非常好。 请注意,f64:EPSILON 可能不适合在这里使用。例如如果中间值明显大于 1,或者涉及大量中间计算,则精度会降低。请注意,考虑到问题中的值,这很好,但通常可能不会达到您的预期

    以上是关于Rust一文讲透Rust中的PartialEq和Eq的主要内容,如果未能解决你的问题,请参考以下文章

    如何解析八进制字符串作为Rust中的浮点数?

    Rust单链表

    「Rust进阶笔记」Rust之derive特性总结

    一文带你走进 Rust 和 WebAssembly 的世界

    Rust双向链表

    现代编程语言:Rust (铁锈,一文掌握钢铁是怎样生锈的)