Custom Equatability in Rust


In Rust, equatability is typically implemented using the PartialEq and Eq traits. While the default implementation works well for many use cases, there are scenarios where custom implementations are necessary.

In this post, we will explore unique business cases where equatability needs customization, such as ignoring specific fields, handling floating-point precision, or using alternative identifiers. We’ll also see how Rust enables these implementations efficiently.

Ignoring Certain Fields in Comparisons

📌 Scenario: In some business logic, fields like IDs, timestamps, or version numbers should not be considered when checking equality. For example, in a user management system, users might be uniquely identified by an ID, but for business operations such as sending notifications or grouping users by attributes, only the name and email should be compared.

🦀 Rust Implementation

#[derive(Debug)]
struct User {
    id: u32, // Ignored during comparison
    name: String,
    email: String,
}

impl PartialEq for User {
    fn eq(&self, other: &Self) -> bool {
        self.name == other.name && self.email == other.email
    }
}


fn main() {
    let user1 = User {
        id: 1,
        name: String::from("Alice"),
        email: String::from("alice@example.com"),
    };

    let user2 = User {
        id: 2, // Different ID, but same name and email
        name: String::from("Alice"),
        email: String::from("alice@example.com"),
    };

    let user3 = User {
        id: 3,
        name: String::from("Bob"),
        email: String::from("bob@example.com"),
    };

    // Comparison ignoring the `id` field
    println!("user1 == user2: {}", user1 == user2); // true
    println!("user1 == user3: {}", user1 == user3); // false

    println!("{:?}", user1);
}

Benefit: Enables equality checks without considering irrelevant fields.

Fuzzy Equality (Tolerance-Based Comparisons)

📌 Scenario: For floating-point comparisons, strict equality often isn’t desirable due to precision errors. These errors occur because floating-point numbers are represented in binary, leading to small rounding discrepancies when performing arithmetic operations. Instead, we check if two numbers are close enough.

🦀 Rust Implementation

#[derive(Debug)]
struct Point {
    x: f64,
    y: f64,
}

impl PartialEq for Point {
    fn eq(&self, other: &Self) -> bool {
        (self.x - other.x).abs() < 0.01 && (self.y - other.y).abs() < 0.01
    }
}

fn main() {
    let p1 = Point { x: 1.0, y: 2.0 };
    let p2 = Point { x: 1.005, y: 2.005 }; // Slightly different but within the threshold
    let p3 = Point { x: 1.02, y: 2.02 }; // Outside the threshold

    // Comparison with tolerance
    println!("p1 == p2: {}", p1 == p2); // true
    println!("p1 == p3: {}", p1 == p3); // false
}

Benefit: Ensures floating-point comparisons account for minor precision differences.

Contextual Equatability (Mode-Based Comparisons)

📌 Scenario: Objects might need to be compared differently based on business logic. For example, in strict mode, we compare all fields, while in a relaxed mode, we might ignore price differences.

🦀 Rust Implementation

#[derive(Debug)]
struct Product {
    name: String,
    price: f64,
}

enum ComparisonMode {
    Strict,
    IgnorePrice,
}

impl Product {
    fn equals(&self, other: &Self, mode: ComparisonMode) -> bool {
        match mode {
            ComparisonMode::Strict => self.name == other.name && self.price == other.price,
            ComparisonMode::IgnorePrice => self.name == other.name,
        }
    }
}

fn main() {
    let product1 = Product {
        name: String::from("Laptop"),
        price: 999.99,
    };

    let product2 = Product {
        name: String::from("Laptop"),
        price: 999.99, // Same price
    };

    let product3 = Product {
        name: String::from("Laptop"),
        price: 899.99, // Different price
    };

    let product4 = Product {
        name: String::from("Phone"),
        price: 799.99, // Different name and price
    };

    // Comparisons
    println!("product1 == product2 (Strict): {}", product1.equals(&product2, ComparisonMode::Strict)); // true
    println!("product1 == product3 (Strict): {}", product1.equals(&product3, ComparisonMode::Strict)); // false
    println!("product1 == product3 (IgnorePrice): {}", product1.equals(&product3, ComparisonMode::IgnorePrice)); // true
    println!("product1 == product4 (IgnorePrice): {}", product1.equals(&product4, ComparisonMode::IgnorePrice)); // false
}

Benefit: Allows for flexible comparison logic based on different contexts.

Lexicographic Equality (Case-Insensitive Comparisons)

📌 Scenario: String-based comparisons sometimes need to be case-insensitive, such as when comparing usernames or product names.

🦀 Rust Implementation

#[derive(Debug)]
struct Person {
    name: String,
}

impl PartialEq for Person {
    fn eq(&self, other: &Self) -> bool {
        self.name.to_lowercase() == other.name.to_lowercase()
    }
}

fn main() {
    let person1 = Person {
        name: String::from("Alice"),
    };

    let person2 = Person {
        name: String::from("alice"), // Same name but different case
    };

    let person3 = Person {
        name: String::from("Bob"), // Different name
    };

    // Comparison ignoring case
    println!("person1 == person2: {}", person1 == person2); // true
    println!("person1 == person3: {}", person1 == person3); // false
}

Benefit: Handles case-insensitive comparisons without requiring manual normalization.

Group-Based Equatability (Multiple Identifiers)

📌 Scenario: Sometimes, two objects should be considered equal if they share at least one common identifier, even if they have different unique keys (e.g., multiple UPC codes for the same product).

🦀 Rust Implementation

#[derive(Debug)]
struct Product {
    upcs: Vec<String>, // Different codes, but same product
    name: String,
}

impl PartialEq for Product {
    fn eq(&self, other: &Self) -> bool {
        self.upcs.iter().any(|upc| other.upcs.contains(upc))
    }
}

fn main() {
    let product1 = Product {
        upcs: vec![String::from("12345"), String::from("67890")],
        name: String::from("Laptop"),
    };

    let product2 = Product {
        upcs: vec![String::from("67890"), String::from("11111")], // Overlapping UPC: "67890"
        name: String::from("Laptop"),
    };

    let product3 = Product {
        upcs: vec![String::from("99999"), String::from("88888")], // No matching UPC
        name: String::from("Laptop"),
    };

    // Comparison based on shared UPCs
    println!("product1 == product2: {}", product1 == product2); // true (common UPC: "67890")
    println!("product1 == product3: {}", product1 == product3); // false (no common UPC)
}

Benefit: Supports alternative identifiers for equality checks.

Final Thoughts

Before wrapping up, let’s recap some key takeaways: Rust allows for flexible equatability customizations, including ignoring fields, fuzzy comparisons, contextual equality, and alternative identifiers. These techniques enable more accurate and meaningful equality checks for different business cases.

Rust’s trait-based approach to equatability allows developers to customize equality logic based on business needs. Whether it’s ignoring certain fields, implementing fuzzy comparisons, handling context-dependent equality, or using alternative identifiers, Rust provides the flexibility to achieve it efficiently.

By leveraging PartialEq and other techniques, we can ensure that equality checks align with our application’s logic instead of being constrained by default implementations.