Bỏ qua điều hướng
EN
Tất cả bài viết

Java cơ bản #8: record, enum và equals, hashCode, toString

Ba phương thức mọi đối tượng đều có — toString, equals, hashCode — vì sao phải ghi đè chúng cùng nhau và cái bẫy khi quên; record (Java 16+) viết lớp dữ liệu bất biến trong một dòng; enum cho tập giá trị cố định, có thuộc tính, phương thức và dùng trong switch.

11 phút đọc

Ôn tập bài nàyThẻ ghi nhớ & câu hỏi ôn tập7 thẻ ghi nhớ · 6 câu trắc nghiệm
Mục lục

Bài 7 nói mọi lớp đều ngầm kế thừa Object. Ba phương thức thừa hưởng từ Object — toString, equals, hashCode — trông vô hại, nhưng hiểu sai chúng gây ra những lỗi rất khó tìm: đối tượng "biến mất" khỏi HashSet, hai sản phẩm giống hệt nhau mà so sánh ra khác.

Bài này giải thích ba phương thức đó, rồi giới thiệu hai công cụ giúp bạn gần như không phải tự viết chúng: record cho dữ liệu và enum cho tập giá trị cố định.

toString

Mặc định, in một đối tượng ra màn hình cho chuỗi vô nghĩa kiểu SanPham@1b6d3586 (tên lớp + mã băm). Ghi đè toString để in thông tin hữu ích — rất có ích khi gỡ lỗi và ghi log:

ToStringDemo.java
class SanPham {
    private final String ma;
    private final String ten;
    private final long gia;
 
    SanPham(String ma, String ten, long gia) {
        this.ma = ma;
        this.ten = ten;
        this.gia = gia;
    }
 
    @Override
    public String toString() {
        return "SanPham[ma=%s, ten=%s, gia=%,d]".formatted(ma, ten, gia);
    }
}
 
void main() {
    var sp = new SanPham("SP01", "Bàn phím", 850_000);
    IO.println(sp);                       // println tự gọi toString()
    IO.println("Vừa thêm: " + sp);        // nối chuỗi cũng gọi toString()
}

equals: hai đối tượng có "bằng nhau" không

== so sánh địa chỉ. equals mặc định (của Object) cũng so sánh địa chỉ. Muốn hai sản phẩm cùng mã được coi là bằng nhau, phải ghi đè equals theo đúng hợp đồng:

EqualsDemo.java
import java.util.HashSet;
import java.util.Objects;
 
class Diem {
    private final int x, y;
    Diem(int x, int y) { this.x = x; this.y = y; }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;                       // cùng đối tượng
        if (!(o instanceof Diem khac)) return false;      // khác kiểu hoặc null
        return x == khac.x && y == khac.y;                // so sánh từng trường quan trọng
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(x, y);                        // ĐỒNG BỘ với equals
    }
 
    @Override
    public String toString() { return "(" + x + ", " + y + ")"; }
}
 
class DiemQuenHashCode {
    private final int x, y;
    DiemQuenHashCode(int x, int y) { this.x = x; this.y = y; }
    @Override
    public boolean equals(Object o) {
        return o instanceof DiemQuenHashCode k && x == k.x && y == k.y;
    }
    // quên hashCode!
}
 
void main() {
    var a = new Diem(1, 2);
    var b = new Diem(1, 2);
    IO.println(a == b);           // false — hai đối tượng khác nhau
    IO.println(a.equals(b));      // true  — cùng nội dung
 
    var tapHop = new HashSet<Diem>();
    tapHop.add(a);
    IO.println(tapHop.contains(b));           // true
 
    var tapLoi = new HashSet<DiemQuenHashCode>();
    tapLoi.add(new DiemQuenHashCode(1, 2));
    IO.println(tapLoi.contains(new DiemQuenHashCode(1, 2)));   // false — "biến mất"!
}

Hợp đồng equals–hashCode

  1. Nếu a.equals(b) thì a.hashCode() == b.hashCode() — bắt buộc.
  2. Hai đối tượng khác nhau có thể trùng hashCode (va chạm), không sao.
  3. equals phải phản xạ, đối xứng, bắc cầu, nhất quán, và x.equals(null) là false.

