返回文章列表

Rust 複合指定與相等性測試

本文探討 Rust 中複合指定運算元與相等性測試的底層機制,包含 AddAssign、PartialEq、PartialOrd 等 trait 的使用方法與實作細節。同時,文章也講解了如何處理 NaN 的比較行為以及自定義型別中運算元多載的技巧,並以 Complex 和 Interval 型別為例,示範如何實作這些

Rust 程式語言

在 Rust 開發中,理解複合指定運算元與相等性測試的運作原理至關重要。這些操作仰賴 std::ops 模組中的特定 trait,例如 AddAssignSubAssign 等用於複合指定,而 PartialEq 則用於相等性測試。對於自定義型別,我們需要實作對應的 trait 才能使用這些運算元。由於 IEEE 浮點數標準中 NaN 的特殊性,浮點數的相等性比較並不滿足所有等價關係的特性,因此 Rust 使用 PartialEq 而非 Eq。文章中以 Complex 複數型別為例,示範如何實作 AddAssignPartialEq,並解釋瞭如何處理 NaN 的比較問題。此外,PartialOrd trait 則用於定義大小比較運算元,文章以 Interval 區間型別為例,說明瞭如何實作 partial_cmp 方法來處理區間的比較邏輯,特別是重疊區間的無序性。

複合指定運算元與相等性測試

在Rust中,複合指定運算元(如+=-=*=等)以及相等性測試(如==!=)是透過特定的trait來實作的。本章將詳細介紹這些運算元的實作方式及其背後的trait。

複合指定運算元

Rust的複合指定運算元是由std::ops模組中的特定trait來支援的。例如,+=運算元對應於AddAssign trait。以下是AddAssign trait的定義:

trait AddAssign<RHS = Self> {
    fn add_assign(&mut self, rhs: RHS);
}

如表12-4所示,Rust為各種運算元提供了對應的trait:

  • 算術運算元:AddAssignSubAssignMulAssignDivAssignRemAssign
  • 位元運算元:BitAndAssignBitOrAssignBitXorAssignShlAssignShrAssign

所有Rust的數值型別都實作了算術複合指定運算元,而整數型別和布林型別則實作了位元複合指定運算元。

自定義型別實作複合指定運算元

為了讓自定義型別支援複合指定運算元,需要為其實作相應的trait。以我們之前定義的Complex型別為例,實作AddAssign trait如下:

use std::ops::AddAssign;

impl<T> AddAssign for Complex<T>
where
    T: AddAssign,
{
    fn add_assign(&mut self, rhs: Complex<T>) {
        self.re += rhs.re;
        self.im += rhs.im;
    }
}

這段程式碼實作在Complex<T>型別上的+=運算,使得兩個複數可以相加。

內容解密:

  1. AddAssign trait定義了add_assign方法,用於實作+=運算。
  2. 在為Complex<T>實作AddAssign時,要求T型別本身必須實作AddAssign,這樣才能對複數的實部和虛部分別進行加法運算。
  3. add_assign方法更新了當前複數(即self)的實部和虛部,將另一個複數(即rhs)的對應部分加到其上。

相等性測試

Rust中的相等性測試是透過實作std::cmp::PartialEq trait來完成的。該trait定義了兩個方法:eqne,分別對應於==!=運算元。

PartialEq trait的定義

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

為自定義型別實作PartialEq

對於我們的Complex<T>型別,可以如下實作:

impl<T: PartialEq> PartialEq for Complex<T> {
    fn eq(&self, other: &Complex<T>) -> bool {
        self.re == other.re && self.im == other.im
    }
}

這裡假設了元件型別T已經實作了PartialEq。這樣,兩個複數被視為相等當且僅當它們的實部和虛部分別相等。

自動派生實作

Rust允許透過在型別定義上新增屬性來自動派生某些trait的實作,例如:

#[derive(Clone, Copy, Debug, PartialEq)]
struct Complex<T> {
    // ...
}

這將自動為我們的結構體生成一個類別似於手寫的、逐場比較的實作。

內容解密:

  1. PartialEq trait用於比較兩個值的相等性。
  2. 在比較非複製(non-Copy)型別的值時,透過參照來進行比較,以避免值的移動。
  3. PartialEq<Rhs>中的界限 Rhs: ?Sized = Self> 表示右手邊的運算元可以是任意大小的型別,允許比較像是字串切片(&str)或切片(&[T])這樣的無大小(unsized)型別。

