Hình dung mỗi kiểu generic như một cái nhãn dán trên thùng đồ lúc chuyển nhà. Khi đóng gói, bạn ghi rõ "sách", "bát đĩa" — người bê nhờ đó không xếp thùng nặng lên thùng dễ vỡ. Nhưng đến nơi, nhãn bị xé hết, và mọi thùng lại chỉ là bìa các-tông giống hệt nhau: mở ra mới biết bên trong là gì. Generics của Java hoạt động đúng như vậy. Trình biên dịch đọc nhãn <String>, <Integer> để kiểm mọi thứ khớp, rồi xé nhãn đi trước khi sinh bytecode. Gần như mọi hạn chế khó hiểu của generics đều bắt nguồn từ cái hành động xé nhãn đó.

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>. Nhãn trên bản thiết kế (lớp) vẫn còn; nhãn trên cái thùng (thể hiện) thì bị xé.

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. Muốn tự tay chạm vào nó một lần cho nhớ, thử đúng 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.

Mẫu số chung

"Generics tồn tại tới đâu trong vòng đời chương trình" là một lựa chọn thiết kế, và mỗi ngôn ngữ có generic trả lời khác nhau — nằm đâu đó trên trục từ xoá sạch tới giữ trọn.

  • Java chọn xoá kiểu vì tương thích ngược, và trả giá bằng đúng bốn hạn chế ở trên. Nhãn trên thể hiện bị xé, nhãn trên lớp thì còn.
  • C#/.NET chọn ngược lại — generic đặc hoá (reified): lúc chạy List<int> và List<string> là hai kiểu khác nhau thật sự, nên typeof(T), new T(), T[] đều hợp lệ, và không có int bị đóng hộp. Đổi lại, Microsoft phải dựng được CLR mới từ đầu chứ không chắp vào nền cũ như Java.
  • Rust đi xa hơn nữa bằng đơn hình hoá (monomorphization): mỗi lần dùng Vec<T> với một kiểu cụ thể, trình biên dịch sinh hẳn một bản mã riêng cho kiểu đó — chi phí bằng không lúc chạy, nhưng đổi lấy mã phình to và thời gian biên dịch lâu hơn.
  • TypeScript còn quyết liệt hơn Java: kiểu generic bị xoá hoàn toàn khi biên dịch xuống JavaScript, không để lại chút metadata nào — lúc chạy không có cách gì biết một mảng "được khai là" Array<string>.
  • Kotlin chạy trên JVM nên thừa hưởng đúng xoá kiểu của Java, nhưng cấp cho bạn một lối thoát hẹp: từ khoá reified trên hàm inline khiến trình biên dịch nhúng thẳng kiểu cụ thể vào chỗ gọi, nên trong hàm đó T::class và is T lại dùng được.

Sợi chỉ chung đáng mang theo: "xoá kiểu hay đặc hoá" không phải lỗi của ngôn ngữ nào, mà là một sự đánh đổi — Java đổi sức mạnh lúc chạy lấy khả năng tương thích với hai mươi năm mã cũ; C# và Rust đổi sự đơn giản (hoặc kích thước mã) lấy thông tin kiểu đầy đủ; TypeScript bỏ hẳn để bám sát JavaScript. Hiểu được trục đánh đổi này thì bốn hạn chế của Java thôi là những luật vô cớ phải nhớ, mà trở thành hệ quả trực tiếp của một quyết định bạn có thể gọi tên — và khi chuyển sang C# hay Rust, bạn biết vì sao ở đó new T() lại đột nhiên chạy được.

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.