CodeGym /Các khóa học /JAVA 25 SELF /equals, hashCode, toString: tạo tự động

equals, hashCode, toString: tạo tự động

JAVA 25 SELF
Mức độ , Bài học
Có sẵn

1. Tạo tự động equals, hashCode, toString

Tại sao cần các phương thức này?

Khi làm việc với đối tượng trong Java, bạn sẽ khá nhanh gặp những bài toán lặp đi lặp lại. Đôi khi cần kiểm tra hai đối tượng có bằng nhau không. Ví dụ, để hiểu xem nó đã có trong một collection như Set hoặc Map hay chưa. Trường hợp khác, đối tượng được dùng làm khóa trong HashMap, và khi đó không thể thiếu các quy tắc so sánh đặc biệt. Và hầu như lúc nào bạn cũng muốn in đối tượng ra log hoặc màn hình sao cho kết quả không phải là thứ “loằng ngoằng” như MyClass@7b23ec81, mà là thứ gì đó có ý nghĩa.

Cho các trường hợp này, mỗi lớp trong Java đều có ba phương thức đặc biệt:

  • equals(Object o) chịu trách nhiệm kiểm tra tính bằng nhau.
  • hashCode() tạo “dấu vân tay” số cho đối tượng, cần thiết cho các collection kiểu bảng băm.
  • toString() trả về biểu diễn chuỗi thuận tiện của đối tượng, giúp việc gỡ lỗi và in ấn dễ dàng hơn nhiều.

Tại sao điều này gây “đau đầu” với các lớp thường?

Với các lớp thông thường, bạn phải tự viết tay các phương thức này. Và đây là lúc sự nhàm chán lẫn đau đầu bắt đầu. Kết quả là một đống mã khuôn mẫu chỉ làm cồng kềnh lớp. Rất dễ mắc lỗi ở đâu đó: quên so sánh một trường, tính hashCode sai và rồi gặp những bug bí ẩn. Và nếu thêm một trường mới vào lớp — bạn sẽ phải chui vào lại tất cả các phương thức này để sửa.

Ví dụ về lớp thường


public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() { return x; }
    public int y() { return y; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }

    @Override
    public int hashCode() {
        return 31 * x + y;
    }

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

Trông quen chứ? Vâng, và đây mới chỉ có hai trường! Nếu có hai mươi trường thì sao?

Record làm điều này như thế nào

Lớp record làm tất cả điều đó cho bạn. Chỉ cần khai báo:


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

Và Java SẼ TỰ sinh ra:

  • Constructor
  • Các getter (x(), y())
  • equals, hashCode, toString

Các phương thức được sinh tự động

  • equals so sánh tất cả thành phần của record theo giá trị.
  • hashCode được tính dựa trên tất cả thành phần.
  • toString trả về chuỗi dạng Point[x=1, y=2].

Hãy xem thử “trực tiếp”!


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

public class Demo {
    public static void main(String[] args) {
        Point p1 = new Point(1, 2);
        Point p2 = new Point(1, 2);

        System.out.println(p1.equals(p2)); // true
        System.out.println(p1.hashCode() == p2.hashCode()); // true
        System.out.println(p1); // Point[x=1, y=2]
    }
}

Kết quả:

true
true
Point[x=1, y=2]

Mọi thứ hoạt động đúng như mong đợi, không thừa một dòng mã nào!

2. Tại sao điều này quan trọng: collection, gỡ lỗi và tính an toàn

Hoạt động đúng trong collection

Hãy tưởng tượng bạn dùng đối tượng làm khóa trong HashMap hoặc phần tử trong HashSet. Nếu equalshashCode được triển khai sai — các collection sẽ hành xử kỳ lạ: không tìm được phần tử bạn vừa thêm, hoặc ngược lại, coi hai đối tượng khác nhau là một.

Với các lớp record, bạn có thể yên tâm: so sánh và băm luôn tính đến mọi thành phần của record (theo thứ tự chúng được khai báo).

Ví dụ: dùng record làm khóa


import java.util.HashMap;
import java.util.Map;

public class Demo {
    public static void main(String[] args) {
        record Point(int x, int y) {}

        Map<Point, String> map = new HashMap<>();
        Point p1 = new Point(3, 4);
        map.put(p1, "Hello!");

        Point p2 = new Point(3, 4);
        System.out.println(map.get(p2)); // "Hello!" — hoạt động!
    }
}

Lưu ý: p1p2 là các đối tượng khác nhau (tham chiếu khác), nhưng chứa các giá trị trường giống hệt, nên được coi là bằng nhau. Còn chi tiết về MapHashMap bạn sẽ học ở cấp độ 26 :P

Tiện lợi khi gỡ lỗi và ghi log

Thay vì thứ nhạt nhẽo Point@1a2b3c4d (như mặc định ở các lớp thường), lớp record in ra đẹp và đầy thông tin:

Point[x=3, y=4]

Điều này tiết kiệm rất nhiều thời gian khi gỡ lỗi và ghi log.

3. equals, hashCode, toString hoạt động thế nào bên trong record

Phương thức equals

Lớp record triển khai equals sao cho hai đối tượng được coi là bằng nhau nếu:

  • Chúng cùng kiểu (cùng lớp record)
  • Tất cả thành phần của chúng đều bằng nhau (== cho kiểu nguyên thủy, equals() cho đối tượng)

Ví dụ so sánh


Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Point p3 = new Point(1, 3);

System.out.println(p1.equals(p2)); // true
System.out.println(p1.equals(p3)); // false

Phương thức hashCode

