Bài về equals cách đây ba hôm kết thúc bằng một lời hứa: record xoá được cả ba vấn đề đã nêu. Hôm nay ta kiểm chứng, và tìm luôn chỗ nó không giải quyết được.

record Diem(int x, int y) {}

Một dòng. Xem javap nói lớp đó thực sự có gì:

final class Rec$Diem extends java.lang.Record {
  Rec$Diem(int, int);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public int x();
  public int y();
}

Trình biên dịch sinh: hàm khởi tạo đầy đủ, equals, hashCode, toString, và hai accessor. Lớp là final, kế thừa java.lang.Record, và mọi trường bên trong đều private final.

Viết tay tương đương sẽ mất khoảng sáu mươi dòng, và mỗi dòng là một chỗ có thể sai.

Ba vấn đề của bài equals biến mất cùng lúc

  toString : Diem[x=3, y=4]
  equals   : true
  hashCode : true
  get(new Diem(1,1)) = một

Không thể quên hashCode — cả hai được sinh cùng nhau.

Không thể lệch trường — cả hai luôn dùng đúng toàn bộ thành phần đã khai.

Không thể sửa khoá sau khi put — mọi trường đều final, không có setter.

Nên record là lựa chọn gần như luôn đúng cho khoá HashMap, phần tử HashSet, DTO, và mọi giá trị trong miền nghiệp vụ.

Vẫn thêm được phương thức của riêng bạn

record không phải cái hộp câm:

record Diem(int x, int y) {
    double khoangCachToiGoc() { return Math.sqrt(x * x + y * y); }
    static Diem goc() { return new Diem(0, 0); }
}
  tự thêm  : 5.0, goc=Diem[x=0, y=0]

Phương thức thường, phương thức tĩnh, hằng số tĩnh, cài đặt interface — đều được. Thứ duy nhất không được là trường thể hiện ngoài danh sách thành phần. Trạng thái của một record chỉ gồm đúng những gì viết trong ngoặc.

Ghi đè toString, equals, hashCode cũng được, nếu cần khác bản mặc định.

Hàm khởi tạo rút gọn

Đây là cú pháp đặc trưng nhất của record, và cũng hay bị bỏ qua:

record Diem(int x, int y) {
    Diem {                                    // không có dấu ngoặc tham số
        if (x < 0 || y < 0)
            throw new IllegalArgumentException("toạ độ không được âm");
    }
}
  chặn: toạ độ không được âm

Không có danh sách tham số, không có phép gán this.x = x. Trình biên dịch chèn phần gán vào sau thân bạn viết. Nhiệm vụ của bạn chỉ là kiểm tra — hoặc chuẩn hoá — các tham số.

Chuẩn hoá được vì trong khối này, xytham số, gán lại được:

record Khoang(int dau, int cuoi) {
    Khoang {
        if (dau > cuoi) { int t = dau; dau = cuoi; cuoi = t; }   // tự sắp lại
    }
}

Cần hàm khởi tạo phụ thì phải uỷ quyền về bản đầy đủ:

Diem(int chung) { this(chung, chung); }
  ctor phụ : Diem[x=5, y=5]

Cái bẫy: record không bất biến sâu

Đây là phần quan trọng nhất của bài, và là chỗ tôi thấy nhiều người hiểu nhầm.

record Nhom(String ten, List<String> thanhVien) {}

List<String> ds = new ArrayList<>(List.of("Minh"));
Nhom n = new Nhom("A", ds);
ds.add("Lan");                    // sửa danh sách gốc từ bên ngoài
n.thanhVien().add("Hải");         // sửa qua chính accessor
  Nhom       : Nhom[ten=A, thanhVien=[Minh, Lan]]
  sau khi add: Nhom[ten=A, thanhVien=[Minh, Lan, Hải]]

Nội dung của record đổi hai lần, dù không có setter nào.

final chỉ khoá tham chiếu, không khoá nội dung — đúng như bài về final đã nói. record bảo đảm bạn không trỏ sang danh sách khác, không bảo đảm danh sách đó không bị sửa.

Với một khoá HashMap, đây là thảm hoạ: mã băm đổi và ta quay lại đúng lỗi "dữ liệu nằm trong map mà không lấy ra được" của bài equals.

