Java — Từ Zero đến Senior/OOP cơ bản — class, object, encapsulation/`toString`, `equals`, `hashCode` — ba method phải override đúng
5/7
~20 phútOOP cơ bản — class, object, encapsulation

`toString`, `equals`, `hashCode` — ba method phải override đúng

Override Object.toString để debug dễ, equals/hashCode đúng contract Java, Objects.equals & Objects.hash, và vì sao HashMap hỏng khi bạn override equals mà quên hashCode.

Mọi class trong Java ngầm kế thừa từ java.lang.Object. Object có 3 method nổi tiếng mà bạn sẽ override sớm hay muộn: toString(), equals(), hashCode(). Không override → class bạn viết hành xử khác mong đợi: in ra User@7a3c2d, không so sánh được nội dung, bỏ vào HashSet thành 2 entry trùng.

Bài này giải thích 3 method, contract Java quy định, vì sao override equals mà quên hashCode là bug rất phổ biến, và dùng Objects.equals/hash để viết nhanh và đúng.

1. Analogy — thẻ căn cước

Bạn có 2 người tên "Nguyễn Văn A" sinh cùng ngày. Hai thẻ căn cước của họ mô tả (toString) nhìn giống nhau đến 90%. Nhưng để xác định "đây có phải cùng một người?" (equals), hệ thống so số căn cước — duy nhất. Và để tra cứu nhanh trong cơ sở dữ liệu (hashCode), hệ thống băm số căn cước thành một index.

Đời thườngJava
Thông tin in trên thẻ để đọctoString()
So số căn cước để biết cùng ngườiequals()
Băm để tra cứu nhanh trong filehashCode()

💡 💡 Cách nhớ

toString = debug print. equals = so nội dung. hashCode = index để hash table dùng. Hai cái sau đi đôi — không ai chỉ override một.

2. toString — chuỗi mô tả object

Default Object.toString() trả ClassName@hexHashCode:

public class Book { String title; int year; }
System.out.println(new Book());   // Book@1540e19d — vo nghia

Override để dễ debug, đẹp log:

public class Book {
    String title;
    int year;

    @Override
    public String toString() {
        return "Book{title='" + title + "', year=" + year + "}";
    }
}

System.out.println(new Book());   // Book{title='null', year=0}

@Override annotation — không bắt buộc nhưng nên có: nếu bạn gõ sai tên (vd toStirng), compiler bắt ngay. Không @Override → Java hiểu bạn khai method mới, không phải override → bug im lặng.

2.1 Trình bày

Convention phổ biến:

ClassName{field1=value1, field2=value2, ...}

Hoặc dùng String.format / text block:

@Override
public String toString() {
    return String.format("Book{title='%s', year=%d}", title, year);
}

Với class nhiều field, cân nhắc library helper như StringBuilder hoặc annotation (Lombok @ToString) — dù module này không nhập library ngoài.

2.2 Khi toString được gọi ngầm

  • System.out.println(obj) → gọi obj.toString().
  • String concat: "name=" + obj → gọi obj.toString().
  • Debugger / IDE hiển thị object.
  • Logger: log.info("processing {}", order) — tuỳ logger implementation gọi ngầm.

Kết quả: toString tốt làm mọi bug dễ debug. Viết tốn 3 phút, tiết kiệm 3 giờ săn bug.

3. equals — so nội dung

Default Object.equals() = this == other — so địa chỉ, gần như luôn false với 2 object khác nhau dù nội dung giống.

Book b1 = new Book("Java", 2018);
Book b2 = new Book("Java", 2018);
System.out.println(b1.equals(b2));   // false — default equals

Override để so nội dung:

@Override
public boolean equals(Object o) {
    if (this == o) return true;                 // cung object
    if (!(o instanceof Book other)) return false;  // kieu khac hoac null
    return year == other.year && Objects.equals(title, other.title);
}

