Danh sách bài viết

Bài 163: PartialEq, Eq, Hash — Equality Traits

Bài 163 của series Rust Cơ Bản — sau khi B162 đã đi Display và Debug phục vụ in giá trị, bộ trait tiếp theo gặp gần như mọi ngày là nhóm equality: PartialEq, Eq, Hash. Mỗi lần gõ a == b, compiler dispatch tới method PartialEq::eq(&a, &b). Mỗi lần nhét key vào HashMap, key phải đồng thời impl cả Hash lẫn Eq. Ba trait này tưởng giống nhau nhưng đóng vai trò khác biệt rõ ràng: PartialEq chỉ định cách so sánh, Eq là marker khẳng định quan hệ phản xạ (x == x luôn đúng), còn Hash sinh giá trị hash để dùng trong cấu trúc dữ liệu băm. Bài này hệ thống hoá cả ba, vì sao f32/f64 không qua được Eq, axiom kinh điển ràng buộc Hash với Eq, và cách viết tay impl PartialEq + Hash phối hợp để compare theo một field cụ thể (ví dụ id) thay vì toàn bộ struct.

09/06/2026
10 phút đọc
0 lượt xem
1

Mục Tiêu

Sau bài học, bạn sẽ:

  • Hiểu PartialEq là trait sinh operator ==!= — mỗi lần compiler thấy a == b sẽ dispatch sang PartialEq::eq(&a, &b).
  • Dùng #[derive(PartialEq)] để auto-impl so sánh field-by-field cho struct, hoặc variant + payload cho enum.
  • Hiểu Eqmarker trait extend PartialEq — không thêm method, chỉ bổ sung guarantee reflexive: với mọi x, luôn có x == x.
  • Biết vì sao f32/f64 chỉ impl PartialEq mà không impl Eq — do IEEE-754 quy định NaN != NaN, phá vỡ reflexive.
  • Hiểu Hash trait sinh giá trị hash từ value, là điều kiện bắt buộc để type được dùng làm key của HashMap/HashSet.
  • Thuộc axiom CONSISTENT giữa HashEq: nếu a == b thì hash(a) == hash(b) — derive an toàn, impl tay phải tự đảm bảo.
  • Áp dụng derive stack phổ biến #[derive(Eq, PartialEq, Hash)] cho mọi struct cần làm map key.
  • Viết tay impl PartialEq để compare theo một field cụ thể (ví dụ id), đồng thời viết impl Hash phối hợp để không vi phạm axiom.
2

PartialEq Trait

PartialEq là trait trong std::cmp định nghĩa một method bắt buộc duy nhất là eq(&self, other: &Self) -> bool và một method tuỳ chọn ne (mặc định là !self.eq(other)). Mỗi khi bạn gõ a == b trong code, compiler dispatch sang lời gọi PartialEq::eq(&a, &b); gõ a != b dispatch sang PartialEq::ne. Hai operator không phải "phép so sánh dựng sẵn của ngôn ngữ" mà chỉ là cú pháp ngọt cho trait method.

Cách nhanh nhất để có == là dùng #[derive(PartialEq)] — compiler tự sinh impl so sánh từng field (với struct) hoặc từng variant + payload (với enum):

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

#[derive(Debug, PartialEq)]
enum Status {
    Active,
    Banned(String),
}

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 1, y: 2 };
    println!("{}", p1 == p2);    // true — field-by-field

    let s1 = Status::Banned(String::from("spam"));
    let s2 = Status::Banned(String::from("spam"));
    println!("{}", s1 == s2);    // true — cùng variant, cùng payload
}

Ràng buộc duy nhất với derive: mọi field phải impl PartialEq. Hầu hết primitive (integer, float, bool, char), String, Vec<T>, Option<T> đều có sẵn — nên derive thường "chạy" mà không cần điều chỉnh thêm.

3

Eq Trait — Marker Reflexive

Eq là một marker trait đặc biệt: nó không thêm method mới, chỉ extend PartialEq và bổ sung một guarantee toán học mà compiler không tự kiểm — quan hệ reflexive: với mọi giá trị x, luôn có x == x.

