Đâ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ì khi viết sai, hậu quả không phải là ngoại lệ hay chương trình sập. Hậu quả là dữ liệu lặng lẽ biến mất, và không có dòng log nào cho bạn biết.
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 đó.
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.
Đâ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.
Đâ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.
Thử ba mươi giây
Lấy một lớp trong dự án của bạn đang được dùng làm khoá HashMap hoặc cho vào HashSet. Kiểm hai điều:
Nó có ghi đè cả equals và hashCode không?
Mọi trường xuất hiện trong equals có phải final không?
Nếu câu trả lời 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.
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.