Generics vào Java năm 2004, và lý do tồn tại của nó gói gọn trong một phép so sánh.

Trước generics, mọi collection chứa Object:

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.

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ếnList<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().

Thử 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. Bạn sẽ thấ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. Ba mươi giây đó tóm tắt toàn bộ lý do generics ra đời.

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.