Một dòng chứng minh:
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass());
a.getClass() : java.util.ArrayList
b.getClass() : java.util.ArrayList
a.getClass() == b.getClass() ? true
Lúc chạy, hai kiểu đó là một. Không có ArrayList<String> trong bộ nhớ — chỉ có ArrayList.
Đây là xoá kiểu: trình biên dịch dùng thông tin generic để kiểm tra, rồi xoá nó đi trước khi sinh bytecode. Và gần như mọi hạn chế khó hiểu của generics đều bắt nguồn từ đây.
Xoá thành cái gì
dat(java.lang.Number) -> void
lay() -> Number
Với lớp Hop<T extends Number>, tham số T bị thay bằng biên trên của nó. Không có biên thì thay bằng Object.
Nghĩa là sau khi biên dịch, Hop<Integer> và Hop<Double> dùng chung một lớp duy nhất, trong đó T đã thành Number.
Nhưng thông tin generic không mất hoàn toàn — nó được giữ trong metadata của file .class:
getGenericParameterTypes : [T]
getGenericType : T
Đây là chỗ nhiều người hiểu nhầm. Xoá kiểu không có nghĩa "generics biến mất sạch". Chữ ký generic vẫn được ghi lại, và phản chiếu đọc được. Nhờ đó Jackson biết List<Nguoi> cần dựng đối tượng gì, và Spring biết tiêm bean nào vào List<Handler>.
Điều thực sự mất là thông tin ở từng thể hiện: một đối tượng ArrayList không biết nó "được khai là" ArrayList<String>.
Vì sao Java chọn cách này
Java 5 thêm generics vào một ngôn ngữ đã có hàng tỷ dòng mã. Yêu cầu là mã cũ vẫn chạy, mã mới dùng được thư viện cũ, và ngược lại.
Xoá kiểu cho phép điều đó: một List cũ và một List<String> mới cùng biên dịch ra một thứ, nên chúng tương thích ở mức bytecode.
Cái giá là bốn hạn chế dưới đây. C# chọn hướng khác — giữ thông tin kiểu lúc chạy — nhưng phải phá vỡ tương thích khi làm vậy.
Bốn thứ không làm được
Bốn lỗi này đều có cùng một nguyên nhân, và một khi hiểu xoá kiểu thì chúng thành hiển nhiên.
Một: không nạp chồng theo tham số generic khác nhau
error: name clash: napChong(List<Integer>) and napChong(List<String>) have the same erasure
Sau khi xoá kiểu, cả hai đều là napChong(List). Cùng chữ ký thì không phải nạp chồng, mà là định nghĩa trùng.
Cách vòng: đổi tên phương thức, hoặc thêm một tham số phân biệt.
Hai: không tạo mảng của tham số kiểu
error: generic array creation
static <T> T[] taoMang(int n) { return new T[n]; }
Mảng giữ kiểu phần tử lúc chạy — đó là cơ chế của ArrayStoreException ở bài về mảng. Nhưng T lúc chạy không tồn tại, nên JVM không biết phải kiểm cái gì.
Cách vòng phổ biến:
@SuppressWarnings("unchecked")
T[] mang = (T[]) new Object[n];
Nó hoạt động, nhưng bạn vừa tự tay tắt kiểm tra kiểu — và trả mảng đó ra ngoài sẽ gây ClassCastException ở chỗ khác. Tốt hơn là dùng List<T>, hoặc nhận thêm một tham số Class<T> để tạo mảng bằng Array.newInstance.
Ba: không dùng instanceof với tham số generic
error: Object cannot be safely cast to List<String>
if (o instanceof List<String>) {}
Lúc chạy không có cách nào phân biệt List<String> với List<Integer>. Viết o instanceof List<?> thì được — vì <?> không đòi hỏi biết kiểu cụ thể.
Bốn: không tạo thể hiện của tham số kiểu
error: unexpected type
static <T> T taoMoi() { return new T(); }
required: class
found: type parameter T
new T() cần biết lớp nào để cấp phát, mà T đã bị xoá. Cách vòng chuẩn là truyền một hàm nhà máy:
static <T> T taoMoi(Supplier<T> nha) { return nha.get(); }
taoMoi(ArrayList::new);
Đây cũng là lý do nhiều API cũ nhận thêm tham số Class<T> clazz — đó là cách "mang" kiểu vào lúc chạy.
Phương thức cầu nối
Đây là phần tinh vi nhất, và nó giải thích một dòng lạ hay xuất hiện trong stack trace.
class Hop<T extends Number> { void dat(T x) { ... } }
class HopSo extends Hop<Integer> {
@Override void dat(Integer x) { super.dat(x); }
}
Sau khi xoá kiểu, Hop.dat có chữ ký dat(Number), còn HopSo.dat là dat(Integer) — hai chữ ký khác nhau, nên về mặt JVM đây không phải ghi đè.
Trình biên dịch giải quyết bằng cách lén sinh thêm một phương thức:
dat(java.lang.Integer) bridge=false synthetic=false
dat(java.lang.Number) bridge=true synthetic=true
Phương thức dat(Number) được đánh dấu bridge — cầu nối. Nó ép kiểu tham số về Integer rồi gọi bản thật. Nhờ đó đa hình vẫn hoạt động đúng.
Hệ quả bạn có thể gặp: nếu ai đó truyền một Double vào qua tham chiếu kiểu Hop thô, cầu nối sẽ ném ClassCastException — ở một dòng mã bạn chưa từng viết. Thấy ClassCastException trong một phương thức mà mã nguồn trông hoàn toàn đúng kiểu, hãy nghĩ tới cầu nối.
Sự an toàn chỉ có ở lúc biên dịch
List<String> chuoi = new ArrayList<>();
List tho = chuoi; // kiểu thô
tho.add(42); // chỉ cảnh báo, không lỗi
danh sách 'String' chứa: [42]
size = 1, get(0) là kiểu Integer
gán vào String -> ClassCastException
Một List<String> chứa Integer. Lúc chạy chẳng có gì ngăn được, vì kiểu đã bị xoá.
ClassCastException chỉ nổ ra khi bạn lấy ra và gán vào một biến String — vì đó là chỗ trình biên dịch đã lén chèn phép ép kiểu.
Đây là lý do cảnh báo unchecked đáng coi trọng: mỗi cảnh báo là một chỗ Java không thể bảo đảm gì nữa, và hậu quả sẽ hiện ra ở nơi khác.
Cách giữ lại thông tin kiểu khi cần
Có hai kỹ thuật đáng biết.
Truyền Class<T> — đơn giản, dùng khắp nơi trong JDBC và Jackson:
<T> T doc(String json, Class<T> kieu) { ... }
Type token — dùng một lớp con ẩn danh để "đóng băng" kiểu vào metadata:
new TypeReference<List<Nguoi>>() {}
Cách này khai thác đúng điều đã nói ở trên: chữ ký generic của lớp vẫn được giữ trong file .class. Lớp ẩn danh kế thừa TypeReference<List<Nguoi>> nên List<Nguoi> nằm trong metadata của nó, và phản chiếu đọc ra được. Jackson và Gson đều dùng mẫu này.
Tương lai
Project Valhalla đang làm việc trên "generic đặc hoá" — cho phép List<int> không cần đóng hộp và giữ được thông tin kiểu lúc chạy. Nhưng nó chưa có trong Java 21, và bài toán tương thích ngược khiến mọi thứ đi rất chậm.
Từ giờ tới lúc đó, xoá kiểu vẫn là điều bạn cần hiểu để không bối rối trước bốn lỗi ở trên.
Thử ba mươi giây
Viết hai phương thức nạp chồng nhận List<String> và List<Integer>, rồi biên dịch.
Đọc thông báo have the same erasure. Đó là lần đầu tiên nhiều người nhận ra generics chỉ tồn tại tới lúc biên dịch — và một khi hiểu điều đó, ba lỗi còn lại trong bài không cần giải thích nữa.
Ngày mai: chọn cấu trúc dữ liệu theo độ phức tạp — bảng chi phí các thao tác, và ba tình huống chọn sai làm chậm hệ thống thật.