為什麼是PartialEq?

傳統數學中,等價關係需要滿足三個條件:反身性、對稱性和傳遞性。然而,Rust中的浮點數遵循IEEE標準,該標準規定NaN(Not a Number)與任何值都不相等,包括它自己。這使得浮點數上的等式不是一個完全的等價關係,因此相關trait被命名為PartialEq而非Eq。

內容解密:

  1. IEEE浮點數標準規定NaN不等於任何值,包括它自己,這破壞了等價關係中的反身性。
  2. 因此,Rust使用PartialEq來表示可能不完全滿足等價關係的相等性比較。

Rust 中的 PartialEq 與 PartialOrd 實作

Rust 語言中,對於等式比較和順序比較的運算子提供了特定的 trait 來規範其行為。在本章中,我們將詳細討論 PartialEqPartialOrd 這兩個 trait,以及如何在自定義型別中實作它們。

PartialEq 實作

PartialEq trait 用於定義 ==!= 運算子的行為。該 trait 要求實作 eq 方法,用於比較兩個值是否相等。Rust 的標準函式庫中,大多數型別都實作了 PartialEq,但對於浮點數型別(如 f32f64),由於 NaN(Not a Number)的存在,它們的比較行為比較特殊。

NaN 的比較行為

根據 IEEE 浮點數標準,NaN 與任何值(包括其自身)的比較結果都是無序的,也就是說,NaN == NaNNaN != NaN 都傳回 false。這種行為違背了等價關係的第三條規則(自反性),因此,浮點數型別的 PartialEq 實作並不滿足完整的等價關係,而是一種部分等價關係。

實作 PartialEq

對於自定義型別,如以下的 Complex 型別,可以透過 derive 屬性自動實作 PartialEq

#[derive(PartialEq)]
struct Complex<T> {
    re: T,
    im: T,
}

或者手動實作:

impl<T: PartialEq> PartialEq for Complex<T> {
    fn eq(&self, other: &Self) -> bool {
        self.re == other.re && self.im == other.im
    }
}

Eq 實作

如果一個型別滿足完整的等價關係(即自反性、對稱性和傳遞性),則可以實作 Eq trait。Eq trait 繼承自 PartialEq,並且不新增任何方法。對於大多數型別,如果它們實作了 PartialEq,也應該實作 Eq。浮點數型別是標準函式庫中少數不實作 Eq 的型別。

impl<T: Eq> Eq for Complex<T> {}

或者在 derive 屬性中直接包含 Eq

#[derive(PartialEq, Eq)]
struct Complex<T> {
    re: T,
    im: T,
}

PartialOrd 實作

PartialOrd trait 用於定義 <, >, <=, 和 >= 運算子的行為。它要求實作 partial_cmp 方法,該方法傳回一個 Option<Ordering> 值。如果傳回 Some(ordering),則表示兩個值之間存在特定的順序;如果傳回 None,則表示兩個值無序。

NaN 的順序比較行為

對於浮點數型別,如果比較的一方或雙方是 NaN,則 partial_cmp 傳回 None,表示無序。

實作 PartialOrd

對於自定義型別,如以下的 Interval 型別,可以實作 PartialOrd 以定義其順序比較行為:

#[derive(Debug, PartialEq)]
struct Interval<T> {
    lower: T, // inclusive
    upper: T // exclusive
}

impl<T: PartialOrd> PartialOrd for Interval<T> {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        if self.upper <= other.lower {
            Some(Ordering::Less)
        } else if self.lower >= other.upper {
            Some(Ordering::Greater)
        } else if self == other {
            Some(Ordering::Equal)
        } else {
            None
        }
    }
}

內容解密:

  1. 檢查是否完全小於對方區間:如果當前區間的上界小於或等於對方區間的下界,則當前區間完全在對方區間之前,因此傳回 Some(Ordering::Less)
  2. 檢查是否完全大於對方區間:如果當前區間的下界大於或等於對方區間的上界,則當前區間完全在對方區間之後,因此傳回 Some(Ordering::Greater)
  3. 檢查是否相等:如果當前區間與對方區間相等(根據先前實作的 PartialEq),則傳回 Some(Ordering::Equal)
  4. 其他情況:如果以上條件都不滿足,表示兩個區間重疊,因此傳回 None 表示無序。

Rust 中的運算元多載

Rust 語言允許開發者透過特定的 trait 來多載某些運算元,使自定義型別能夠支援各種運算操作。這種機制增強了語言的表達能力和程式碼的可讀性。