pub trait Eq: PartialEq<Self> {
    // không method nào — chỉ là marker
}

Khi bạn impl (hoặc derive) Eq cho một type, bạn "thề" với compiler và với người đọc code rằng equality của type này thoả mãn đầy đủ ba tính chất của equivalence relation: reflexive (a == a), symmetric (a == b ⟺ b == a), transitive (a == b, b == c ⟹ a == c). Trong khi đó, PartialEq chỉ đòi symmetric + transitive — bỏ ngỏ reflexive.

Phần lớn type "đời thường" — integer, bool, char, String, struct chỉ chứa các field như vậy — đều thoả reflexive. Quy tắc thực dụng: mọi type không chứa field float nên impl Eq. Cách đơn giản nhất: thêm cả hai vào derive list:

#[derive(Debug, PartialEq, Eq)]
struct UserId(u64);

fn main() {
    let u1 = UserId(42);
    let u2 = UserId(42);
    assert_eq!(u1, u2);    // dùng được — Eq cho phép HashMap/HashSet key
}

Vì sao "đeo" thêm Eq dù không có method mới? Vì rất nhiều API trong stdlib và crate khác có bound T: Eq — nổi tiếng nhất là HashMap<K, V> đòi K: Eq + Hash. Không có Eq, bạn không dùng được type làm key của map băm.

4

Vì Sao Float Không Eq

Có một ngoại lệ rất nổi tiếng: f32f64 chỉ impl PartialEq, không impl Eq. Lý do đến từ chuẩn IEEE-754 (chuẩn biểu diễn số thực dấu phẩy động trên gần như mọi CPU hiện đại): có một giá trị đặc biệt gọi là NaN (Not-a-Number) đại diện cho kết quả không xác định như 0.0 / 0.0, sqrt(-1.0), hay log(-1.0). Theo IEEE-754, NaN không bằng bất kỳ giá trị nào — kể cả chính nó:

fn main() {
    let n = f64::NAN;
    println!("NaN == NaN: {}", n == n);    // false!
    println!("NaN != NaN: {}", n != n);    // true!

    // dùng kiểm tra NaN
    println!("is_nan: {}", n.is_nan());    // true
}

Tính chất NaN != NaN phá vỡ reflexive: tồn tại xx == xfalse. Vì vậy f64 không thoả guarantee của Eq → standard library chủ động không impl Eq cho float. Hệ quả: bất kỳ struct nào có field f32/f64 cũng không thể derive Eq (compiler báo lỗi error[E0277]: the trait bound `f64: Eq` is not satisfied), và không thể dùng làm HashMap key trực tiếp.

Cách xử lý khi cần dùng giá trị "giống float" làm key: (a) đổi sang integer (giá tiền nên lưu cents u64, không lưu USD f64); (b) dùng crate ordered-float bọc f64 trong OrderedFloat<f64> đã impl Eq + Hash với quy ước riêng cho NaN.

5

Hash Trait

Hash nằm trong std::hash, định nghĩa method hash<H: Hasher>(&self, state: &mut H) — nhận một hasher và "nạp" thông tin của value vào hasher để cuối cùng cho ra một số u64 đại diện. Người gọi không cần đụng tới chi tiết: chỉ cần biết type có impl Hash là dùng được làm key cho HashMap/HashSet.

use std::collections::HashMap;

#[derive(Debug, PartialEq, Eq, Hash)]
struct UserId(u64);

fn main() {
    let mut map: HashMap<UserId, &str> = HashMap::new();
    map.insert(UserId(1), "Khanh");
    map.insert(UserId(2), "Linh");

    if let Some(name) = map.get(&UserId(1)) {
        println!("user 1 = {name}");   // "Khanh"
    }
}

Derive mặc định #[derive(Hash)] sinh impl dựa byte representation của từng field theo thứ tự khai báo — tương đương "feed lần lượt mỗi field vào hasher". Field nào cũng phải impl Hash. Primitive integer, bool, char, String, Vec<T>, Option<T> đều có; f32/f64 thì không có (cùng lý do với Eq) — đó là điểm chặn nếu bạn lỡ định dùng float làm key.

