Đây là lớp trông có vẻ an toàn: final, mọi trường private final, có kiểm tra đầu vào trong hàm khởi tạo.
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.
Hết chặng ngoại lệ
Sáu bài vừa qua: cây phân cấp Throwable, try-with-resources, ngoại lệ nghiệp vụ mang dữ liệu, ghi log, Optional, và lập trình phòng thủ.
Một sợi chỉ xuyên suốt cả sáu: làm cho thất bại trở nên hiển hiện. Ngoại lệ bị nuốt, log thiếu ngữ cảnh, null không được khai báo, đối tượng bị sửa lén — tất cả đều là những thất bại xảy ra trong im lặng. Và thứ tốn thời gian nhất khi gỡ lỗi không bao giờ là lỗi ồn ào.
Từ 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.