HashSet, HashMap (bài 9) tìm phần tử bằng cách tính hashCode để chọn "ngăn", rồi mới dùng equals trong ngăn đó. Quên ghi đè hashCode → hai đối tượng "bằng nhau" rơi vào hai ngăn khác nhau → không tìm thấy. Quy tắc: ghi đè equals thì luôn ghi đè hashCode.

Lưu ý: đừng dùng trường có thể thay đổi trong equals/hashCode của đối tượng nằm trong HashSet/HashMap. Sửa trường đó sau khi thêm vào tập hợp → đối tượng nằm sai "ngăn", không tìm lại được. Đối tượng bất biến tránh được hoàn toàn vấn đề này.

Viết tay ba phương thức này vừa dài vừa dễ sai. May là có record.

record: lớp dữ liệu trong một dòng

Từ Java 16, record là cách khai báo lớp chỉ để mang dữ liệu bất biến:

RecordDemo.java
import java.util.HashSet;
 
record Diem(int x, int y) {}
 
record SanPham(String ma, String ten, long gia) {
    SanPham {                                        // constructor rút gọn: chỉ để kiểm tra/chuẩn hoá
        if (gia < 0) throw new IllegalArgumentException("Giá không được âm");
        ten = ten.strip();
    }
 
    SanPham giamGia(int phanTram) {                  // thêm phương thức bình thường được
        return new SanPham(ma, ten, gia * (100 - phanTram) / 100);   // "sửa" = tạo bản mới
    }
 
    static SanPham mienPhi(String ma, String ten) {  // phương thức static (factory) cũng được
        return new SanPham(ma, ten, 0);
    }
}
 
void main() {
    var p = new Diem(3, 4);
    IO.println(p);                                   // Diem[x=3, y=4] — toString có sẵn
    IO.println(p.x() + p.y());                       // accessor tên x(), không phải getX()
    IO.println(p.equals(new Diem(3, 4)));            // true — equals/hashCode có sẵn
 
    var tap = new HashSet<Diem>();
    tap.add(new Diem(1, 1));
    IO.println(tap.contains(new Diem(1, 1)));        // true
 
    var sp = new SanPham("SP01", "  Chuột không dây ", 400_000);
    var spSale = sp.giamGia(25);
    IO.println(sp + " → " + spSale);
    IO.println(SanPham.mienPhi("QT", "Móc khoá"));
}

Một dòng record Diem(int x, int y) {} cho bạn: các trường private final, constructor đầy đủ, accessor x()/y(), equals, hashCode, toString.

Record làm đượcRecord không làm được
Thêm phương thức, phương thức static, constructor phụKế thừa lớp khác (extends)
Cài đặt interface (như bài 7 với sealed)Thêm trường instance ngoài danh sách thành phần
Kiểm tra dữ liệu trong constructor rút gọnSửa trường sau khi tạo (luôn bất biến)

Dùng record cho: kết quả trả về nhiều giá trị, DTO nhận/gửi API, khoá của HashMap, sự kiện, toạ độ, tiền tệ… Dùng lớp thường khi đối tượng có trạng thái thay đổi theo thời gian và hành vi phức tạp (như TaiKhoan ở bài 6).

Lưu ý: record bất biến nông: record Lop(String ten, List<String> hocVien) vẫn cho sửa nội dung danh sách bên trong. Sao chép phòng thủ trong constructor: hocVien = List.copyOf(hocVien);.

enum: tập giá trị cố định

Trạng thái đơn hàng, ngày trong tuần, mức độ ưu tiên — những thứ có số giá trị cố định, biết trước. Dùng int (0, 1, 2) hay String ("DANG_GIAO") đều dễ gõ sai và không được trình biên dịch kiểm tra. enum giải quyết:

EnumCoBan.java
enum TrangThai { CHO_XAC_NHAN, DANG_GIAO, DA_GIAO, DA_HUY }
 