Cách chữa nằm ngay trong hàm khởi tạo rút gọn:

record NhomAnToan(String ten, List<String> thanhVien) {
    NhomAnToan {
        thanhVien = List.copyOf(thanhVien);   // sao chép phòng vệ
    }
}
  NhomAnToan chặn sửa: UnsupportedOperationException

List.copyOf tạo một bản sao bất biến. Người gọi sửa danh sách gốc cũng không ảnh hưởng, và sửa qua accessor thì ném ngoại lệ.

Thành phần của record mà là collection hoặc mảng thì luôn phải sao chép phòng vệ trong hàm khởi tạo rút gọn. Không có nó, record chỉ bất biến trên bề mặt. Với mảng thì List.copyOf không dùng được — phải array.clone(), và cả equals lẫn hashCode sinh sẵn cũng so mảng theo địa chỉ, nên nói chung đừng để mảng làm thành phần của record.

Khi nào record không phù hợp

record sinh ra cho dữ liệu trong suốt — đối tượng mà toàn bộ trạng thái là những gì bạn thấy trong ngoặc, và không có gì phải giấu. Ba trường hợp nên tránh:

Khi cần giấu cách biểu diễn bên trong. Accessor của record là công khai và trùng tên thành phần. Nếu hôm nay bạn lưu hoTen mà mai muốn tách thành hoten, mọi mã gọi hoTen() đều gãy. Lớp thường cho phép đổi bên trong mà giữ nguyên phương thức công khai.

Khi có bất biến phức tạp cần bảo vệ liên tục. record cho kiểm tra lúc tạo, nhưng nếu logic nghiệp vụ đòi trạng thái thay đổi theo quy trình — đơn hàng đi từ "mới" sang "đã thanh toán" sang "đã giao" — thì một lớp thường với các phương thức có tên rõ ràng phù hợp hơn.

Khi cần kế thừa. record luôn final và đã kế thừa java.lang.Record, nên không mở rộng được. Nó cài interface được, và kết hợp với sealed rất đẹp — chuyện của ngày mai.

record với JPA và Jackson

Hai câu hỏi thực tế hay gặp:

JPA: record không làm entity được. JPA cần hàm khởi tạo không tham số và cần sửa trường bằng phản chiếu — cả hai đều trái với thiết kế của record. Nhưng nó rất hợp làm projection, tức kiểu trả về cho các truy vấn chọn vài cột.

Jackson: hỗ trợ tốt từ phiên bản 2.12, không cần chú thích gì. Đây là chỗ record thay thế DTO viết tay gọn nhất.

Kết hợp với pattern matching

record và pattern matching sinh ra cho nhau. Từ Java 21, bạn bóc thành phần ngay trong switch:

static String moTa(Object o) {
    return switch (o) {
        case Diem(int x, int y) when x == y -> "nằm trên đường chéo";
        case Diem(int x, int y)             -> "điểm (" + x + ", " + y + ")";
        default                             -> "không phải điểm";
    };
}

Diem(int x, int y)record pattern — nó vừa kiểm kiểu vừa tách hai thành phần ra hai biến, một bước. Lồng nhiều tầng cũng được: case Duong(Diem(var x1, var y1), Diem p2).

Đây là lý do record không chỉ là "cách viết tắt cho lớp dữ liệu". Nó là mảnh ghép của một hướng đi rộng hơn trong Java hiện đại: mô tả dữ liệu bằng kiểu, rồi bóc nó ra bằng pattern.

Thử ba mươi giây

Viết record Nguoi(String ten, List<String> soDienThoai) {}, tạo một đối tượng, rồi gọi .soDienThoai().add("0900").

Nó chạy. Không lỗi biên dịch, không ngoại lệ. Sau đó thêm hàm khởi tạo rút gọn với List.copyOf và chạy lại — lần này UnsupportedOperationException.

Ba mươi giây đó dạy bạn khác biệt giữa "bất biến" và "bất biến sâu" chắc hơn bất kỳ định nghĩa nào.

Ngày mai: sealed — đóng hệ phân cấp lại, và vì sao điều đó khiến switch không cần default nữa.