Nhắc lại: HashMap<K, V> đòi K: Eq + Hash. Có Hash mà thiếu Eq không đủ; có Eq mà thiếu Hash cũng không đủ — phải đủ cả hai. Stack derive tối thiểu cho map key là #[derive(PartialEq, Eq, Hash)].

6

Axiom Hash + Eq

Đây là axiom quan trọng nhất, in đậm cũng không thừa: nếu a == b thì hash(a) == hash(b). Cách nói gọn: Hash phải consistent với Eq. Hai value bằng nhau theo PartialEq bắt buộc phải sinh ra cùng giá trị hash.

Vì sao quan trọng? HashMap hoạt động hai bước: (1) tính hash(key) để xác định slot/bucket trong bảng băm; (2) trong bucket đó, dùng == để tìm chính xác key. Nếu a == b nhưng hash(a) != hash(b), hai value sẽ rơi vào hai bucket khác nhau — map.insert(a, ...) rồi map.get(&b) sẽ trả về None dù logic ngữ nghĩa thì hai key "bằng nhau". Bug "key biến mất" cực kỳ khó debug.

May mắn: khi derive cả HashPartialEq, compiler tự sinh impl thoả axiom (cả hai cùng "đọc" mọi field theo cùng thứ tự). Bạn không phải lo gì. Vấn đề chỉ phát sinh khi viết tay một trong hai — trách nhiệm giữ axiom là của lập trình viên (compiler không kiểm tra được). Đây là chủ đề của Bước 8 và 9.

Chiều ngược lại không bắt buộc: hash(a) == hash(b) không suy ra a == b — đó là hiện tượng collision, hoàn toàn bình thường và không phá vỡ HashMap.

7

Common Derive Stack

Trong code production, "stack" derive ba trait này có vài biến thể rất phổ biến, đáng thuộc lòng:

// 1. Struct nhỏ, KHÔNG cần dùng làm key — chỉ cần ==
#[derive(Debug, PartialEq)]
struct Vec3 { x: f64, y: f64, z: f64 }   // có float → KHÔNG thêm Eq được

// 2. Struct/enum không có float, CÓ KHẢ NĂNG làm key sau này
#[derive(Debug, PartialEq, Eq)]
struct UserName(String);

// 3. Struct/enum dùng làm HashMap/HashSet key — stack đầy đủ
#[derive(Debug, PartialEq, Eq, Hash)]
struct UserId(u64);

#[derive(Debug, PartialEq, Eq, Hash)]
enum Role { Admin, Editor, Viewer }

// 4. Cộng thêm Clone, Copy cho ID-like nhỏ
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct OrderId(u32);

Vài nguyên tắc:

  • Không bao giờ derive Eq hay Hash trên struct có field float — compile fail. Nếu thực sự cần map key, bọc qua wrapper integer hoặc ordered-float.
  • PartialEq là default đặt sẵn cho mọi struct/enum mới — kể cả khi chưa dùng ==, các framework test (assert_eq!) hay match guard rất hay cần.
  • Thêm Eq + Hash sớm khi struct là "ID-like" hoặc value-type bất biến — kiểu UserId, OrderId, HttpMethod — sau này gần như chắc chắn sẽ làm key.
  • Đừng derive ngược lại: #[derive(Eq)] mà không có PartialEq sẽ báo lỗi vì Eq: PartialEq (supertrait).
8

Custom impl PartialEq

Đôi khi quy tắc field-by-field của #[derive(PartialEq)] không đúng nghiệp vụ. Ví dụ kinh điển: một User có nhiều field như id, name, email, updated_at — nhưng theo nghiệp vụ hai User được coi là "cùng một người" khi cùng id, không quan tâm các field khác có thay đổi hay không (vì name đổi, email đổi nhưng vẫn là user đó).

Lúc này derive không đủ — phải viết tay impl PartialEq:

#[derive(Debug)]
struct User {
    id: u64,
    name: String,
    email: String,
}

impl PartialEq for User {
    fn eq(&self, other: &Self) -> bool {
        // chỉ so sánh id - bỏ qua name, email
        self.id == other.id
    }
}

// Eq là marker — không có method, chỉ "thề" reflexive
// id so sánh u64 với chính nó luôn true → reflexive OK
impl Eq for User {}

fn main() {
    let u1 = User { id: 1, name: "Khanh".into(),  email: "[email protected]".into() };
    let u2 = User { id: 1, name: "Khanh2".into(), email: "[email protected]".into() };
    let u3 = User { id: 2, name: "Linh".into(),   email: "[email protected]".into() };

    println!("{}", u1 == u2);   // true — cùng id dù khác name/email
    println!("{}", u1 == u3);   // false — khác id
}

Lưu ý: vì bạn đã quyết định "equality = same id", trách nhiệm là tự đảm bảo reflexive + symmetric + transitive. Ở ví dụ trên cả ba đều thoả vì u64 equality vốn là quan hệ tương đương. Nhưng còn axiom với Hash thì sao — sang Bước 9.

9

Custom Hash + Eq Phối Hợp

Bước 8 đã đổi PartialEq sang so sánh chỉ theo id. Nếu giờ derive #[derive(Hash)] mặc định, compiler sẽ hash tất cả field (id, name, email) — và đây là cái bẫy: với cùng id nhưng khác name, PartialEq nói "bằng" nhưng Hash cho ra hai giá trị khác. Vi phạm axiom!

Hậu quả thực tế trong HashMap:

// SAI - vi phạm axiom Hash consistent với Eq
let mut map: HashMap<User, &str> = HashMap::new();
map.insert(User { id: 1, name: "Khanh".into(),  email: "[email protected]".into() }, "data");

// Cùng id (eq nói "bằng"), nhưng hash khác → get về None!
let key = User { id: 1, name: "Khanh2".into(), email: "[email protected]".into() };
assert_eq!(map.get(&key), None);   // BUG: key "biến mất"

Cách sửa: nếu eq compare theo id, hash cũng phải dựa id:

use std::hash::{Hash, Hasher};

impl Hash for User {
    fn hash<H: Hasher>(&self, state: &mut H) {
        // chỉ hash id - khớp với eq compare theo id
        self.id.hash(state);
    }
}

fn main() {
    let mut map: std::collections::HashMap<User, &str> = std::collections::HashMap::new();
    map.insert(User { id: 1, name: "Khanh".into(),  email: "[email protected]".into() }, "data");

    let key = User { id: 1, name: "Khanh2".into(), email: "[email protected]".into() };
    assert_eq!(map.get(&key), Some(&"data"));   // OK — cùng id thì hash giống
}

Quy tắc vàng: bất cứ thứ gì eq đọc, hash cũng phải đọc — và ngược lại. Cách an toàn nhất khi cần custom: tự tay impl cả PartialEq lẫn Hash cạnh nhau trong cùng một block code, đặt chú thích rõ ràng "compare/hash theo trường X" để người sau không lỡ tay sửa lệch một bên.

10

Tổng Kết

  • PartialEq định nghĩa eqa == b dispatch sang PartialEq::eq(&a, &b). Derive sinh so sánh field-by-field.
  • Eq là marker extend PartialEq, bổ sung guarantee reflexive x == x. Không thêm method, chỉ "đeo nhãn".
  • f32/f64 không impl Eq vì IEEE-754 quy định NaN != NaN — phá reflexive. Struct có field float cũng không Eq.
  • Hash sinh giá trị hash từ value, bắt buộc cho HashMap/HashSet key. Derive mặc định dựa byte representation từng field.
  • Axiom CONSISTENT: nếu a == b thì hash(a) == hash(b). Derive an toàn — impl tay phải tự đảm bảo, vi phạm khiến key "biến mất" trong map.
  • Stack derive phổ biến: #[derive(PartialEq, Eq, Hash)] cho mọi type ID-like / map key. Bỏ Eq + Hash nếu có field float.
  • Custom impl PartialEq: ví dụ compare User theo id thay vì full struct — viết tay fn eq(&self, other) { self.id == other.id }, kèm impl Eq for User {}.
  • Custom Hash + Eq phối hợp: nếu eq đọc id, hash cũng chỉ hash id — giữ axiom, tránh bug key biến mất.
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Khai báo struct Color { r: u8, g: u8, b: u8 } và derive sao cho có thể dùng làm HashSet<Color>. Viết main insert 3 màu, kiểm tra contains với một màu trùng giá trị nhưng tạo từ literal khác.
  2. Vì sao đoạn sau không compile? Cách sửa ngắn nhất?
    #[derive(PartialEq, Eq, Hash)]
    struct Product { name: String, price: f64 }
  3. Cho struct Email(String). So sánh hai email phải case-insensitive ("[email protected]" == "[email protected]"). Viết tay impl PartialEq, impl Eq, impl Hash sao cho cả ba nhất quán với nhau (axiom).
  4. Giải thích vì sao chỉ override impl PartialEq for User mà quên override Hash sẽ làm HashMap<User, _> hoạt động sai. Mô tả luồng dữ liệu chi tiết (bucket, collision).
  5. NaN có bao nhiêu "bit pattern" khác nhau trong f64? Đoạn code f64::NAN == f64::NAN trả về gì? Còn f64::NAN.is_nan() trả về gì? Vì sao Rust chọn cách thứ hai để kiểm tra NaN?