PartialOrd 與 Interval 實作

首先,我們來看如何為自定義的 Interval<T> 型別實作 PartialOrd trait,使其能夠進行比較操作。

use std::cmp::{Ordering, PartialOrd};

impl<T: PartialOrd> PartialOrd<Interval<T>> for Interval<T> {
    fn partial_cmp(&self, other: &Interval<T>) -> Option<Ordering> {
        if self == other { 
            Some(Ordering::Equal) 
        } else if self.lower >= other.upper { 
            Some(Ordering::Greater) 
        } else if self.upper <= other.lower { 
            Some(Ordering::Less) 
        } else { 
            None 
        }
    }
}

內容解密:

  1. PartialOrd trait 的實作允許 Interval<T> 進行部分比較。
  2. 當兩個區間完全相等時,傳回 Some(Ordering::Equal)
  3. 如果 self 的下界大於或等於 other 的上界,則 self 大於 other,傳回 Some(Ordering::Greater)
  4. 如果 self 的上界小於或等於 other 的下界,則 self 小於 other,傳回 Some(Ordering::Less)
  5. 當兩個區間重疊時,無法確定它們的相對大小,因此傳回 None

有了這個實作,我們可以對 Interval 例項進行比較:

assert!(Interval { lower: 10, upper: 20 } < Interval { lower: 20, upper: 40 });
assert!(Interval { lower: 7, upper: 8 } >= Interval { lower: 0, upper: 1 });
assert!(Interval { lower: 7, upper: 8 } <= Interval { lower: 7, upper: 8 });
// 重疊的區間無法比較
let left = Interval { lower: 10, upper: 30 };
let right = Interval { lower: 20, upper: 40 };
assert!(!(left < right));
assert!(!(left >= right));

Index 與 IndexMut

Rust 中的 IndexIndexMut trait 用於定義索引操作,例如 a[i]。這兩個 trait 分別用於不可變和可變的索引存取。

trait Index<Idx> {
    type Output: ?Sized;
    fn index(&self, index: Idx) -> &Self::Output;
}

trait IndexMut<Idx>: Index<Idx> {
    fn index_mut(&mut self, index: Idx) -> &mut Self::Output;
}

內容解密:

  1. Index trait 定義了一個方法 index,它接受一個索引並傳回對應值的不可變參照。
  2. IndexMut trait 繼承自 Index,並新增了一個方法 index_mut,用於傳回可變參照。
  3. 這兩個 trait 的實作使得自定義型別能夠支援類別似陣列的索引操作。

Image 型別的 Index 與 IndexMut 實作

為了使一個影像型別 Image<P> 能夠像二維陣列一樣被索引,我們需要為它實作 IndexIndexMut

struct Image<P> {
    width: usize,
    pixels: Vec<P>
}

impl<P> std::ops::Index<usize> for Image<P> {
    type Output = [P];
    fn index(&self, row: usize) -> &[P] {
        let start = row * self.width;
        &self.pixels[start .. start + self.width]
    }
}

impl<P> std::ops::IndexMut<usize> for Image<P> {
    fn index_mut(&mut self, row: usize) -> &mut [P] {
        let start = row * self.width;
        &mut self.pixels[start .. start + self.width]
    }
}

內容解密:

  1. Image<P> 的索引操作傳回一個畫素值的切片 [P]
  2. 索引操作的實作是根據將二維索引轉換為一維索引在 pixels 向量上進行的。
  3. 當對 Image 進行索引時,如果行索引超出範圍,將觸發 panic。

其他運算元

Rust 不允許所有運算元被多載。某些運算元,如邏輯運算元、範圍運算元等,有固定的行為,不能被改變。此外,函式呼叫運算元和解參照運算元可以透過其他 trait(如 DerefFn 相關的 trait)進行多載。

內容解密:

  1. Rust 對某些運算元的行為有嚴格的規定,不允許透過多載改變其語義。
  2. 解參照運算元 * 和點運算元 . 可以透過實作特定的 trait(如 DerefDerefMut)來改變其行為。
  3. 函式呼叫運算元可以透過閉包或實作 FnFnMutFnOnce trait 的型別來使用。

總之,Rust 的運算元多載機制提供了靈活性和表達能力,但也需要謹慎使用,以避免混淆和錯誤。開發者應根據具體情況選擇適當的 trait 和實作方式,以達到最佳的程式碼品質。