void main() {
    TrangThai t = TrangThai.DANG_GIAO;
    IO.println(t);                                  // DANG_GIAO
    IO.println(t.name() + " " + t.ordinal());       // DANG_GIAO 1 — thứ tự khai báo (đừng lưu ordinal vào DB)
    IO.println(t == TrangThai.DANG_GIAO);           // so sánh enum bằng == là đúng và an toàn
    IO.println(TrangThai.valueOf("DA_GIAO"));       // từ chuỗi → enum (sai tên → IllegalArgumentException)
 
    for (TrangThai tt : TrangThai.values()) IO.print(tt + " ");
    IO.println("");
 
    String moTa = switch (t) {                       // switch trên enum: không cần default nếu đủ mọi giá trị
        case CHO_XAC_NHAN -> "Đang chờ cửa hàng xác nhận";
        case DANG_GIAO -> "Shipper đang giao";
        case DA_GIAO -> "Giao thành công";
        case DA_HUY -> "Đơn đã huỷ";
    };
    IO.println(moTa);
}

enum có thuộc tính và phương thức

Mỗi hằng enum là một đối tượng; enum có thể có constructor, thuộc tính, phương thức:

EnumNangCao.java
enum HangThanhVien {
    DONG("Đồng", 0, 0),
    BAC("Bạc", 5_000_000, 3),
    VANG("Vàng", 20_000_000, 5),
    KIM_CUONG("Kim cương", 50_000_000, 10);
 
    private final String tenHienThi;
    private final long chiTieuToiThieu;
    private final int phanTramGiam;
 
    HangThanhVien(String tenHienThi, long chiTieuToiThieu, int phanTramGiam) {   // constructor luôn private
        this.tenHienThi = tenHienThi;
        this.chiTieuToiThieu = chiTieuToiThieu;
        this.phanTramGiam = phanTramGiam;
    }
 
    long giaSauGiam(long gia) {
        return gia * (100 - phanTramGiam) / 100;
    }
 
    static HangThanhVien theoChiTieu(long tongChiTieu) {
        HangThanhVien ketQua = DONG;
        for (HangThanhVien h : values()) {
            if (tongChiTieu >= h.chiTieuToiThieu) ketQua = h;   // values() theo thứ tự khai báo tăng dần
        }
        return ketQua;
    }
 
    String tenHienThi() { return tenHienThi; }
}
 
void main() {
    for (long chiTieu : new long[]{1_000_000, 8_000_000, 70_000_000}) {
        var hang = HangThanhVien.theoChiTieu(chiTieu);
        IO.println("Chi %,d → hạng %s, áo 500.000đ còn %,d".formatted(chiTieu, hang.tenHienThi(), hang.giaSauGiam(500_000)));
    }
}

Quy tắc nghiệp vụ (ngưỡng hạng, phần trăm giảm) nằm gọn trong enum; thêm hạng mới chỉ cần thêm một dòng. EnumMap và EnumSet (trong java.util) là các cấu trúc dữ liệu tối ưu riêng cho khoá enum.

Bất biến (immutability)

Record, enum, String, Integer, LocalDate đều bất biến. Đây không phải tình cờ:

  • An toàn khi chia sẻ: truyền cho phương thức khác, lưu vào tập hợp, dùng giữa nhiều luồng — không ai sửa được.
  • Dễ suy luận: giá trị lúc tạo là giá trị mãi mãi; không có lỗi "ai đó sửa mất rồi".
  • Khoá an toàn cho HashMap/HashSet.

Cách "sửa" đối tượng bất biến là tạo bản mới (sp.giamGia(25), chuoi.toUpperCase(), ngay.plusDays(1)). Mặc định hãy làm lớp bất biến; chỉ cho phép thay đổi khi thật sự cần.

NgayThang.java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
 