Rule (contract) JLS quy định equals phải thoả:

  1. Reflexive: x.equals(x) == true.
  2. Symmetric: x.equals(y) == true iff y.equals(x) == true.
  3. Transitive: x.equals(y) && y.equals(z)x.equals(z) == true.
  4. Consistent: gọi nhiều lần cùng kết quả (với object không đổi).
  5. Non-null: x.equals(null) == false.

Viết sai contract → HashSet, HashMap, List.contains, Arrays.equals hành xử ngẫu nhiên. Contract này không phải "best practice" — là yêu cầu của spec.

3.1 Viết equals đúng — dùng Objects.equals

Objects.equals(a, b) xử lý null an toàn:

import java.util.Objects;

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Book other)) return false;
    return year == other.year
        && Objects.equals(title, other.title)
        && Objects.equals(author, other.author);
}

Pattern này:

  • this == o — fast path cho cùng object.
  • instanceof Type var (Java 16+ pattern matching) — kiểm kiểu + bind trong 1 dòng.
  • Objects.equals — null-safe so sánh.

4. hashCode — PHẢI override cùng với equals

Contract: 2 object equal phải có cùng hashCode.

x.equals(y) == true  IMPLIES  x.hashCode() == y.hashCode()

(Ngược lại không đảm bảo — 2 object cùng hash không nhất thiết equal; đó là collision bình thường.)

Default Object.hashCode() dựa vào địa chỉ — 2 object có nội dung giống vẫn hash khác. Override equals mà quên hashCode → vi phạm contract → bug tinh vi:

class Book {
    String title;
    public Book(String title) { this.title = title; }

    @Override
    public boolean equals(Object o) {
        return o instanceof Book b && Objects.equals(title, b.title);
    }
    // KHONG override hashCode
}

Set<Book> s = new HashSet<>();
s.add(new Book("Java"));
System.out.println(s.contains(new Book("Java")));   // false!

HashSet.contains hash object mới → lookup bucket đó → không tìm thấy vì new Book("Java") cho hash khác với book đã add. Tưởng bug ngẫu nhiên, thực chất vi phạm contract.

4.1 Viết hashCode đúng — dùng Objects.hash

@Override
public int hashCode() {
    return Objects.hash(title, author, year);
}

Objects.hash(...) kết hợp các field bằng công thức chuẩn, ổn định, null-safe. Chỉ cần truyền đúng bộ field giống với equals dùng.

⚠️ ⚠️ Phải dùng CÙNG bộ field cho equals và hashCode

Nếu equals so 3 field title/author/year, hashCode cũng phải hash 3 field đó. Khác bộ → contract vỡ.

// SAI
public boolean equals(Object o) { ...so 3 field... }
public int hashCode() { return title.hashCode(); }   // chi 1 field -> van dung contract

// Nhung neu nguoc lai:
public boolean equals(Object o) { ...so 2 field title, author... }
public int hashCode() { return Objects.hash(title, author, year); }
// Hai book khac year nhung cung title+author -> equals = true, hashCode khac -> BUG

5. IDE generate — dùng nhưng hiểu

Mọi IDE (IntelliJ, Eclipse, VSCode) có "Generate → equals and hashCode". Chọn các field cần so, IDE sinh code chuẩn. Dùng thoải mái, nhưng hiểu code sinh ra để debug khi cần.

Output IDE IntelliJ:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Book book = (Book) o;
    return year == book.year && Objects.equals(title, book.title);
}

@Override
public int hashCode() {
    return Objects.hash(title, year);
}

Một khác biệt so với ví dụ trên: getClass() != o.getClass() thay vì instanceof. Lý do liên quan đến kế thừa và symmetric — instanceof có thể vi phạm symmetric với subclass. getClass cứng hơn. Chi tiết bàn ở module kế thừa.

6. Record — Java 16+ tự sinh đủ 3 method

public record Book(String title, String author, int year) { }

Record tự sinh:

  • Constructor canonical Book(String, String, int).
  • Accessor title(), author(), year() (không phải getTitle).
  • equals, hashCode, toString chuẩn, dựa trên mọi component.