Mã băm được tính dựa trên tất cả thành phần của record, thường dùng phương thức chuẩn Objects.hash(...).


System.out.println(p1.hashCode()); // Ví dụ, 994
System.out.println(p2.hashCode()); // Cũng 994
System.out.println(p3.hashCode()); // Số khác

Phương thức toString

Biểu diễn chuỗi luôn theo định dạng:

ClassName[field1=value1, field2=value2, ...]

System.out.println(p1); // Point[x=1, y=2]

4. Ghi đè equals, hashCode, toString: khi nào và như thế nào?

Đôi khi (hiếm, nhưng có) bạn cần thay đổi hành vi mặc định của các phương thức này. Ví dụ, bạn muốn toString trả về chuỗi ở định dạng khác, hoặc so sánh chỉ dựa trên một phần trường.

Lưu ý: nếu bạn ghi đè equals/hashCode, hãy làm điều đó thật cẩn trọng! Vi phạm “hợp đồng” của chúng có thể dẫn tới bug khó lần ra.

Cách ghi đè phương thức

Chỉ cần khai báo phương thức của bạn bên trong thân lớp record:


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

Point p = new Point(3, 5);
System.out.println(p); // (3; 5)

Có thể ghi đè equals/hashCode không?

Có, nhưng cực kỳ không khuyến nghị nếu bạn không chắc mình đang làm gì. Ví dụ, nếu bạn muốn so sánh chỉ theo trường x (điều này vốn đã kỳ lạ):


public record Point(int x, int y) {
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point other)) return false;
        return x == other.x;
    }

    @Override
    public int hashCode() {
        return Integer.hashCode(x);
    }
}

Point p1 = new Point(1, 2);
Point p2 = new Point(1, 999);

System.out.println(p1.equals(p2)); // true (!)

Nhưng hãy cẩn thận: nếu bạn ghi đè equals, hãy luôn ghi đè cả hashCode — nếu không các collection sẽ hoạt động không đúng.

Best practice

  • Nếu bạn không biết rõ lý do để ghi đè — đừng ghi đè!
  • Với toString — thoải mái định dạng theo ý nếu muốn.
  • Với equals/hashCode — chỉ khi có lý do thuyết phục và bạn hiểu hậu quả.

5. Thực hành: so sánh đối tượng và dùng record trong collection

Ví dụ: so sánh hai đối tượng record


public record User(String name, int age) {}

public class Demo {
    public static void main(String[] args) {
        User u1 = new User("Alice", 20);
        User u2 = new User("Alice", 20);
        User u3 = new User("Bob", 25);

        System.out.println(u1.equals(u2)); // true
        System.out.println(u1.equals(u3)); // false

        System.out.println(u1.hashCode() == u2.hashCode()); // true
        System.out.println(u1); // User[name=Alice, age=20]
    }
}

Ví dụ: dùng record làm khóa trong HashMap

Giả sử chúng ta có một ứng dụng lưu số lần ghé thăm của người dùng theo tên và tuổi của họ (biết đâu trong câu lạc bộ có hai người “Ivan 20 tuổi”).


import java.util.HashMap;
import java.util.Map;

public class Demo {
    public static void main(String[] args) {
        record User(String name, int age) {}

        Map<User, Integer> visits = new HashMap<>();
        User ivan20 = new User("Ivan", 20);
        User ivan22 = new User("Ivan", 22);

        visits.put(ivan20, 5);
        visits.put(ivan22, 2);

        // Kiểm tra việc tìm theo giá trị hoạt động chính xác
        System.out.println(visits.get(new User("Ivan", 20))); // 5
        System.out.println(visits.get(new User("Ivan", 22))); // 2
    }
}

Nếu equalshashCode không được triển khai đúng, việc tìm kiếm đã không hoạt động. Còn chi tiết về MapHashMap bạn sẽ học trong các bài giảng cấp độ 26 :P

6. Lỗi thường gặp khi làm việc với equals, hashCode, toString trong các lớp record

Lỗi số 1: Mong đợi có thể thay đổi trường sau khi tạo.
Các trường của record luôn là final, và việc so sánh dựa trên giá trị của chúng, được thiết lập trong constructor. Nếu bạn “thay đổi” trạng thái bên trong theo cách tinh vi nào đó (ví dụ, thông qua một đối tượng có thể thay đổi nằm trong trường), việc so sánh và mã băm có thể trở nên không chính xác.

Lỗi số 2: Ghi đè equals nhưng quên hashCode.
Nếu bạn ghi đè một trong hai phương thức này — hãy luôn ghi đè phương thức còn lại! Nếu không, các collection (HashSet, HashMap) sẽ hành xử khó lường.

Lỗi số 3: Kỳ vọng toString ở một định dạng khác.
Nếu bạn cần định dạng chuỗi đặc biệt — chỉ cần ghi đè toString. Mặc định định dạng luôn là ClassName[field1=value1, field2=value2].

Lỗi số 4: Dùng record cho các lớp phức tạp với trường mutable.
Các trường của record nên là bất biến. Nếu bạn dùng, chẳng hạn, ArrayList làm trường, và ai đó thay đổi nội dung của nó — việc so sánh và mã băm có thể “hỏng”. Với record, tốt nhất chỉ dùng các kiểu bất biến.

Lỗi số 5: Dùng record cho các lớp có hành vi không phải value-object.
Record không phải “lớp nhỏ cú pháp gọn”. Nó là value-object, được thiết kế để lưu một tập giá trị. Nếu bạn có logic phức tạp, trạng thái mutable hoặc cần kế thừa — hãy dùng lớp thường.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION