Hình dung một tủ hồ sơ khổng lồ. Muốn tìm nhanh một tài liệu, bạn không lật từng tờ — bạn nhìn chữ cái đầu của tên nó để biết ngăn nào, mở đúng ngăn đó ra, rồi mới đọc tên để chắc là đúng tờ cần. hashCode() là "chữ cái đầu quyết định ngăn"; equals() là "đọc tên để xác nhận". HashMap làm đúng hai bước ấy. Và toàn bộ bài hôm nay là chuyện gì xảy ra khi hai bước đó không ăn khớp — hậu quả không phải chương trình sập, mà là dữ liệu lặng lẽ biến mất, không một dòng log.
Đây là bài mà tôi cho là quan trọng nhất trong cả chặng hướng đối tượng — không phải vì equals và hashCode khó viết (IDE sinh giúp trong ba giây), mà vì viết sai thì hỏng theo cách không ai thấy được.
Ta bắt đầu bằng ba mức độ hỏng, từ nhẹ tới nặng.
Mức 1: có equals, quên hashCode
static class ThieuHash {
final String ma;
ThieuHash(String ma) { this.ma = ma; }
@Override public boolean equals(Object o) {
return o instanceof ThieuHash t && ma.equals(t.ma);
}
// không có hashCode
}
equals nói bằng nhau : true
nhưng get() trả về : null
Set chứa 2 phần tử giống nhau, size = 2
Hai đối tượng equals bằng nhau, nhưng HashMap không tìm ra, và HashSet giữ cả hai như hai phần tử khác nhau.
Lý do nằm ở cách HashMap làm việc. Nó không so sánh khoá của bạn với mọi khoá trong map — như thế thì chậm. Nó dùng hashCode() để tính ra một cái ngăn, rồi chỉ so sánh trong ngăn đó. Đúng cái tủ hồ sơ: nhìn chữ cái đầu để chọn ngăn.
Không ghi đè hashCode thì bản mặc định của Object được dùng, mà bản đó dựa trên danh tính đối tượng. Hai đối tượng khác nhau gần như chắc chắn cho hai mã băm khác nhau, rơi vào hai ngăn khác nhau, và equals không bao giờ có cơ hội được gọi — như thể hai tờ giấy ghi cùng một tên nhưng bị xếp vào hai ngăn khác hẳn.
Đây là quy tắc quan trọng nhất trong cả bài:
equals thì phải ghi đè hashCode. Luôn luôn, không có ngoại lệ. Hai đối tượng bằng nhau bắt buộc phải có cùng mã băm.
Chiều ngược lại thì không bắt buộc: hai đối tượng cùng mã băm không nhất thiết bằng nhau.
"Aa".hashCode() = 2112
"BB".hashCode() = 2112
hai khoá cùng mã băm, map size = 2, get("BB") = 2
"Aa" và "BB" có cùng mã băm — một ví dụ va chạm nổi tiếng trong String. Nhưng HashMap vẫn hoạt động đúng, vì sau khi tìm ra ngăn nó còn dùng equals để phân biệt. Va chạm chỉ làm chậm, không làm sai.
Mức 2: viết đúng cả hai
@Override public boolean equals(Object o) {
return o instanceof Dung d && Objects.equals(ma, d.ma);
}
@Override public int hashCode() {
return Objects.hash(ma);
}
get() trả về : giá trị
Set size = 1
Ba điểm trong tám dòng đó:
instanceof Dung d vừa kiểm kiểu vừa khai biến đã ép — cú pháp từ Java 16 mà ta gặp ở bài đa hình. Nó cũng xử lý luôn trường hợp o là null, vì null instanceof bất cứ gì đều là false.
Objects.equals(a, b) an toàn với null: hai null thì bằng nhau, một null thì không, không có null thì gọi a.equals(b).
Objects.hash(...) nhận nhiều trường và trộn chúng lại. Với một trường duy nhất, ma.hashCode() nhanh hơn vì Objects.hash phải tạo một mảng — chi tiết chỉ đáng quan tâm khi lớp này làm khoá trong vòng lặp nóng.
Quy tắc vàng: dùng đúng những trường nào trong equals thì dùng đúng những trường ấy trong hashCode. Lệch nhau là hợp đồng gãy.
Mức 3: khoá đổi được — chỗ dữ liệu thật sự biến mất
Đây là phần đáng sợ nhất. Lớp viết hoàn toàn đúng equals và hashCode, nhưng trường dùng để so sánh lại thay đổi được:
Map<DoiDuoc, String> m3 = new HashMap<>();
DoiDuoc k = new DoiDuoc("A");
m3.put(k, "giá trị");
k.ma = "B"; // đổi khoá sau khi đã put
get(chính đối tượng đó) : null
get(new DoiDuoc("B")) : null
map.size() : 1
containsValue("giá trị"): true
duyệt map thì vẫn thấy : B -> giá trị
Đọc kỹ bốn dòng này.
Lấy bằng chính đối tượng đã dùng làm khoá — null. Lấy bằng một khoá mới bằng nó — cũng null. Nhưng map vẫn báo size = 1, containsValue vẫn thấy, và duyệt map vẫn in ra được.
Dữ liệu vẫn nằm trong map, không cách nào lấy ra bằng khoá.
Vì sao: lúc put, mã băm của "A" quyết định phần tử nằm ở ngăn số 3 (chẳng hạn). Sau khi đổi thành "B", mã băm thành một số khác, trỏ sang ngăn số 7. get đi tìm ở ngăn 7 và không thấy gì. Ngăn 3 vẫn giữ phần tử, nhưng không ai đi tìm ở đó nữa. Đúng như bạn xếp một tờ hồ sơ vào ngăn "A" rồi bôi tên nó thành "B": tờ giấy vẫn nằm trong tủ, nhưng bạn tìm ở ngăn "B" thì mãi không ra.
Đây là kiểu lỗi tôi từng mất cả buổi để tìm, và nó thường xuất hiện dưới dạng "cache thỉnh thoảng miss mà không hiểu vì sao" hoặc "phần tử trong Set tự nhân đôi".
Cách tránh, xếp theo mức độ tôi khuyên dùng:
Dùng khoá bất biến. String, Integer, record, hoặc lớp có mọi trường final. Đây là lý do String được thiết kế bất biến ngay từ đầu.
Nếu lớp buộc phải đổi được, chỉ dùng những trường không đổi trong equals — thường là một mã định danh do hệ thống cấp.
Đừng bao giờ sửa đối tượng đang làm khoá trong map hay phần tử trong set. Nếu phải sửa, lấy ra trước, sửa, rồi cho vào lại.
Hợp đồng năm điều khoản
Tài liệu của Object.equals quy định năm điều. Chúng nghe như toán học nhưng mỗi điều đều bảo vệ một thứ cụ thể:
Phản xạ — x.equals(x) phải đúng. Phá điều này thì list.contains(x) trả về false dù x đang nằm trong danh sách.
Đối xứng — x.equals(y) đúng thì y.equals(x) cũng phải đúng. Đây là điều bị phá nhiều nhất, và thường do kế thừa (xem mục sau).
Bắc cầu — x.equals(y) và y.equals(z) thì x.equals(z).
Nhất quán — gọi nhiều lần cho cùng kết quả, miễn là đối tượng không đổi.
Không bằng null — x.equals(null) luôn false, và không được ném ngoại lệ.
Cộng thêm hợp đồng của hashCode: bằng nhau thì cùng mã băm, và mã băm phải nhất quán trong suốt thời gian đối tượng không đổi.
Điều khoản đối xứng và bài toán kế thừa
Giả sử Diem có x, y; DiemMau kế thừa Diem và thêm màu:
Diem a = new Diem(1, 2);
DiemMau b = new DiemMau(1, 2, "đỏ");
a.equals(b); // true nếu Diem.equals chỉ so x, y
b.equals(a); // false vì DiemMau còn so cả màu
Đối xứng gãy. Và hậu quả rất khó chịu: list.contains() cho kết quả khác nhau tuỳ thứ tự bạn viết.
Không có cách nào vừa giữ được đối xứng vừa cho lớp con thêm trường vào equals — đây là một hạn chế thật sự của kế thừa, không phải lỗi lập trình. Hai lối thoát:
Dùng getClass() thay vì instanceof trong equals. Khi đó Diem và DiemMau không bao giờ bằng nhau, đối xứng được giữ. Đổi lại, nó phá nguyên tắc thay thế Liskov: một DiemMau không dùng thay Diem được ở mọi chỗ nữa.
Dùng thành phần thay cho kế thừa — đúng bài học của phần trước. DiemMau giữ một Diem bên trong thay vì kế thừa nó.
Đây chính là lý do sách vở khuyên: lớp có equals thì nên là final, hoặc là record.
record giải quyết gần hết
record Diem(int x, int y) {}
record sinh sẵn equals, hashCode và toString dựa trên toàn bộ các trường thành phần, và mọi trường đều final. Ba vấn đề của bài này biến mất cùng lúc: không quên hashCode, không lệch trường, không đổi được khoá.
Với lớp thuần dữ liệu — DTO, khoá tổng hợp, giá trị trong miền nghiệp vụ — record gần như luôn là lựa chọn đúng. Ta sẽ nói kỹ ở bài riêng.
Vài lưu ý khi tự viết
Đừng đưa trường tính toán được vào equals. Nếu tongTien luôn bằng donGia * soLuong thì so sánh nó là thừa, và nguy hiểm nếu có lúc chưa kịp tính lại.
Cẩn thận với mảng. Arrays.equals chứ không phải equals, như bài về mảng đã chỉ ra. Objects.hash với tham số là mảng cũng cho mã băm theo địa chỉ — dùng Arrays.hashCode.
Với số thực, dùng Double.compare. 0.0 == -0.0 là true nhưng Double.valueOf(0.0).equals(-0.0) là false, và NaN != NaN trong khi equals lại coi hai NaN bằng nhau. Objects.equals trên Double xử lý đúng theo equals, nên hãy nhất quán.
Để IDE sinh, rồi đọc lại. Sinh máy móc rồi quên là cách tốt nhất để có một equals so cả trường createdAt — khiến hai bản ghi cùng nội dung mà khác thời điểm tạo lại không bằng nhau.
Muốn tự soi trong ba mươi giây: lấy một lớp trong dự án đang được dùng làm khoá HashMap hoặc cho vào HashSet, rồi kiểm hai điều — nó có ghi đè cả equals lẫn hashCode không, và mọi trường xuất hiện trong equals có phải final không. Nếu câu thứ hai là "không", bạn đang có một quả bom hẹn giờ: nó chỉ nổ vào ngày có ai đó sửa đối tượng sau khi đã đưa vào map.
Mẫu số chung
Cái tủ hồ sơ hai bước "chọn ngăn rồi xác nhận tên" không phải chuyện riêng của Java — mọi cấu trúc bảng băm ở mọi ngôn ngữ đều dựng trên đúng nó, nên đúng hợp đồng ấy theo bạn đi khắp nơi. Python bắt bạn định nghĩa __eq__ và __hash__ cùng nhau, y hệt; và nó chặn thẳng cái bẫy Mức 3 bằng thiết kế — một list không băm được, nên bạn không thể dùng vật thể đổi-được làm khoá dict, ngôn ngữ không cho. C# có Equals/GetHashCode cùng hợp đồng đó, và record của nó cũng giải quyết gọn như Java. C++ đòi bạn cung cấp cả operator== lẫn một chuyên biệt hoá std::hash thì mới bỏ được vật thể vào unordered_map. Go đi xa nhất về phía an toàn: khoá map phải "so sánh được", struct thì runtime tự so từng trường và tự băm — không có hashCode để bạn viết sai — và bạn không thể dùng slice làm khoá, đúng tinh thần "khoá phải bất biến".
Điểm chung, và là thứ đáng mang theo: bất cứ khi nào một cấu trúc dùng băm để tìm nhanh, nó dựa vào hai lời hứa — bằng nhau thì cùng mã băm, và mã băm không đổi khi vật còn nằm trong đó. Phá lời hứa thứ nhất (quên hashCode) thì vật giống nhau nằm hai ngăn; phá lời hứa thứ hai (khoá đổi được) thì vật kẹt trong ngăn cũ không ai tìm tới. Cả hai đều hỏng lặng lẽ, không exception, không log — chỉ có dữ liệu như bốc hơi. Vài ngôn ngữ cưỡng chế để bạn không phá được (Python cấm list làm khoá, Go cấm slice); Java thì tin bạn, và cái giá của lòng tin đó là bạn phải tự nhớ: khoá của một bảng băm phải bất biến. Đó là lý do một dòng record thay cho một lớp đổi-được không chỉ ngắn hơn — nó xoá sạch cả một họ lỗi.
Ngày mai: toString, Comparable và Comparator — sắp xếp tự nhiên khác sắp xếp theo yêu cầu, và một cái bẫy khi compareTo không nhất quán với equals.