Đáp án
  1. use std::collections::HashSet;
    
    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
    struct Color { r: u8, g: u8, b: u8 }
    
    fn main() {
        let mut s: HashSet<Color> = HashSet::new();
        s.insert(Color { r: 255, g: 0, b: 0 });
        s.insert(Color { r: 0, g: 255, b: 0 });
        s.insert(Color { r: 0, g: 0, b: 255 });
        let red = Color { r: 255, g: 0, b: 0 };
        assert!(s.contains(&red));   // true — derive giữ axiom
    }
  2. f64 không impl Eq (do NaN != NaN) → không thể derive Eq cho Product. Sửa: đổi price: f64 sang price_cents: u64 (lưu 199 cents thay vì 1.99 USD) — tiền tệ vốn nên là integer, vừa tránh sai số float vừa qua được Eq + Hash.
  3. use std::hash::{Hash, Hasher};
    
    struct Email(String);
    
    impl PartialEq for Email {
        fn eq(&self, other: &Self) -> bool {
            self.0.to_lowercase() == other.0.to_lowercase()
        }
    }
    impl Eq for Email {}
    
    impl Hash for Email {
        fn hash<H: Hasher>(&self, state: &mut H) {
            // hash trên lowercase — khớp với eq
            self.0.to_lowercase().hash(state);
        }
    }
  4. HashMap hai bước: (1) hash(key) chọn bucket; (2) trong bucket dùng == tìm chính xác. Nếu u1 == u2 (theo id) nhưng hash(u1) != hash(u2) (vì derive hash full struct), hai key rơi vào hai bucket khác — insert(u1) đặt entry ở bucket A, get(&u2) tìm ở bucket B → không thấy → trả None. Bug "key biến mất" mà compile không hề báo gì.
  5. f64 dài 64 bit; theo IEEE-754, NaN ứng với mọi bit pattern có exponent toàn 1 và mantissa khác 0 — tức hàng ngàn tỷ bit pattern khác nhau đều là NaN. Vì vậy nan == nan trả false theo chuẩn (không thể bằng nhau theo bit pattern và cũng không nên bằng nhau theo ngữ nghĩa "không xác định"). Phải dùng x.is_nan() — method này check pattern (exponent + mantissa) một cách an toàn, không phụ thuộc operator ==.
12

Bài Tiếp Theo

Bài 164: PartialOrd, Ord — Ordering Traits — bộ đôi tương tự cho thứ tự: PartialOrd sinh <, >, <=, >= trả Option<Ordering> (vì NaN không so sánh được); Ord total order trả thẳng Ordering, dùng cho BTreeMap key và sort(). Float lại không impl Ord cùng lý do với Eq — và bạn sẽ thấy bộ equality (Eq) + ordering (Ord) đi với nhau rất chặt.

Bài này khép cụm "equality": ba trait PartialEq, Eq, Hash tưởng đơn giản nhưng nắm chắc axiom giúp bạn tránh nguyên một lớp bug ngấm ngầm trong HashMap. Hai bài sau (164-165) hoàn tất bộ "comparison + default" cho mọi struct mới.