Tạo một đối tượng nghe đơn giản: gọi new, xong. Nhưng 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.
Bình thường bạn không cần quan tâm. Cho tới hôm bạn gặp một trường đã khai giá trị rõ ràng mà lúc chạy lại là null — không có dòng nào gán null cả — và không hiểu chuyện gì đang xảy ra.
Bài này giải thích thứ tự đó, và cái bẫy ẩn trong nó.
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. 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.
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.
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.
Thử 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, nhưng biểu hiện khác hẳn tuỳ 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.
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ỏ.