Rust PartialEq, Eq, PartialOrd and Ord

Overview of Equality and Ordering Traits

  1. PartialEq:
    • Allows the use of == and !=.
    • Supports "partial" equality for types where some comparisons might be undefined (e.g., floating-point numbers with NaN).
  2. Eq:
    • A marker trait indicating that equality is total (i.e., every value can be compared to every other value, and the relation is reflexive, symmetric, and transitive).
  3. PartialOrd:
    • Enables ordering comparisons (<, >, <=, >=).
    • Returns an Option<Ordering> because not all pairs of values might be comparable.
  4. Ord:
    • Specifies a total order by implementing the cmp method.
    • Requires that every pair of elements yields a definitive ordering (Less, Equal, or Greater).

The Price Struct Example

// Using rust_decimal crate
use rust_decimal::Decimal;
use rust_decimal_macros::dec;

#[derive(Debug, Clone, Copy)]
pub struct Price {
    pub list: Decimal,
    pub offer: Option<Decimal>,
}

What It Represents

  • list: The regular price.
  • offer: An optional discounted price.

Defining the "Effective Price"

For our domain, the effective price is defined as:

  1. If an offer exists, use it.
  2. Otherwise, use the list price.

This concept is central because:

  • It drives how equality and ordering will be determined.
  • It allows a "discounted" price logic, meaning a price with an offer (if lower) should be considered "cheaper" than one without.
impl Price {
    // Other methods...
    
    /// Returns the effective price:
    /// if an offer is provided, it's the offer; otherwise it's the list price.
    fn effective(&self) -> Decimal {
        self.offer.unwrap_or(self.list)
    }
}

Custom Equality and Ordering Implementations

Since our test cases require specific behavior, we will implement custom versions of the traits.

Equality: PartialEq and Eq

/// Two prices are equal if they have the same effective price and list price
impl PartialEq for Price {
    fn eq(&self, other: &Self) -> bool {
        self.effective() == other.effective() && self.list == other.list
    }
}

/// Marker trait indicating Price implements total equality
impl Eq for Price {}

Ordering: PartialOrd and Ord

For ordering, the primary key is the effective price. If the effective prices are equal, compare the list prices:

use std::cmp::Ordering;

/// Total ordering based on effective price first, then list price as a tie-breaker
impl Ord for Price {
    fn cmp(&self, other: &Self) -> Ordering {
        match self.effective().cmp(&other.effective()) {
            Ordering::Equal => self.list.cmp(&other.list),
            ord => ord,
        }
    }
}

/// Partial ordering that delegates to the total ordering implementation
impl PartialOrd for Price {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

Example Usage

// Example usage in collections
let mut prices = vec![
   Price {
         list: Decimal::from_f32(10.0).unwrap(),
         offer: Some(Decimal::from_f32(8.0).unwrap()),
   },
   Price {
         list: Decimal::from_f32(5.0).unwrap(),
         offer: None,
   },
];

prices.sort(); // Works because we implemented Ord
let min_price = prices.iter().min().unwrap(); // Gets the lowest price
assert_eq!(min_price, &Price {
   list: Decimal::from_f32(5.0).unwrap(),
   offer: None,
});

// Using prices in a sorted collection
use std::collections::BTreeSet;
let mut price_set = BTreeSet::new();

price_set.insert(Price {
   list: Decimal::from_f32(10.0).unwrap(),
   offer: Some(Decimal::from_f32(8.0).unwrap()),
});

price_set.insert(Price {
   list: Decimal::from_f32(5.0).unwrap(),
   offer: None,
});

assert_eq!(price_set.iter().min().unwrap(), &Price {
   list: Decimal::from_f32(5.0).unwrap(),
   offer: None,
});

Property-Based Testing

It's important to verify that our implementations satisfy the expected properties of these traits:

#[test]
fn test_reflexivity() {
   let price = Price { list: Decimal::from_f32(10.0).unwrap(), offer: Some(Decimal::from_f32(8.0).unwrap()) };
   assert_eq!(price, price);
}

#[test]
fn test_symmetry() {
   let a = Price { list: Decimal::from_f32(10.0).unwrap(), offer: Some(Decimal::from_f32(8.0).unwrap()) };
   let b = Price { list: Decimal::from_f32(10.0).unwrap(), offer: Some(Decimal::from_f32(8.0).unwrap()) };
   assert_eq!(a == b, b == a);
}

#[test]
fn test_transitivity() {
   let a = Price { list: Decimal::from_f32(10.0).unwrap(), offer: Some(Decimal::from_f32(8.0).unwrap()) };
   let b = Price { list: Decimal::from_f32(10.0).unwrap(), offer: Some(Decimal::from_f32(8.0).unwrap()) };
   let c = Price { list: Decimal::from_f32(10.0).unwrap(), offer: Some(Decimal::from_f32(8.0).unwrap()) };
   
   assert!(a == b && b == c && a == c);
}

#[test]
fn test_ord_consistency() {
   let a = Price { list: Decimal::from_f32(10.0).unwrap(), offer: Some(Decimal::from_f32(8.0).unwrap()) };
   let b = Price { list: Decimal::from_f32(10.0).unwrap(), offer: Some(Decimal::from_f32(8.0).unwrap()) };
   
   // If two values compare equal under Ord, they should be equal under PartialEq
   assert!(a.cmp(&b) == Ordering::Equal && a == b);
}

Total vs. Partial Comparisons

Total Equality and Total Ordering