void main() {
    LocalDate homNay = LocalDate.of(2026, 10, 28);
    LocalDate hanNop = homNay.plusWeeks(2);           // bản mới; homNay không đổi
    IO.println(homNay + " → hạn nộp " + hanNop + " (" + hanNop.getDayOfWeek() + ")");
    IO.println("Còn " + ChronoUnit.DAYS.between(homNay, LocalDate.of(2027, 1, 1)) + " ngày tới năm mới");
}

(java.time — LocalDate, LocalDateTime, Duration — là thư viện ngày giờ chuẩn từ Java 8; đừng dùng java.util.Date cũ trong code mới.)

Bài tập

  1. Viết record PhanSo(long tu, long mau) tự rút gọn và đưa dấu âm lên tử trong constructor rút gọn; kiểm tra new PhanSo(2, 4).equals(new PhanSo(1, 2)) là true.
  2. Viết record Tien(long soDong) với cong, tru (không cho âm), nhan(int), toString dạng 1.250.000 ₫.
  3. Viết enum PhuongTien (XE_MAY, O_TO, XE_TAI) có phí gửi xe theo giờ; viết long tinhPhi(PhuongTien p, int soGio).
  4. Viết lớp thường NhanVien (mã, tên, lương), ghi đè equals/hashCode theo mã; thử thêm hai nhân viên cùng mã vào HashSet và giải thích kết quả.

Tổng kết

  • Ghi đè toString để in đối tượng có nghĩa.
  • equals so sánh nội dung theo hợp đồng; ghi đè equals thì phải ghi đè hashCode, nếu không HashSet/HashMap hoạt động sai.
  • record: lớp dữ liệu bất biến, có sẵn constructor, accessor, equals, hashCode, toString; constructor rút gọn để kiểm tra dữ liệu.
  • enum: tập giá trị cố định, an toàn kiểu, so sánh bằng ==, có thuộc tính/phương thức, dùng tốt với switch.
  • Ưu tiên đối tượng bất biến; "sửa" bằng cách tạo bản mới.
  • Bài tiếp theo: Collections — List, Set, Map — nơi equals và hashCode phát huy tác dụng.

Nguồn tham khảo: dev.java — "Records", "Enums"; JEP 395: Records; tài liệu API Java 25 — java.lang.Object (hợp đồng equals/hashCode), java.time; Bloch — Effective Java, mục 10–12 (equals, hashCode, toString), 17 (bất biến), 34 (enum thay cho hằng int).

Đang tải câu hỏi luyện tập…

Bài này thế nào?

Bài viết có hữu ích với bạn?

Đánh giá giúp mình biết nên viết thêm về chủ đề nào.

Đang tải đánh giá…
Chọn số sao từ 1 đến 5

Bình luận

Đăng nhập để bình luận không cần nhập tên và được duyệt nhanh hơn.

0/2000
Đang tải bình luận…

Đọc tiếp

Bài số № 50

Java cơ bản #7: kế thừa, đa hình, lớp trừu tượng, interface và sealed

Ba trụ cột còn lại của OOP: kế thừa với extends và super, ghi đè @Override, đa hình (một lời gọi, nhiều hành vi), lớp trừu tượng, interface và default method, instanceof với pattern matching, sealed + switch pattern, và nguyên tắc ưu tiên kết hợp (composition) hơn kế thừa.

17 phút đọc

Bài số № 49

Java cơ bản #6: lớp và đối tượng — constructor, this, đóng gói, static

Bước vào lập trình hướng đối tượng: lớp là bản thiết kế, đối tượng là thực thể; thuộc tính và phương thức, constructor, từ khoá this, đóng gói với private + getter/setter có kiểm tra, static khác gì instance, null và NullPointerException, package và tổ chức file.

Bài số № 55

Java cơ bản #12: dự án cuối — ứng dụng quản lý chi tiêu với Maven và JUnit

Gom toàn bộ kiến thức vào một dự án thật: cấu trúc Maven và pom.xml, chia lớp model–kho dữ liệu–service–giao diện, lưu file CSV, thống kê bằng Stream, cảnh báo ngân sách, viết kiểm thử JUnit 5, đóng gói file jar chạy được — và lộ trình học tiếp sau Java cơ bản.

19 phút đọc