Xây một ngôi nhà thì móng phải xong hẳn trước khi dựng tường — chuyện hiển nhiên. Tạo một đối tượng Java cũng đi theo đúng trật tự đó: phần của lớp cha dựng xong rồi mới tới lớp con. Bình thường bạn chẳng cần để ý. Cho tới hôm gặp một trường đã khai giá trị rõ ràng mà lúc chạy lại là null — không dòng nào gán null cả — thì mới thấy cái trật tự ấy có một cái bẫy nằm sẵn bên trong. Bài này mở nó ra.
Giữa lúc bạn gõ new Con() và lúc đối tượng sẵn sàng dùng, JVM chạy qua sáu bước theo một thứ tự cố định.
Sáu bước, đo bằng cách in ra
Tôi dựng hai lớp có đủ mọi loại khối khởi tạo và đánh số chúng:
static class Cha {
static { System.out.println("1. khối tĩnh của Cha"); }
{ System.out.println("3. khối thể hiện của Cha"); }
Cha() {
System.out.println("4. hàm khởi tạo Cha");
gioiThieu(); // gọi phương thức bị ghi đè
}
void gioiThieu() { System.out.println(" Cha.gioiThieu()"); }
}
static class Con extends Cha {
static { System.out.println("2. khối tĩnh của Con"); }
private String ten = "đã gán";
{ System.out.println("5. khối thể hiện của Con, ten = " + ten); }
Con() {
super();
System.out.println("6. hàm khởi tạo Con, ten = " + ten);
}
@Override void gioiThieu() {
System.out.println(" Con.gioiThieu(), ten = " + ten);
}
}
new Con() cho ra:
1. khối tĩnh của Cha
2. khối tĩnh của Con
3. khối thể hiện của Cha
4. hàm khởi tạo Cha
Con.gioiThieu(), ten = null <-- vẫn null!
5. khối thể hiện của Con, ten = đã gán
6. hàm khởi tạo Con, ten = đã gán
Đọc kỹ dòng thứ năm. Ta sẽ quay lại nó.
Thứ tự rút ra:
Khối tĩnh chạy trước, từ lớp cha xuống lớp con, và chỉ chạy một lần duy nhất trong cả vòng đời chương trình — lúc lớp được nạp. Giống việc đấu điện nước cho cả con phố: làm một lần cho tất cả. Tạo đối tượng thứ hai thì chúng không chạy lại:
--- tạo lần hai: khối tĩnh KHÔNG chạy lại ---
3. khối thể hiện của Cha
4. hàm khởi tạo Cha
...
Rồi tới phần thể hiện, cũng từ cha xuống con. Với mỗi lớp: các trường được gán và các khối thể hiện chạy theo đúng thứ tự chúng xuất hiện trong file, sau đó mới tới thân hàm khởi tạo.
Nói gọn thành một câu: cha xong hẳn rồi mới tới con. Điều này hợp lý — lớp con dựng trên nền lớp cha, nên nền phải xong trước, đúng như móng trước tường.
Cái bẫy: trường của lớp con còn null
Dòng thứ năm trong kết quả:
Con.gioiThieu(), ten = null <-- vẫn null!
ten được khai là private String ten = "đã gán";. Không có chỗ nào gán null. Vậy mà nó null.
Lý do nằm ở thứ tự. Cha() gọi gioiThieu(), và vì Con đã ghi đè phương thức đó nên bản của Con được gọi — đa hình hoạt động ngay cả khi đối tượng chưa dựng xong. Nhưng tại thời điểm đó, phần khởi tạo của Con chưa chạy, nên ten vẫn mang giá trị mặc định là null. Người thợ móng vừa gọi người trang trí tầng trên xuống treo tranh, trong khi tầng trên còn chưa kê nổi đồ đạc.
Ba cách tránh:
Để phương thức đó là private hoặc final — khi ấy không ai ghi đè được, và gọi trong hàm khởi tạo là an toàn.
Đánh dấu cả lớp là final nếu nó không sinh ra để kế thừa. Đây thường là lựa chọn đúng, và ta sẽ bàn kỹ ở bài về kế thừa.
Tách phần cần gọi ra một phương thức khởi tạo riêng, để người dùng gọi sau khi đối tượng đã dựng xong.
super() và this()
Mỗi hàm khởi tạo bắt đầu bằng một trong hai lời gọi, và chỉ một:
super(...) gọi hàm khởi tạo của lớp cha. Không viết thì trình biên dịch tự chèn super() không tham số. Đó là lý do trong bytecode ở phần thứ ba ta thấy invokespecial gọi Object.<init> dù mã nguồn chẳng có dòng nào.
this(...) gọi một hàm khởi tạo khác của chính lớp đó:
NhieuHamKhoiTao(String ten) {
this(ten, 0);
System.out.println(" ctor 1 tham số");
}
NhieuHamKhoiTao(String ten, int tuoi) {
this.ten = ten; this.tuoi = tuoi;
System.out.println(" ctor 2 tham số: " + ten + ", " + tuoi);
}
ctor 2 tham số: Minh, 0
ctor 1 tham số
Bản hai tham số chạy xong trước, rồi mới tới phần còn lại của bản một tham số. Đây là mẫu rất đáng dùng: dồn toàn bộ logic khởi tạo vào một hàm khởi tạo đầy đủ nhất, các bản còn lại chỉ điền giá trị mặc định rồi ủy quyền. Sửa luật thì sửa một chỗ.
Lời gọi này phải là câu lệnh đầu tiên của hàm khởi tạo. Lý do: không được phép động vào đối tượng khi lớp cha chưa dựng xong.
super(), miễn không đụng tới this. Trên JDK 21 thì vẫn phải dùng cách cũ — gọi một phương thức tĩnh để kiểm tra ngay trong lời gọi: super(kiemTra(thamSo)).
Hàm khởi tạo mặc định biến mất khi nào
Lớp không viết hàm khởi tạo nào thì trình biên dịch thêm một bản rỗng, không tham số — ta đã nhìn thấy nó bằng javap ở phần thứ ba.
Nhưng chỉ cần bạn viết một hàm khởi tạo bất kỳ, bản mặc định biến mất:
class TaiKhoan {
TaiKhoan(String chu) { ... }
}
new TaiKhoan(); // lỗi biên dịch: không có hàm khởi tạo không tham số
Đây là nguyên nhân của một lỗi rất hay gặp khi làm việc với các framework. Nhiều thư viện — JPA, Jackson, và phần lớn công cụ dùng phản chiếu — cần một hàm khởi tạo không tham số để tạo đối tượng rỗng rồi đổ dữ liệu vào. Bạn thêm một hàm khởi tạo tiện tay, và thư viện gãy với thông báo khó hiểu về việc không tạo được thể hiện.
Khối thể hiện: hiếm khi cần
Khối { ... } không có tên chạy trước thân hàm khởi tạo, ở mọi hàm khởi tạo.
Thật lòng thì tôi gần như không dùng nó. Có nhiều hàm khởi tạo và muốn chia sẻ phần chung thì this(...) rõ ràng hơn hẳn — người đọc thấy ngay luồng đi, thay vì phải nhớ có một khối vô danh nằm đâu đó phía trên trong file.
Chỗ duy nhất nó thực sự hữu ích là lớp ẩn danh, vốn không đặt được hàm khởi tạo:
List<String> ds = new ArrayList<>() {{
add("a");
add("b");
}};
Mẫu này có tên là "khởi tạo bằng ngoặc kép", và tôi khuyên đừng dùng. Nó tạo ra một lớp con ẩn danh cho mỗi lần gọi, và lớp ẩn danh giữ tham chiếu ngầm tới đối tượng bao ngoài — nguồn gốc của những vụ rò rỉ bộ nhớ rất khó tìm. Từ Java 9 đã có List.of("a", "b") gọn hơn và không có tác dụng phụ.
Khối tĩnh dùng khi nào
Khi cần khởi tạo dữ liệu tĩnh phức tạp hơn một biểu thức:
private static final Map<String, String> MA_QUOC_GIA;
static {
Map<String, String> m = new HashMap<>();
m.put("VN", "Việt Nam");
m.put("JP", "Nhật Bản");
MA_QUOC_GIA = Collections.unmodifiableMap(m);
}
Ngày nay Map.of(...) giải quyết phần lớn trường hợp này gọn hơn. Khối tĩnh chỉ còn đáng dùng khi phải đọc file cấu hình, hoặc nạp thư viện gốc.
Một cảnh báo: ngoại lệ ném ra từ khối tĩnh rất khó chịu. Lần nạp đầu tiên bạn nhận ExceptionInInitializerError; mọi lần sau nhận NoClassDefFoundError với thông báo chẳng liên quan gì tới nguyên nhân thật — đúng cái bẫy đã nhắc ở bài về classpath hôm trước. Nên khối tĩnh phải càng đơn giản càng tốt.
Vài mẫu đáng theo
Hàm khởi tạo chỉ nên gán trường và kiểm tra tham số. Đọc file, gọi mạng, mở kết nối — đưa hết ra ngoài. Một đối tượng nên tạo được nhanh và không gây tác dụng phụ.
Kiểm tra ngay tại cửa vào, để không tồn tại đối tượng ở trạng thái sai:
TaiKhoan(String chu, long soDu) {
this.chu = Objects.requireNonNull(chu, "chủ tài khoản không được null");
if (soDu < 0) throw new IllegalArgumentException("số dư ban đầu không được âm");
this.soDu = soDu;
}
Objects.requireNonNull ngắn hơn viết if và ném NPE ngay tại chỗ sai, thay vì để nó nổ ở một dòng cách đó ba lớp gọi.
Quá bốn tham số thì cân nhắc builder, nhất là khi nhiều tham số cùng kiểu — new Don("A","B",true,false,3) rất dễ hoán nhầm hai đối số mà trình biên dịch không hề biết.
Muốn tự thấy cái bẫy đổi hình ra sao trong ba mươi giây: lấy đoạn mã đầu bài, đổi private String ten = "đã gán" thành private final String ten = "đã gán" rồi chạy lại. Kết quả sẽ khác: với trường final được gán bằng hằng số, trình biên dịch nội tuyến giá trị vào ngay chỗ dùng, nên gioiThieu() in ra "đã gán" chứ không phải null. Cùng một cái bẫy, biểu hiện khác hẳn tuỳ một chi tiết nhỏ — đó là lý do quy tắc "đừng gọi phương thức ghi đè được trong hàm khởi tạo" đáng nhớ như một luật, thay vì suy luận lại mỗi lần.
Mẫu số chung
Cái trật tự "móng trước tường" và cái bẫy đi kèm nó không phải chuyện riêng của Java — mọi ngôn ngữ hướng đối tượng đều phải trả lời hai câu: phần cha hay phần con dựng trước, và một lời gọi phương thức ảo giữa lúc đang xây thì thấy bản nào. Java trả lời: cha trước, và lời gọi ảo thấy bản của lớp con — nên bạn dính cái null ở trên. C# chọn y hệt Java, cùng cái bẫy. Nhưng C++ chọn ngược lại và đó là một quyết định thiết kế đáng nhớ: trong lúc hàm khởi tạo của lớp cơ sở đang chạy, đối tượng "vẫn còn là" lớp cơ sở, nên lời gọi ảo dispatch về bản của lớp cơ sở chứ không phải bản ghi đè — an toàn hơn, nhưng làm nhiều người bất ngờ theo hướng khác. Python thì gọi super().__init__() bằng tay, nên thứ tự nằm trong tầm kiểm soát của bạn — đổi lại, quên gọi là phần cha không bao giờ được khởi tạo.
Điểm chung, và là thứ đáng mang theo: giữa lúc new và lúc đối tượng dùng được, luôn có một quãng đối tượng tồn tại nhưng chưa hoàn chỉnh — và mọi lỗi khởi tạo khó chịu nhất đều nảy ra từ việc có ai đó nhìn thấy đối tượng trong quãng đó. Cách một ngôn ngữ xử lý lời gọi ảo giữa chừng chỉ khác nhau ở chi tiết; lời khuyên thì hội tụ về đúng một câu ở mọi nơi: đừng để phần cha gọi ngược vào thứ mà phần con sẽ ghi đè, vì phần con lúc ấy chưa tồn tại đầy đủ. Móng không được nhờ tầng trên treo tranh khi tầng trên còn chưa xây.
Ngày mai: đóng gói và bốn mức truy cập — vì sao private là mặc định đúng, và vì sao sinh getter với setter cho mọi trường là thói quen nên bỏ.