Book b1 = new Book("Java", "Bloch", 2018);
Book b2 = new Book("Java", "Bloch", 2018);
System.out.println(b1);               // Book[title=Java, author=Bloch, year=2018]
System.out.println(b1.equals(b2));    // true
System.out.println(b1.hashCode() == b2.hashCode());   // true

Bài sau sẽ đào sâu record. Hiện tại nhớ: nếu class là data holder thuần (immutable, so content), record thay được cả 4 method và constructor.

7. Khi nào KHÔNG override equals/hashCode?

  • Identity-based object — mỗi instance là duy nhất về bản chất, không so nội dung. Vd Thread, ServerSocket, EventLoop. Default = địa chỉ là đúng.
  • Entity có ID — 2 entity cùng id bằng nhau. Override nhưng chỉ dựa vào id, không so field khác. (Đặc biệt quan trọng với JPA entity — contract riêng).

8. Pitfall tổng hợp

Nhầm 1: Override equals quên hashCode. ✅ Luôn override cặp. IDE "Generate equals and hashCode" sinh luôn cả 2.

Nhầm 2: equals parameter type sai.

public boolean equals(Book other) { ... }   // overload, KHONG override Object.equals

public boolean equals(Object o) — parameter phải Object. Luôn dùng @Override — compiler bắt sai signature.

Nhầm 3: Dùng == cho String / wrapper trong equals.

return title == other.title;   // so dia chi — vo voi String tao bang new

Objects.equals(title, other.title).

Nhầm 4: hashCode trả constant (vd return 1;).

public int hashCode() { return 1; }   // dung contract nhung HashMap O(n) — kham kho

✅ Tuân contract không đủ — phải phân bố đều. Dùng Objects.hash(fields).

Nhầm 5: toString include field nhạy cảm.

public String toString() { return "User{email=" + email + ", password=" + password + "}"; }

✅ Loại field như password, token, credit card khỏi toString. Log là nơi thường xuyên dump object.

9. 📚 Deep Dive Oracle

ℹ️ 📚 Deep Dive Oracle (optional)

Spec / reference chính thức:

Ghi chú: Item 10 của Effective Java là bản spec "giải thích đời" cho equals contract — đọc được 20 phút và tiết kiệm cả năm debug. Bloch thiết kế cả Objects.hash và record trong JDK, nên hiểu sâu nhất.

10. Tóm tắt

  • Object có 3 method thường override: toString, equals, hashCode.
  • Default toString trả ClassName@hex — vô nghĩa; override để debug dễ.
  • Default equals so địa chỉ; override để so nội dung.
  • equals phải tuân 5 rule: reflexive, symmetric, transitive, consistent, non-null.
  • Override equalsbắt buộc override hashCode với cùng bộ field. Vi phạm → HashSet/HashMap hành xử sai.
  • Dùng Objects.equals(a, b)Objects.hash(fields...) để viết nhanh và đúng.
  • @Override annotation bắt lỗi nếu signature sai (đặc biệt equals hay bị overload).
  • Record (Java 16+) tự sinh đủ 3 method cho class data-holder — giảm boilerplate.
  • Loại field nhạy cảm (password, token) khỏi toString.

11. Tự kiểm tra

Tự kiểm tra
Q1
Đoạn sau có 2 bug. Chỉ ra và sửa:
public class Book {
    String title;
    int year;
    public Book(String title, int year) { this.title = title; this.year = year; }

    public boolean equals(Book other) {
        return this.title == other.title && this.year == other.year;
    }
}

Bug 1 — signature sai: equals(Book other) nhận Book, không phải Object. Đây là overload, không override Object.equals. HashSet/HashMap vẫn dùng default equals(Object) → so địa chỉ → hành xử sai. Thiếu @Override annotation — nếu có, compiler sẽ bắt ngay.

Bug 2 — so String bằng ==: this.title == other.title so reference. Hai Book có cùng nội dung "Java" nhưng tạo từ new String("Java") sẽ cho địa chỉ khác.

