99 Ngày Java — Ngày 24: equals, hashCode & toString
Ngày 23 khép lại với lời hẹn xuống nền móng của mọi đối tượng — hôm nay ta đến với bộ ba contract của Object: equals, hashCode và toString. Ba method này có sẵn trong mọi lớp, nhưng phiên bản mặc định chỉ hiểu danh tính tham chiếu — còn HashMap, HashSet thì đặt trọn niềm tin vào việc bạn cài chúng đúng. Bài hôm nay đi qua contract của từng method, cách cài chuẩn với Objects utility, và hai cái bẫy khiến phần tử "mất tích" ngay trong map.

equals — từ danh tính tham chiếu đến bằng nhau nghiệp vụ
// Mặc định, equals của Object so sánh THAM CHIẾU — hệt như ==:
SanPham a = new SanPham("SP-001", "Bàn phím cơ");
SanPham b = new SanPham("SP-001", "Bàn phím cơ");
a.equals(b); // false?! — hai đối tượng khác nhau trên heap
// Muốn "bằng nhau theo nghiệp vụ" (cùng mã sản phẩm) phải override:
@Override
public boolean equals(Object o) {
if (this == o) return true; // 1. chính nó — lối tắt nhanh nhất
if (!(o instanceof SanPham sp)) return false; // 2. pattern matching (Ngày 15)
return ma.equals(sp.ma); // 3. so sánh field ĐỊNH DANH
}
// Contract 5 điều: phản xạ · đối xứng · bắc cầu · nhất quán ·
// và x.equals(null) luôn false — instanceof lo sẵn điều cuối:
// o là null thì instanceof trả false, không bao giờ NPEBản mặc định của
equalschính là==— hữu ích khi "bằng" nghĩa là cùng một đối tượng, nhưng đa số kiểu dữ liệu nghiệp vụ cần "bằng" theo giá trị định danh: mã sản phẩm, số CMND, slug bài viết.Ba bước cài chuẩn: lối tắt
this == o, kiểm tra kiểu bằng pattern matching (Ngày 15) — gọn hơn hẳn cặpgetClass/ép kiểu thủ công, và so sánh đúng bộ field định danh — không lôi mọi field vào.Contract 5 điều nghe hàn lâm nhưng vi phạm là có thật: đối xứng vỡ khi so sánh chéo kiểu cha–con, nhất quán vỡ khi equals dựa trên field thay đổi liên tục — cả hai đều làm collection hành xử khó lường.
hashCode — người bạn đồng hành bắt buộc
// Quy tắc VÀNG: override equals thì BẮT BUỘC override hashCode —
// hai đối tượng equals nhau PHẢI trả cùng một hashCode:
@Override
public int hashCode() {
return Objects.hash(ma); // đúng bộ field của equals — không hơn, không kém
}
// Chiều ngược lại KHÔNG bắt buộc: cùng hashCode mà không equals
// vẫn hợp lệ — đó chỉ là va chạm (collision), HashMap xử lý được.
// Vi phạm thì sao? Giả sử equals so theo "ma", hashCode trộn cả "ten":
// → hai đối tượng "bằng nhau" cho ra hai hash khác nhau
// → rơi vào hai bucket khác nhau trong HashMap
// → lỗi IM LẶNG: contains trả false, phần tử "trùng" lọt vào Set,
// cache tra không thấy — không có exception nào chỉ đườngContract liên kết hai method: equals nhau thì hashCode phải bằng nhau — cách tuân thủ đơn giản nhất là cho
Objects.hashnhận đúng bộ field mà equals dùng, thêm bớt một field là contract vỡ.Chiều ngược lại được phép: hai đối tượng khác nhau chung một hash chỉ là collision — làm bucket đông hơn, tra cứu chậm đi chút, nhưng không sai; Ngày 34 sẽ xem HashMap treeify bucket đông thế nào.
Lỗi vi phạm contract không ném exception — nó chỉ lặng lẽ làm
containstrả false,Setchứa "bản sao", cache luôn miss: loại lỗi tốn nhiều giờ debug nhất vì code trông hoàn toàn hợp lý.
Vai trò trong HashMap — hai bước tra cứu
// HashMap tìm phần tử qua HAI bước — hai contract cùng tham gia:
// Bước 1: hashCode() → chọn bucket (nhảy thẳng, không duyệt)
// Bước 2: equals() → so từng phần tử trong bucket đó
Map<SanPham, Integer> tonKho = new HashMap<>();
tonKho.put(new SanPham("SP-001", "Bàn phím cơ"), 12);
// Quên override hashCode → dùng bản mặc định theo danh tính:
tonKho.get(new SanPham("SP-001", "Bàn phím cơ")); // null! — sai bucket từ bước 1
// Bẫy thứ hai: SỬA key sau khi put
SanPham key = new SanPham("SP-002", "Chuột");
tonKho.put(key, 5);
key.setMa("SP-999"); // hash đổi → map vẫn giữ nó ở bucket theo hash CŨ
tonKho.get(key); // null! — "mất tích" ngay bên trong map
// → key của HashMap nên BẤT BIẾN (bài học Ngày 20, đào sâu Ngày 29)Tốc độ O(1) của
HashMapđến từ bước 1: hash nhảy thẳng tới bucket thay vì duyệt tuần tự — nhưng chính vì thế, hash sai là tìm sai chỗ ngay từ đầu,equalschuẩn đến đâu cũng không cứu được.Bẫy key bị sửa sau khi
putnguy hiểm ở chỗ map không hề biết: entry vẫn nằm ở bucket theo hash cũ, mọi phépget/removetheo hash mới đều trượt — đối tượng chiếm bộ nhớ nhưng không thể chạm tới.Hai bài học gộp một: key nên là kiểu bất biến (
String, record, enum — Ngày 23 vừa nói enum còn đượcEnumMapưu ái riêng), và mọi kiểu định làm key phải cài đủ cặp equals/hashCode.
toString & bộ tiện ích Objects
// toString mặc định: vn.motdev.SanPham@1b6d3586 — vô dụng khi debug
@Override
public String toString() {
return "SanPham[ma=%s, ten=%s]".formatted(ma, ten);
}
// log, debugger, String.valueOf, nối chuỗi — tất cả tự gọi toString
// Bộ tiện ích Objects — null-safe cho cả ba contract:
Objects.equals(a, b); // an toàn khi a là null — không NPE
Objects.hash(ma, ten); // gộp nhiều field thành một hash
Objects.toString(x, "N/A"); // fallback khi x null
Objects.requireNonNull(ma, "ma không được null"); // fail-fast trong constructor
// Và record (Ngày 20) sinh sẵn CẢ BỘ BA từ các component —
// thêm một lý do để dữ liệu bất biến chọn record thay class thườngtoStringkhông có contract chặt như hai method kia, nhưng là bề mặt debug của lớp: log và debugger gọi nó liên tục — một dòng override tiết kiệm hàng giờ đọc mã băm vô nghĩa; lưu ý đừng nhét dữ liệu nhạy cảm (mật khẩu, token) vào đây.Lớp
Objectsđáng dùng ở mọi nơi:Objects.equalsné NPE khi so sánh field có thể null,requireNonNullbiến lỗi tiềm ẩn thành fail-fast ngay constructor — triết lý gặp từ Ngày 21 của series Spring.Với dữ liệu bất biến, record (Ngày 20) sinh tự động cả ba method từ các component — đúng chuẩn contract; chỉ khi "bằng nhau" khác "mọi component bằng nhau" mới cần tự override lại.
Bài tập nhỏ
Cài
equals/hashCode/toStringcho lớpSanPham(định danh theoma) — viết test: hai đối tượng cùng mã phải equals nhau và cùng hashCode.Cố tình xóa
hashCodeoverride rồi đưa đối tượng vàoHashSet— quan sát "bản sao" lọt vào set như thế nào, sau đó khôi phục và so kết quả.Tái hiện bẫy key bị sửa:
putmột key mutable vàoHashMap, đổi field định danh, thửget— rồi giải thích vì saomap.size()vẫn đếm đủ.Chuyển
SanPhamthành record và xóa cả ba override — chạy lại test ở bài 1 để kiểm chứng record sinh đúng contract.
Kết luận
Bộ contract của Object gói trong bốn ý: equals chuyển "bằng nhau" từ tham chiếu sang định danh nghiệp vụ với contract 5 điều, hashCode phải đồng bộ tuyệt đối với equals vì HashMap tra cứu hai bước hash-rồi-equals, key nên bất biến để hash không đổi sau khi put, và Objects utility + record giúp cài chuẩn ít dòng nhất. Ngày 25 ta đổi nhịp với hai công cụ viết method linh hoạt: varargs và đệ quy — quy tắc tham số biến thiên và nghệ thuật gọi chính mình. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


