Hãy nghĩ về một cái thùng carton. Không dán nhãn, bạn nhét gì vào cũng được — cốc thuỷ tinh lẫn với sách lẫn với dao — và chỉ tới lúc thò tay lấy ra mới phát hiện mình vừa cầm nhầm con dao, hoặc làm vỡ cái cốc. Dán lên thùng dòng chữ "chỉ đựng cốc" thì người đóng gói bị chặn ngay khi định bỏ con dao vào. Generics chính là cái nhãn đó, và lý do nó tồn tại gói gọn trong việc khi nào bạn phát hiện ra mình nhầm.
Generics vào Java năm 2004. Trước nó, mọi collection chứa Object — cái thùng không nhãn:
List tho = new ArrayList();
tho.add("chuỗi");
tho.add(42);
Trình biên dịch cho qua. Nhưng khi bạn lấy ra và ép kiểu:
danh sách thô : [chuỗi, 42]
chuỗi
-> ClassCastException: class java.lang.Integer cannot be cast to class java.lang.St...
Lỗi hiện ra lúc chạy, ở một dòng cách xa chỗ thêm dữ liệu sai, có thể ở một lớp khác, một tuần sau.
Với generics, cùng lỗi đó xuất hiện ngay lúc gõ:
Loi.java:8: error: incompatible types: int cannot be converted to String
s.add(42);
^
Đó là toàn bộ giá trị của generics: chuyển lỗi kiểu từ lúc chạy sang lúc biên dịch. Không phải để mã ngắn hơn — thực ra nó dài hơn — mà để lỗi xuất hiện ở đúng chỗ sai, lúc đóng gói chứ không phải lúc mở hàng.
Kiểu thô vẫn biên dịch được, nhưng có cảnh báo
Java giữ khả năng dùng kiểu thô để tương thích với mã cũ, nhưng nó cảnh báo:
Gen.java:39: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
tho.add("chuỗi");
Nếu thấy unchecked trong build của bạn, đó là chỗ trình biên dịch không kiểm tra được và mọi bảo đảm về kiểu đều mất. Đừng bỏ qua. Bật -Xlint:unchecked để thấy hết, và coi chúng như lỗi.
Biên trên: mở khoá các phương thức
Tham số kiểu trần như <T> thì T chỉ được coi là Object — bạn không gọi được gì ngoài toString, equals, hashCode.
Biên trên mở rộng chuyện đó:
static <T extends Number> double tong(List<T> ds) {
double t = 0;
for (T x : ds) t += x.doubleValue(); // gọi được vì T chắc chắn là Number
return t;
}
tong(List.of(1, 2, 3)) = 6.0
tong(List.of(1.5, 2.5)) = 4.0
Chữ extends ở đây không có nghĩa "kế thừa lớp". Nó nghĩa là "là một loại của", nên dùng được cả với interface:
static <T extends Comparable<T>> T lonNhat(List<T> ds) { ... }
Cần nhiều biên thì nối bằng dấu &, và lớp (nếu có) phải đứng đầu:
static <T extends Number & Comparable<T>> T lonNhatSo(List<T> ds) { ... }
Đọc một thông báo lỗi generics
Đây là phần mà tôi nghĩ hữu ích nhất bài. Gọi tong với danh sách chuỗi:
Loi.java:5: error: method tong in class Loi cannot be applied to given types;
tong(List.of("a", "b"));
^
required: List<T>
found: List<String>
reason: inference variable E has incompatible bounds
upper bounds: Number,Object
lower bounds: String
where T,E are type-variables:
T extends Number declared in method <T>tong(List<T>)
E extends Object declared in method <E>of(E,E)
Mười dòng cho một lỗi mà bản chất là "String không phải Number". Nhìn lần đầu rất nản, nhưng nó có cấu trúc cố định:
Dòng 1 — tên phương thức không áp dụng được. Đây là loại lỗi.
required / found — cần gì, nhận được gì. Hai dòng này thường đủ để hiểu vấn đề.
reason — vì sao suy luận kiểu thất bại. upper bounds: Number là ràng buộc từ chữ ký của tong; lower bounds: String là từ dữ liệu bạn truyền vào. Hai ràng buộc mâu thuẫn.
where — giải thích các chữ cái. T là của tong, E là của List.of.
Mẹo đọc: bỏ qua phần where, đọc required/found trước. Chín trên mười lần là đủ. Phần reason chỉ cần khi có nhiều tầng generic lồng nhau.
Và một thông báo rất hay gặp khác:
error: incompatible types: ArrayList<String> cannot be converted to List<Object>
List<Object> o = new ArrayList<String>();
Đây là chuyện generics không hiệp biến — List<String> không phải một loại của List<Object>, dù String là một loại của Object.
Nghe phản trực giác, nhưng nó chính là bài học từ ArrayStoreException ở bài về mảng: mảng hiệp biến nên lỗi bị đẩy tới lúc chạy. Generics chọn cách ngược lại — chặn ngay lúc biên dịch.
Nếu thật sự cần một danh sách "chứa String nhưng đọc như Object", đó là việc của ký tự đại diện — chuyện của ngày mai.
Lớp generic và phương thức generic
Lớp generic — tham số kiểu khai ở tên lớp:
static class Hop<T> {
private T noiDung;
void dat(T x) { noiDung = x; }
T lay() { return noiDung; }
}
Phương thức generic — tham số kiểu khai riêng, trước kiểu trả về:
static <A, B> Map.Entry<A, B> cap(A a, B b) { return Map.entry(a, b); }
Hop<String>.lay() : XIN CHÀO
cap("tuoi", 30) : tuoi=30 (Entry<String,Integer>)
Chú ý là bạn không phải viết Gen.<String,Integer>cap(...) — trình biên dịch suy ra từ đối số. Chỉ khi suy luận không đủ thông tin mới cần chỉ định tường minh.
Phương thức generic dùng được cả trong lớp không generic, và đó là lựa chọn đúng khi tham số kiểu chỉ liên quan tới một phương thức chứ không tới trạng thái của cả lớp.
Toán tử kim cương
Map<String, List<Integer>> m = new HashMap<String, List<Integer>>(); // Java 5
Map<String, List<Integer>> m = new HashMap<>(); // Java 7+
Dấu <> bảo trình biên dịch suy ra kiểu từ vế trái. Với kiểu lồng nhau nhiều tầng thì nó tiết kiệm đáng kể.
Cẩn thận với var: var ds = new ArrayList<>(); cho ra ArrayList<Object> — vì không có vế trái để suy ra. Dùng var thì phải ghi kiểu ở vế phải: var ds = new ArrayList<String>();.
Quy ước đặt tên
Một chữ cái viết hoa, theo quy ước chung của Java:
| Chữ | Nghĩa |
|---|---|
T |
Type — kiểu chung |
E |
Element — phần tử của collection |
K, V |
Key, Value |
R |
Result — kiểu trả về |
A, B, U, S |
khi cần nhiều tham số |
Đây chỉ là quy ước, nhưng theo nó thì người đọc hiểu ngay. Đặt tên dài như <KieuPhanTu> không sai nhưng làm chữ ký khó đọc.
Vài chỗ hay vấp
Không tạo được thể hiện của tham số kiểu. new T() không biên dịch — vì lúc chạy T không còn tồn tại, chuyện của bài xoá kiểu. Cách vòng là truyền vào một Supplier<T>.
Không tạo được mảng của tham số kiểu. new T[10] cũng không. Dùng List<T>, hoặc ép kiểu từ Object[] kèm @SuppressWarnings.
Không dùng kiểu nguyên thuỷ. Không có List<int> — phải List<Integer>, với chi phí đóng hộp như bài kiểu dữ liệu đã nói. Project Valhalla đang giải quyết chuyện này, nhưng chưa có trong Java 21.
Tham số kiểu không dùng cho static của lớp generic: static T tao() trong Hop<T> không hợp lệ, vì T thuộc về từng thể hiện. Phải khai riêng: static <U> U tao().
Muốn tự thấy lý do generics ra đời trong ba mươi giây: viết một List kiểu thô, thêm vào đó một String và một Integer, rồi duyệt và ép kiểu về String. Chạy. Dòng đầu in ra bình thường, rồi ClassCastException — lỗi xuất hiện ở vòng lặp, không phải ở chỗ thêm dữ liệu sai. Đúng cái thùng không nhãn: mọi thứ ổn cho tới lúc thò tay lấy ra.
Mẫu số chung
Cái nhãn dán trên thùng — "chỉ đựng cốc" — có tên học thuật là đa hình tham số, và gần như mọi ngôn ngữ tĩnh hiện đại đều có nó, chỉ khác nhau ở một điểm sâu: cái nhãn có còn lúc chạy hay không. Java xoá kiểu: List<String> lúc chạy chỉ là List, nhãn bong mất — nên new T() và new T[] không làm được, đúng ba cái vấp ở trên. TypeScript còn xoá triệt để hơn: kiểu biến mất hoàn toàn khi biên dịch xuống JavaScript. Ở phía ngược lại, C# giữ nguyên nhãn (reified) — lúc chạy vẫn biết List<int> là gì, nên new T() chạy được và không có chi phí đóng hộp cho kiểu giá trị. C++ với template và Rust với generic thì đơn hình hoá: trình biên dịch sinh một bản mã riêng cho mỗi kiểu cụ thể — nhanh nhất, nhưng phình mã. Go mãi 1.18 mới có generic, và diễn đạt biên bằng interface — đúng cái <T extends Number> của Java, chỉ khác cú pháp [T constraints.Ordered].
Điểm chung, và là thứ đáng mang theo, gồm hai vế. Vế mục đích: generic dời việc kiểm kiểu từ lúc chạy về lúc biên dịch — nó không làm mã chạy khác đi, nó làm cái sai bị bắt sớm hơn, ngay tại dòng gây ra nó, thay vì nổ ra ở một ClassCastException xa lắc về sau. Vế đánh đổi: erasure (Java, TS) đổi sự đơn giản và tương thích lấy việc mất kiểu lúc chạy — mọi thứ bạn "không làm được với T" đều truy về đây; còn reification/monomorphization (C#, C++, Rust) giữ kiểu lúc chạy nhưng trả giá bằng phình mã hoặc runtime phức tạp hơn. Biết ngôn ngữ mình dùng nằm ở phía nào của lằn ranh đó là biết trước những gì generic của nó cho phép và cấm.
Ngày mai: ký tự đại diện và quy tắc PECS — extends để đọc, super để ghi, và vì sao Collections.copy lại có chữ ký trông kỳ quặc như vậy.