Lớp này trông kín như bưng: final, mọi trường private final, có kiểm tra đầu vào trong hàm khởi tạo. Vậy mà nó bị sửa hai lần, qua hai cửa khác nhau, không một setter nào. Vì final chỉ đóng đinh bạn đang trỏ vào đối tượng nào, chứ không khoá được nội dung đối tượng đó — và một đối tượng có hai cái cửa để dữ liệu đi qua: đường vào (hàm khởi tạo) và đường ra (getter). Lớp này để ngỏ cả hai.
static final class KhoangHo {
private final Date batDau, ketThuc;
private final List<String> nguoiThamGia;
KhoangHo(Date batDau, Date ketThuc, List<String> nguoiThamGia) {
if (batDau.after(ketThuc)) throw new IllegalArgumentException("bắt đầu sau kết thúc");
this.batDau = batDau;
this.ketThuc = ketThuc;
this.nguoiThamGia = nguoiThamGia;
}
Date batDau() { return batDau; }
List<String> nguoiThamGia() { return nguoiThamGia; }
}
Và đây là chuyện xảy ra:
ban đầu : [1000 -> 2000] [Minh]
sau khi sửa : [9999 -> 2000] [Minh, kẻ lạ]
sau getter : [1 -> 2000] [Minh, kẻ lạ, kẻ lạ 2]
Đối tượng bị sửa hai lần, bằng hai đường khác nhau, dù không có một setter nào.
Đường thứ nhất: cửa vào
Date d1 = new Date(1000);
List<String> ds = new ArrayList<>(List.of("Minh"));
KhoangHo ho = new KhoangHo(d1, d2, ds);
d1.setTime(9999); // sửa qua tham chiếu người gọi vẫn đang giữ
ds.add("kẻ lạ");
Hàm khởi tạo lưu chính tham chiếu được truyền vào. Người gọi vẫn giữ tham chiếu đó, nên vẫn sửa được. Và họ có thể sửa sau khi phép kiểm tra đã chạy — nên ràng buộc "bắt đầu trước kết thúc" bị phá mà chẳng có gì ngăn.
Đây là kiểu lỗi rất khó tìm trong mã thật, vì thủ phạm nằm ở một file khác, có khi được viết bởi người khác, có khi cách đó nửa năm.
Đường thứ hai: cửa ra
ho.batDau().setTime(1);
ho.nguoiThamGia().add("kẻ lạ 2");
Getter trả về chính tham chiếu bên trong. Bất kỳ ai gọi getter đều nhận được quyền sửa ruột đối tượng. Trường private final không giúp gì — nó chỉ khoá việc trỏ sang chỗ khác, không khoá nội dung.
Ta đã gặp đúng chuyện này ở bài record (thành phần là List) và bài đóng gói (getter trả List). Ở đây nó lộ ra trọn vẹn cả hai đầu.
Sao chép phòng vệ
Chữa bằng cách sao chép ở cả hai cửa:
static final class KhoangKin {
private final Instant batDau, ketThuc;
private final List<String> nguoiThamGia;
KhoangKin(Instant batDau, Instant ketThuc, List<String> nguoiThamGia) {
this.batDau = Objects.requireNonNull(batDau, "batDau");
this.ketThuc = Objects.requireNonNull(ketThuc, "ketThuc");
if (batDau.isAfter(ketThuc))
throw new IllegalArgumentException(
"bắt đầu (%s) sau kết thúc (%s)".formatted(batDau, ketThuc));
this.nguoiThamGia = List.copyOf(nguoiThamGia); // sao chép ở CỬA VÀO
}
List<String> nguoiThamGia() { return nguoiThamGia; } // đã bất biến, trả thẳng được
}
ban đầu : [...] [Minh]
sửa nguồn : [...] [Minh] <- không ảnh hưởng
sửa getter : bị chặn
Hai thay đổi quan trọng:
List.copyOf ở hàm khởi tạo cắt đứt liên hệ với danh sách của người gọi. Từ đó danh sách bên trong là của riêng đối tượng.
Dùng Instant thay cho Date. Date sửa được, Instant thì không — nên không cần sao chép nó, và cũng không cần bọc getter. Đây là cách chữa tận gốc: chọn kiểu bất biến thì không phải phòng vệ.
Với Date — hoặc bất kỳ kiểu sửa được nào mà bạn buộc phải dùng — thì phải sao chép cả hai chiều:
this.batDau = new Date(batDau.getTime()); // cửa vào
Date batDau() { return new Date(batDau.getTime()); } // cửa ra
Kiểm tra đầu vào ở đâu
Nguyên tắc: kiểm tra ở biên giới, tin tưởng ở bên trong.
Biên giới là những chỗ dữ liệu từ ngoài đi vào: tham số của phương thức công khai, dữ liệu đọc từ mạng, kết quả phân tích JSON, đầu vào của người dùng. Ở đó kiểm tra kỹ.
Bên trong — giữa các phương thức private của cùng một lớp — thì không cần kiểm lại. Nếu đã bảo đảm ở cửa vào, kiểm lại chỉ làm mã rối và che mất chỗ nào mới là ranh giới thật.
Ba công cụ trong thư viện chuẩn:
Objects.requireNonNull(x, "tên tham số"); // ném NPE, có thông điệp
Objects.requireNonNullElse(x, macDinh); // thay null bằng giá trị mặc định
Objects.checkIndex(i, length); // kiểm chỉ số, ném IndexOutOfBounds
NPE: batDau
Thông điệp của requireNonNull chỉ cần là tên tham số. Người đọc stack trace sẽ thấy ngay tham số nào null, ở dòng nào — đủ để tìm ra chỗ gọi sai.
Còn thông điệp cho lỗi giá trị thì nên đầy đủ như bài về ngoại lệ đã nói:
bắt đầu (1970-01-01T01:23:20Z) sau kết thúc (1970-01-01T00:16:40Z)
Có cả hai giá trị thật, nên người đọc log không phải đoán.
Ném kiểu nào
Quy ước trong thư viện chuẩn, và tôi khuyên theo:
| Tình huống | Ngoại lệ |
|---|---|
| Tham số là null mà không được phép | NullPointerException |
| Giá trị nằm ngoài khoảng cho phép | IllegalArgumentException |
| Chỉ số ngoài biên | IndexOutOfBoundsException |
| Đối tượng đang ở trạng thái không cho phép thao tác này | IllegalStateException |
Nhiều người thấy lạ khi ném NullPointerException một cách chủ động — vì quen coi NPE là lỗi. Nhưng đây chính là quy ước của JDK, và Objects.requireNonNull làm đúng như vậy. NPE ném có chủ ý, kèm tên tham số, hữu ích hơn hẳn một NPE ngẫu nhiên ở ba tầng sâu hơn.
Phân biệt IllegalArgumentException với IllegalStateException: sai ở tham số hay sai ở thời điểm. Rút tiền quá số dư là IllegalStateException — số tiền hợp lệ, chỉ là lúc này không làm được.
Bất biến: cách chữa tận gốc
Mọi thứ ở trên là cách sống chung với đối tượng sửa được. Cách tốt hơn là đừng có đối tượng sửa được.
Một lớp bất biến cần:
Mọi trường private final.
Lớp final, hoặc dùng record — nếu không, lớp con có thể thêm trạng thái sửa được.
Không có setter, và không có phương thức nào đổi trạng thái.
Sao chép phòng vệ cho mọi trường thuộc kiểu sửa được, ở cả hai cửa.
Đổi lại bạn được rất nhiều: an toàn luồng miễn phí, dùng làm khoá HashMap không lo, không cần sao chép khi truyền đi, và không bao giờ phải hỏi "ai đã sửa cái này".
Cần "thay đổi" thì tạo bản mới:
KhoangKin doiKetThuc(Instant moi) {
return new KhoangKin(this.batDau, moi, this.nguoiThamGia);
}
Đây chính là cách String, Instant, BigDecimal và mọi record hoạt động. Với dữ liệu lớn thì tốn kém hơn, nhưng phần lớn đối tượng nghiệp vụ đều nhỏ, và chi phí cấp phát trong JVM hiện đại thấp hơn nhiều so với trực giác — chuyện của chặng JVM.
Kiểm tra không phải là bắt hết mọi thứ
Một cảnh báo để cân bằng: lập trình phòng thủ dễ bị đẩy quá đà thành mã đầy if kiểm tra những điều không thể xảy ra.
Ba câu hỏi trước khi thêm một phép kiểm:
Điều này có thật sự xảy ra được không? Trường vừa gán trong hàm khởi tạo thì không cần kiểm null ở mọi phương thức.
Ai là người sai? Nếu là lỗi lập trình thì ném ngoại lệ, đừng "xử lý" nó. Nếu là dữ liệu người dùng thì mới cần thông báo tử tế.
Kiểm ở đây có phải chỗ đúng không? Kiểm cùng một thứ ở năm tầng là dấu hiệu không ai biết ranh giới nằm đâu.
Muốn tự thấy cả hai cửa rò trong ba mươi giây: tìm một lớp có trường kiểu List, Map, Date hoặc mảng, rồi nhìn đúng hai dòng — public LopNay(List<X> ds){ this.ds = ds; } (cửa vào) và public List<X> getDs(){ return ds; } (cửa ra). Cả hai đều rò: truyền một danh sách vào rồi sửa chính danh sách đó từ ngoài, hoặc gọi getter rồi add thẳng vào kết quả, và trạng thái bên trong đổi theo — dù trường đã khai final. List.copyOf(ds) ở cả hai chỗ đóng lại cả hai đường.
Mẫu số chung
Cái chuyện "khoá con trỏ chứ không khoá nội dung" không phải đặc thù của Java — nó đúng cho mọi ngôn ngữ có ngữ nghĩa tham chiếu, và cách chúng đối phó lại rất khác nhau. JavaScript có const khoá đúng cái ràng buộc chứ không phải giá trị (y hệt final), và Object.freeze chỉ đóng băng một tầng, nên trả mảng nội bộ ra ngoài vẫn rò. Python không có private thật, và trả thẳng một list nội bộ là cùng lỗ hổng; cách chữa là tuple, frozenset, hoặc dataclass frozen=True. Go đỡ hơn ở chỗ struct gán là sao chép theo giá trị, nhưng slice và map vẫn là tham chiếu — nên vẫn rò đúng kiểu này. Còn Rust là ngôi sao: hệ sở hữu và trình mượn biến chuyện này thành lỗi biên dịch — bạn không thể vừa giữ một tham chiếu sửa-được vừa để người khác đọc, ngôn ngữ cưỡng chế thẳng.
Điểm chung, và là thứ đáng mang theo: trong ngôn ngữ tham chiếu, final/const chỉ đóng đinh bạn trỏ vào đâu, không khoá được thứ bạn trỏ tới — nên một đối tượng luôn có hai cửa rò, đường vào và đường ra, và bảo vệ được tính bất biến phải bịt cả hai. Hai cách bịt hội tụ ở mọi ngôn ngữ: sao chép ở biên giới, hoặc tốt hơn, dùng kiểu bất biến để khỏi phải sao chép. Rust cưỡng chế điều này lúc biên dịch, Go giảm nhẹ bằng ngữ nghĩa giá trị, còn Java (và JS, Python) thì tin bạn — và cái giá của lòng tin là bạn phải tự nhớ copyOf ở cả hai cửa. Một trường List để hở là một cánh cửa ai cũng vào được, dù bạn đã viết final to đùng trước mặt nó.
Ngày mai ta sang chặng Collections và Generics — mười bốn bài, bắt đầu bằng một tấm bản đồ toàn cảnh, rồi mổ vào bên trong HashMap, và kết thúc ở PECS cùng chuyện xoá kiểu lúc biên dịch.