Bonus bug 3: quên override hashCode — sẽ vi phạm contract nếu dùng trong HashSet/HashMap.

Fix:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Book other)) return false;
    return year == other.year && Objects.equals(title, other.title);
}

@Override
public int hashCode() {
    return Objects.hash(title, year);
}
Q2
Đoạn sau in gì?
Set<Book> s = new HashSet<>();
s.add(new Book("Java", 2018));
System.out.println(s.contains(new Book("Java", 2018)));

(Giả sử Book.equals đã override đúng nhưng KHÔNG override hashCode.)

In false — hầu như chắc chắn.

HashSet.contains gọi hashCode() trước để tìm bucket, rồi mới dùng equals trong bucket đó. hashCode default (của Object) dựa vào địa chỉ → hai Book cùng nội dung có hash khác → fall vào bucket khác → contains không tìm thấy, return false.

Đây là bug class: vi phạm contract "equals → cùng hashCode". Fix: override hashCode dựa trên cùng bộ field với equals. Dùng Objects.hash(title, year).

Q3
Giải thích tại sao return 1; trong hashCode "đúng contract" mà vẫn là bug nghiêm trọng.

Contract chỉ yêu cầu: x.equals(y) => x.hashCode() == y.hashCode(). return 1 thoả — mọi object cùng hash nên kéo theo object equal cũng cùng hash.

Nhưng contract chỉ là correctness. HashMap / HashSet dựa vào phân bố đều của hash để đạt O(1) lookup. Nếu mọi object cùng hash 1 → tất cả rơi vào 1 bucket → lookup degenerate thành O(n) (linked list / tree trong bucket). 1 triệu entry → tra cứu 1 phần tử mất ~1 triệu phép so sánh equals.

Vì vậy: hash phải phân bố tốt theo nội dung. Dùng Objects.hash(fields) hoặc logic tương đương — xáo trộn bit đủ đều để hash map hoạt động hiệu quả.

Q4
Đoạn sau có vấn đề security gì?
public class User {
    String username;
    String password;
    String apiToken;
    @Override
    public String toString() {
        return "User{username='" + username + "', password='" + password + "', apiToken='" + apiToken + "'}";
    }
}

log.info("processing user {}", user);

toString include passwordapiToken — mọi lệnh log object này sẽ dump credential vào log file. Log file thường:

  • Lưu lâu dài, rotate nhưng archive.
  • Được gửi lên log aggregator (Elastic, Splunk, Datadog).
  • Truy cập được bởi nhiều người (dev, ops, SRE).
  • Backup vào S3/Drive.

→ Credential rò rỉ cho bất kỳ ai có quyền đọc log. Incident thật đã xảy ra với nhiều công ty.

Fix: loại field nhạy cảm khỏi toString, hoặc mask:

public String toString() {
    return "User{username='" + username + "', password='***', apiToken='" + mask(apiToken) + "'}";
}

private static String mask(String s) {
    return s == null ? null : (s.length() <= 4 ? "****" : s.substring(0, 4) + "****");
}

Rule chung: mọi field được đánh dấu sensitive (password, token, SSN, credit card) phải mask hoặc bỏ qua trong toString.

Q5
Viết equals, hashCode, toString cho class:
public class Point {
    double x;
    double y;
    public Point(double x, double y) { this.x = x; this.y = y; }
}
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Point other)) return false;
    return Double.compare(x, other.x) == 0
        && Double.compare(y, other.y) == 0;
}

@Override
public int hashCode() {
    return Objects.hash(x, y);
}

@Override
public String toString() {
    return "Point{x=" + x + ", y=" + y + "}";
}

Lưu ý Double.compare thay vì x == other.x: với double, NaN == NaNfalse-0.0 == +0.0true — hành vi khác contract equals. Double.compare xử lý đúng cả 2 case.

Hoặc ngắn hơn với record Java 16+:

public record Point(double x, double y) {}

Record tự sinh 3 method (dùng Double.compare cho double component).


Bài tiếp theo: Record — data class với 1 dòng