  1. Total Equality (Eq and PartialEq):

    • Implement these when every instance of our type can be compared without any ambiguity. For example, if we know that for any two values, either they are equal or they are not (with no edge cases like "undefined" comparisons), then we should implement both PartialEq and Eq.
    • Use Case: Types that do not include floating-point numbers with NaN, or any values that might be "incomparable."
    • Example: Our Price type, when based on Decimal (assuming it provides total equality), should implement both so that if two prices compare equal in ordering (via Ord) they are also equal with Eq.
  2. Total Ordering (Ord and PartialOrd):

    • Implement these when we can always determine a clear ordering between any two instances of our type. If every pair of elements can be compared to yield a definitive ordering (Less, Equal, or Greater), we should implement both PartialOrd and Ord.
    • Use Case: Types that need to be used in sorted collections (like BTreeMap or BTreeSet) require a total order.
    • Example: In our custom Price example, if we define the ordering based on the effective price (offer if available, otherwise list) and then use the list price as a tie-breaker, every Price value can be compared with any other.

Partial Equality and Partial Ordering

  1. Partial Equality (PartialEq without Eq):

    • Use just PartialEq when there might be cases where comparison is not fully defined. A classic example is floating-point numbers (e.g., f32 or f64) where NaN != NaN makes equality a "partial" concept.
    • When Not to Use Eq: If our type can contain values for which equality isn't reflexive or total (e.g., if it embeds floats that might be NaN), we should only implement PartialEq.
  2. Partial Ordering (PartialOrd without Ord):

    • Use PartialOrd when not all elements can be compared. Again, floating-point numbers are a classic case since comparisons involving NaN yield None rather than an Ordering.
    • When Not to Use Ord: If our type can encounter undefined comparisons (or if the notion of ordering isn't complete), implement PartialOrd alone.
    • Example: If our type had a field that could sometimes be "missing" or "undefined" (beyond the normal Option handling), we might only provide a partial order.

Trait Relationships and Consistency

  • Consistency Is Key:

    • If we implement Ord, it implies a total order. One of the guarantees we must provide is that if a.cmp(&b) returns Ordering::Equal, then a == b must hold under our PartialEq implementation.
    • Practical Impact: When designing our custom implementations (as in the Price example), ensure that the fields we use for ordering are the same fields (or logically equivalent values) we use for equality.
  • Implementation Dependencies:

    • Ord requires that we also implement PartialOrd.
    • Eq requires that we implement PartialEq.
    • In most cases, if we can derive Ord (because all our inner types are totally ordered), we will also derive Eq.

Common Pitfalls

  1. Inconsistency Between Ord and PartialEq:

    • If a.cmp(&b) == Ordering::Equal but a != b, this violates the contract and can lead to unpredictable behavior in collections.
  2. Using Derived Implementations When Custom Logic Is Needed:

    • The derived implementations compare all fields in order, which may not match our domain requirements (like our "effective price" concept).
  3. Neglecting Edge Cases:

    • Forgetting to handle special values like empty collections, None options, or boundary values.
  4. Not Testing Contract Properties:

    • Failing to verify reflexivity, symmetry, and transitivity for equality relations.

Simplifying with Helper Crates

For simple cases, we might leverage crates that can help with implementations:

// Using derive_more for simplified implementations
use derive_more::{PartialEq, Eq};

#[derive(PartialEq, Eq)]
struct SimplePrice {
    // Fields
}

Other useful crates include:

  • derivative: Provides more control over derived implementations
  • compare: Offers utilities for building complex comparisons

Deciding for Our Own Types

  1. When to Implement Both (Eq + Ord):

    • Our type has a clear, total notion of equality and ordering.
    • We want to use our type as a key in sorted collections.
    • Example: A Price type where both the effective price (offer or list) and list price always provide a meaningful, total comparison.
  2. When to Implement Only the Partial Traits (PartialEq + PartialOrd):

    • Our type's values might sometimes be "incomparable" (e.g., types containing floating-point numbers with NaN).
    • We do not need to use our type in contexts where a total order is required (like sorted sets/maps).
    • Example: A custom numeric type that might include invalid or "empty" states where a complete ordering doesn't make sense.

Takeaways

  1. Consistency Between Traits:

    • Ensure that if cmp returns Equal, then the PartialEq implementation must also indicate equality. This consistency is crucial when using types in collections that depend on these traits (like sorted sets or maps).
  2. Total vs. Partial Ordering:

    • If we need to sort our data (for example, in a sorted container like a BTreeSet), our type must implement Ord. If our type might not have a well-defined order (as with floats), then we may only implement PartialOrd or use a wrapper that provides a total order.
  3. Custom vs. Derive:

    • While deriving traits is convenient, custom implementations let us tailor behavior to fit business logic—such as preferring discounted (offer) prices over list prices.
  4. Testing:

    • Create tests that express our domain requirements. These tests guide our implementation and ensure that our type behaves correctly in sorting and equality checks.
Share Note
rocket

© 2023 KungFuDev made with love / cd 💜

Heavily inspired/copied from shuttle.rs