Chương 09 · Generics

Generics — kiểu tổng quát

Generics cho phép viết code làm việc với nhiều kiểu khác nhau mà vẫn giữ được kiểm tra kiểu lúc biên dịch. Ra đời ở Java 5, nó biến List từ "hộp chứa Object phải ép kiểu bằng tay" thành List<String> an toàn. Nhưng để giữ tương thích ngược với code cũ, Java chọn một cách cài đặt đặc biệt gọi là type erasure — và gần như mọi điều kỳ lạ của generics đều bắt nguồn từ đó.

Độ dài: ~1500 dòng Bài tập: 5 Quiz: 8 Prerequisites: Chương 1-8
🎯 Mục tiêu chương
  • Hiểu vấn đề generics giải quyết — và code Java trước năm 2004 trông thế nào.
  • Viết generic class và generic method.
  • Dùng bounded type parameter: <T extends Comparable<T>>.
  • Nắm wildcard: ?, ? extends T, ? super T.
  • Áp dụng nguyên tắc PECS — Producer Extends, Consumer Super.
  • Hiểu type erasure và mọi hệ quả của nó.
  • Biết vì sao List<String> không phải con của List<Object> — tính bất biến (invariance).
  • Xử lý đúng raw type và cảnh báo "unchecked".
  • Biết vì sao không tạo được mảng generic và cách JDK lách qua.
  • Đối chiếu với generics của TypeScript và type hints của Python.

1. Vấn đề mà generics giải quyết

TruocJava5.java — code Java trước năm 2004
// ❌ Không có generics: List chứa Object
List ds = new ArrayList();
ds.add("Xin chào");
ds.add(42);              // ⚠️ trình biên dịch không phản đối gì
ds.add(new SinhVien());   // ⚠️ cũng được luôn

String s = (String) ds.get(0);   // phải ép kiểu bằng tay
String t = (String) ds.get(1);   // 💥 ClassCastException LÚC CHẠY

// Vấn đề: lỗi chỉ lộ ra khi chương trình ĐANG CHẠY, có khi ở máy khách hàng


// ✅ Với generics: lỗi bị bắt ngay LÚC BIÊN DỊCH
List<String> ds2 = new ArrayList<>();
ds2.add("Xin chào");
// ds2.add(42);          // ❌ error: incompatible types: int cannot be
                         //    converted to String

String s2 = ds2.get(0);       // ✅ không cần ép kiểu
Generics đổi lỗi lúc chạy thành lỗi lúc biên dịch. Đó là toàn bộ mục đích của nó. Một ClassCastException ở máy khách hàng lúc 2 giờ sáng đắt hơn rất nhiều so với một dòng đỏ trong IDE.

2. Generic class

GenericClass.java
// <T> là THAM SỐ KIỂU — một chỗ trống sẽ được điền khi dùng
public class Hop<T> {
    private T noiDung;

    public void dat(T x)  { this.noiDung = x; }
    public T lay()      { return noiDung; }
    public boolean rong() { return noiDung == null; }
}

Hop<String> h1 = new Hop<>();     // <> = diamond, Java 7+ tự suy ra
h1.dat("Xin chào");
String s = h1.lay();               // không cần ép kiểu ✅
// h1.dat(42);                       // ❌ lỗi biên dịch

Hop<Integer> h2 = new Hop<>();
h2.dat(42);

// --- Nhiều tham số kiểu ---
public class Cap<A, B> {
    private final A dau;
    private final B sau;

    public Cap(A dau, B sau) { this.dau = dau; this.sau = sau; }
    public A dau() { return dau; }
    public B sau() { return sau; }

    // Đổi chỗ — trả về Cap với thứ tự kiểu ngược lại
    public Cap<B, A> doiCho() { return new Cap<>(sau, dau); }
}

Cap<String, Integer> c = new Cap<>("tuoi", 22);
Cap<Integer, String> d = c.doiCho();

// ✅ Java 16+: record generic — gọn hơn hẳn
public record Cap<A, B>(A dau, B sau) {
    public Cap<B, A> doiCho() { return new Cap<>(sau, dau); }
}
Ký tựQuy ước dùng choVí dụ trong JDK
TType — kiểu bất kỳList<T>, Optional<T>
EElement — phần tử của collectionCollection<E>, Set<E>
K, VKey, ValueMap<K,V>
NNumber
RReturn — kiểu trả vềFunction<T,R>
S, U, VKiểu thứ 2, 3, 4BiFunction<T,U,R>

3. Generic method

GenericMethod.java
// <T> đặt TRƯỚC kiểu trả về — đây là điểm hay quên
public static <T> void in(List<T> ds) {
    for (T x : ds) System.out.println(x);
}

public static <T> T phanTuDau(List<T> ds) {
    return ds.isEmpty() ? null : ds.get(0);
}

// Nhiều tham số kiểu
public static <K, V> Map<V, K> daoNguoc(Map<K, V> m) {
    Map<V, K> kq = new HashMap<>();
    m.forEach((k, v) -> kq.put(v, k));
    return kq;
}

// Java tự SUY RA kiểu từ tham số — không cần nói
in(List.of("a", "b"));                    // T = String
Integer n = phanTuDau(List.of(1, 2, 3));   // T = Integer

// Chỉ định tường minh khi cần (hiếm)
List<String> rong = Collections.<String>emptyList();
📌 Generic method khác generic class

Một class không generic vẫn có thể chứa generic method, và ngược lại — một generic class có thể có method với tham số kiểu riêng, độc lập với tham số kiểu của class. Khi method có tham số kiểu riêng, tham số đó chỉ tồn tại trong phạm vi lời gọi method đó.

PhamVi.java
class Hop<T> {              // T của CLASS
    private T noiDung;

    <U> Cap<T, U> ghepVoi(U khac) {   // U của METHOD, độc lập
        return new Cap<>(noiDung, khac);
    }
}

4. Bounded type parameter — giới hạn kiểu

Bounded.java
// ❌ T bất kỳ → không gọi được method nào ngoài của Object
static <T> T lonNhat(List<T> ds) {
    T max = ds.get(0);
    for (T x : ds)
        if (x.compareTo(max) > 0) max = x;   // ❌ error: cannot find symbol
    return max;
}

// ✅ Giới hạn trên: T phải cài đặt Comparable<T>
static <T extends Comparable<T>> T lonNhat(List<T> ds) {
    T max = ds.get(0);
    for (T x : ds)
        if (x.compareTo(max) > 0) max = x;   // ✅ giờ gọi được
    return max;
}
lonNhat(List.of(3, 1, 4));         // 4
lonNhat(List.of("b", "a", "c"));   // "c"
// lonNhat(List.of(new Object()));  // ❌ Object không cài Comparable

// --- Giới hạn bằng class ---
static <T extends Number> double tong(List<T> ds) {
    double t = 0;
    for (T x : ds) t += x.doubleValue();   // method của Number
    return t;
}

// --- NHIỀU giới hạn: dùng &, class phải đứng ĐẦU ---
static <T extends Number & Comparable<T> & Serializable> T xuLy(T x) {
    ...
}

// --- Giới hạn đệ quy — mẫu hay gặp trong JDK ---
public abstract class Enum<E extends Enum<E>> implements Comparable<E> { }
// Nghĩa: "E là một enum có thể so sánh với chính loại E của nó"
// → mỗi enum chỉ so sánh được với enum cùng loại, không so chéo

5. Generic là bất biến (invariant)

BatBien.java
// Mảng thì HIỆP BIẾN (covariant) — cha con truyền được
Object[] mang = new String[3];       // ✅ biên dịch được
mang[0] = 42;                        // 💥 ArrayStoreException LÚC CHẠY

// Generic thì BẤT BIẾN (invariant) — bắt lỗi ngay lúc biên dịch
// List<Object> ds = new ArrayList<String>();
//   ❌ error: incompatible types

Vì sao Java cố tình làm generic bất biến

Giả sử nó được phép — hãy xem chuyện gì sẽ xảy ra:

GiaSu.java
List<String> chuoi = new ArrayList<>();
List<Object> obj = chuoi;      // giả sử được phép...
obj.add(42);                    // hợp lệ với List<Object>
String s = chuoi.get(0);        // 💥 lấy được Integer từ List<String>!

Điều đó phá vỡ toàn bộ lời hứa an toàn kiểu của generics. Nên Java cấm hẳn ở bước biên dịch.

Mảng thì hiệp biến vì lý do lịch sử: nó có trước generics (Java 1.0), và cần thế để viết được các hàm như Arrays.sort(Object[]). Cái giá phải trả là ArrayStoreException lúc chạy — chính xác là loại lỗi mà generics sinh ra để loại bỏ. Đây cũng là lý do không nên trộn mảng với generic.

6. Wildcard ?

Tính bất biến an toàn nhưng cứng nhắc: một method nhận List<Number> sẽ không nhận được List<Integer>. Wildcard nới lỏng điều đó một cách có kiểm soát.

Wildcard.java
// ① ? — kiểu bất kỳ, không biết là gì
static void inSoLuong(List<?> ds) {
    System.out.println(ds.size());
    for (Object o : ds) System.out.println(o);   // đọc ra Object thì được
    // ds.add("x");   // ❌ KHÔNG thêm được gì (trừ null)
}
inSoLuong(List.of("a"));      // ✅
inSoLuong(List.of(1, 2));    // ✅ nhận được mọi List


// ② ? extends T — giới hạn TRÊN: "T hoặc bất kỳ con nào của T"
static double tong(List<? extends Number> ds) {
    double t = 0;
    for (Number n : ds) t += n.doubleValue();   // ✅ ĐỌC được (là Number)
    // ds.add(1);      // ❌ KHÔNG GHI được — không biết chính xác kiểu gì
    return t;
}
tong(List.of(1, 2, 3));         // List<Integer> ✅
tong(List.of(1.5, 2.5));       // List<Double>  ✅


// ③ ? super T — giới hạn DƯỚI: "T hoặc bất kỳ cha nào của T"
static void themSo(List<? super Integer> ds) {
    ds.add(1);                // ✅ GHI được (Integer chắc chắn vừa)
    ds.add(2);
    Object o = ds.get(0);      // ⚠️ đọc ra chỉ chắc là Object
    // Integer i = ds.get(0);   // ❌ có thể là List<Number> hoặc List<Object>
}
themSo(new ArrayList<Integer>());   // ✅
themSo(new ArrayList<Number>());    // ✅
themSo(new ArrayList<Object>());    // ✅
DạngĐọc được gìGhi được gìDùng khi
List<T>TTVừa đọc vừa ghi cùng một kiểu
List<? extends T>T ✅❌ chỉ nullNguồn — chỉ đọc dữ liệu ra
List<? super T>Object ⚠️T ✅Đích — chỉ ghi dữ liệu vào
List<?>Object❌ chỉ nullKhông quan tâm kiểu (đếm, kiểm tra rỗng)

6.1. PECS — Producer Extends, Consumer Super

PECS — nếu tham số là nguồn cung cấp dữ liệu (Producer), dùng ? extends. Nếu là đích nhận dữ liệu (Consumer), dùng ? super. Nếu vừa đọc vừa ghi, không dùng wildcard.
PECS.java — chính JDK dùng nguyên tắc này
// Chữ ký thật của Collections.copy trong JDK:
public static <T> void copy(List<? super T> dich,      // CONSUMER → super
                               List<? extends T> nguon) { // PRODUCER → extends
    for (int i = 0; i < nguon.size(); i++)
        dich.set(i, nguon.get(i));
}

// Nhờ vậy lời gọi này hợp lệ:
List<Object>  dich  = new ArrayList<>(Collections.nCopies(3, null));
List<Integer> nguon = List.of(1, 2, 3);
Collections.copy(dich, nguon);      // ✅ Object ⊃ Integer


// --- Ví dụ tự viết ---
static <T> void chuyenHet(Collection<? extends T> tu,   // đọc → extends
                             Collection<? super T> den) {   // ghi → super
    for (T x : tu) den.add(x);
}

List<Integer> a = List.of(1, 2, 3);
List<Number>  b = new ArrayList<>();
chuyenHet(a, b);                    // ✅ Integer → Number


// --- Comparator cũng theo PECS ---
// Chữ ký thật của List.sort:
void sort(Comparator<? super E> c);
// Comparator TIÊU THỤ phần tử → super
// Nhờ vậy Comparator<Object> dùng được cho List<String>

Comparator<Object> theoChuoi = Comparator.comparing(Object::toString);
List<String> ds = new ArrayList<>(List.of("b", "a"));
ds.sort(theoChuoi);                  // ✅ nhờ ? super E
💡 Cách nhớ PECS

Producer Extends — bạn lấy dữ liệu ra từ nó, nên chỉ cần biết dữ liệu đó ít nhất là T. Ghi vào thì không an toàn, vì bạn không biết chính xác nó là List<Integer> hay List<Double>.

Consumer Super — bạn đưa dữ liệu vào nó, nên nó phải chứa được ít nhất T. Đọc ra thì chỉ chắc chắn được là Object.

Một cách nhớ khác: "extends thì đọc, super thì ghi". Hoặc nhìn vào hướng mũi tên trong cây kế thừa: extends nhìn xuống (nhận mọi con), super nhìn lên (nhận mọi cha).

7. Type erasure — chìa khoá của mọi điều kỳ lạ

Java cài đặt generics bằng cách xoá thông tin kiểu sau khi biên dịch. Bytecode sinh ra gần như giống hệt code Java 1.4 không có generics.

Mã nguồn
List<String> ds;
String s = ds.get(0);
javac kiểm tra
Kiểm tra kiểu đầy đủ
rồi XOÁ
Bytecode
List ds;
String s = (String) ds.get(0);
Trình biên dịch kiểm tra kiểu rất kỹ, sau đó xoá sạch và tự chèn phép ép kiểu. Lúc chạy, JVM chỉ thấy List trần.
Erasure.java — quy tắc xoá
// Trước khi xoá                        Sau khi xoá
List<String>                    →     List
Map<String, Integer>           →     Map
<T> void f(T x)                 →     void f(Object x)
<T extends Number> void g(T x) →     void g(Number x)   // thay bằng GIỚI HẠN

// Kiểm chứng bằng javap:
// $ javap -c Hop.class
//   public java.lang.Object lay();     ← T đã biến thành Object

7.1. Bảy hệ quả của type erasure

HeQua.java
// ① Mọi List<X> là CÙNG MỘT class lúc chạy
List<String>  a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass());   // true
System.out.println(a.getClass());                   // class java.util.ArrayList


// ② Không dùng instanceof với kiểu tham số
// if (x instanceof List<String>)   // ❌ error: illegal generic type
if (x instanceof List<?>) { }              // ✅ chỉ kiểm tra được đến đây


// ③ Không tạo được instance của T
class Hop<T> {
    // T tao() { return new T(); }   // ❌ error: type parameter T cannot be
                                     //    instantiated directly

    // ✅ Giải pháp 1: truyền Class<T> vào (gọi là "type token")
    T tao(Class<T> cls) throws Exception {
        return cls.getDeclaredConstructor().newInstance();
    }
    // ✅ Giải pháp 2: truyền factory — cách hiện đại, sạch hơn
    T tao(Supplier<T> factory) { return factory.get(); }
}


// ④ Không tạo được MẢNG generic
// T[] m = new T[10];                    // ❌
// List<String>[] ds = new List<String>[10];  // ❌ generic array creation

// ✅ Cách JDK lách (chính ArrayList làm y hệt):
@SuppressWarnings("unchecked")
T[] m = (T[]) new Object[10];
// ✅ Hoặc dùng List<T> thay mảng — luôn tốt hơn


// ⑤ Không dùng kiểu nguyên thủy làm tham số kiểu
// List<int> ds;      // ❌ — vì sau khi xoá, T thành Object,
                      //    mà int không phải Object
List<Integer> ds;      // ✅ phải dùng wrapper


// ⑥ Không nạp chồng method chỉ khác ở tham số kiểu
class X {
    void f(List<String> a) { }
    // void f(List<Integer> a) { }   // ❌ error: name clash — both erase to f(List)
}


// ⑦ Không tạo được exception generic
// class MyException<T> extends Exception { }   // ❌
// catch (MyException<String> e)                // không phân biệt được lúc chạy

Vì sao Java chọn erasure — và cái giá phải trả

Năm 2004, đã có hàng triệu dòng Java 1.4 đang chạy trong production. Nếu generics đổi định dạng .class, mọi thư viện cũ sẽ ngừng hoạt động. Erasure cho phép tương thích hai chiều: code Java 5 chạy với thư viện cũ, và ngược lại.

Cái giá: mất thông tin kiểu lúc chạy, dẫn tới bảy hạn chế ở trên. C# (ra sau, không có gánh nặng tương thích) chọn reified generics — giữ nguyên thông tin kiểu, nên typeof(List<int>) khác typeof(List<string>) và new T() hoạt động.

Dự án Valhalla của OpenJDK đang nghiên cứu đưa reified generics vào Java, nhưng đó là bài toán rất khó và chưa có mốc thời gian.

8. Raw type và cảnh báo "unchecked"

RawType.java
// RAW TYPE — generic class dùng mà không có tham số kiểu
List raw = new ArrayList();   // ⚠️ chỉ tồn tại vì tương thích ngược
raw.add("a");
raw.add(42);                // không ai ngăn
String s = (String) raw.get(1);   // 💥 ClassCastException

// Raw type LÂY LAN — vô hiệu hoá generics ở mọi nơi nó đi qua
List raw2 = new ArrayList<String>();
raw2.add(42);               // ⚠️ biên dịch được, chỉ cảnh báo unchecked!

// ✅ Không biết kiểu gì? Dùng wildcard, KHÔNG dùng raw type
List<?> anToan = new ArrayList<String>();
// anToan.add(42);       // ❌ bị chặn ✅


// --- @SuppressWarnings: dùng đúng cách ---

// ❌ Đặt trên cả method — che luôn cảnh báo khác
@SuppressWarnings("unchecked")
public void phuongThucDai() {
    ... 50 dòng ...
}

// ✅ Thu hẹp tới đúng dòng cần, kèm comment giải thích VÌ SAO an toàn
public <T> T[] toArray(T[] a) {
    // An toàn: mảng được tạo từ chính component type của a
    @SuppressWarnings("unchecked")
    T[] kq = (T[]) Arrays.copyOf(mang, soLuong, a.getClass());
    return kq;
}
⚠️ Đừng bao giờ dùng raw type trong code mới

Raw type chỉ tồn tại để code Java 1.4 vẫn biên dịch được. Dùng nó là tự nguyện từ bỏ toàn bộ lợi ích của generics, và tệ hơn — nó lây lan: một biến raw type khiến mọi thao tác trên nó mất kiểm tra kiểu, kể cả khi đối tượng gốc được tạo có generic đầy đủ.

Bật -Xlint:rawtypes,unchecked khi biên dịch để thấy hết cảnh báo, và coi chúng như lỗi.

9. Thực hành tốt

ThucHanhTot.java
// ✅ ① Ưu tiên List<T> hơn mảng T[]
// Mảng hiệp biến + generic bất biến = không trộn được an toàn.
// List an toàn hơn, linh hoạt hơn, và có sẵn nhiều method.

// ✅ ② Dùng wildcard cho THAM SỐ, không dùng cho KIỂU TRẢ VỀ
static double tong(List<? extends Number> ds) { ... }   // ✅ linh hoạt
static List<? extends Number> lay() { ... }             // ❌ ép người gọi
                                                             //    cũng phải dùng wildcard

// ✅ ③ Tên tham số kiểu theo quy ước: T, E, K, V, R

// ✅ ④ Generic method thay wildcard khi hai tham số phải CÙNG kiểu
static void doiCho(List<?> ds, int i, int j) {
    // ds.set(i, ds.get(j));   // ❌ không ghi được vào List<?>
}
static <T> void doiCho(List<T> ds, int i, int j) {   // ✅
    T tam = ds.get(i);
    ds.set(i, ds.get(j));
    ds.set(j, tam);
}

// ✅ ⑤ Kiểu trả về generic tránh phải ép kiểu
static <T> List<T> loc(List<T> ds, Predicate<? super T> dk) {
    List<T> kq = new ArrayList<>();
    for (T x : ds) if (dk.test(x)) kq.add(x);
    return kq;
}
List<String> dai = loc(List.of("a", "abc"), s -> s.length() > 1);

// ✅ ⑥ Bounded type khi cần gọi method của kiểu đó
static <T extends Comparable<? super T>> T max(List<? extends T> ds) { ... }
// ↑ Đây là chữ ký thật của Collections.max — PECS áp dụng ở CẢ HAI chỗ
🔁 Đối chiếu với TypeScript & Python
JavaTypeScriptPython
Cú phápList<T>Array<T>list[T]
Tồn tại lúc chạy❌ Bị xoá❌ Bị xoá❌ Chỉ là chú thích
Kiểm traLúc biên dịchLúc biên dịchChỉ khi chạy mypy
Giới hạn trên<T extends X><T extends X>TypeVar("T", bound=X)
Hiệp biến / nghịch biếnWildcard tại nơi dùngSuy ra theo cấu trúccovariant=True tại nơi khai báo
Bắt buộc?Không, nhưng nênKhông, nhưng nênHoàn toàn tuỳ chọn

Điểm khác biệt lớn nhất: Java đặt wildcard ở nơi sử dụng (List<? extends T> trong chữ ký method), còn Kotlin/C#/Python đặt ở nơi khai báo (out T / in T). Cách của Java linh hoạt hơn nhưng dài dòng hơn — bạn phải viết PECS ở mọi chữ ký, thay vì khai báo một lần trong định nghĩa kiểu.

10. Cheat sheet chương 9

Tình huốngCách viết
Generic classclass Hop<T> { }
Generic methodstatic <T> T f(List<T> ds) — <T> trước kiểu trả về
Giới hạn trên<T extends Number>
Nhiều giới hạn<T extends Number & Comparable<T>> — class đứng đầu
Tham số chỉ để đọcList<? extends T> (Producer)
Tham số chỉ để ghiList<? super T> (Consumer)
Vừa đọc vừa ghiList<T> — không wildcard
Không quan tâm kiểuList<?>
Không biết kiểu gìList<?> — không bao giờ raw List
Cần tạo instance của TTruyền Supplier<T> hoặc Class<T>
Cần mảng genericDùng List<T>; bất đắc dĩ thì (T[]) new Object[n]
Kiểm tra kiểu lúc chạyx instanceof List<?> — không thể sâu hơn
Ép kiểu unchecked@SuppressWarnings("unchecked") ở hẹp nhất + comment
Kiểu trả vềCụ thể (List<T>), không wildcard
Bật cảnh báojavac -Xlint:unchecked,rawtypes

Bài tập

Bộ sưu tập generic tự viết

Cài đặt ba cấu trúc generic từ đầu:

API
class NganXep<T>  { void push(T x); T pop(); T peek(); boolean rong(); int size(); }
class HangDoi<T>  { void offer(T x); T poll(); T peek(); boolean rong(); }
class Cay<T extends Comparable<T>> { void them(T x); boolean chua(T x); List<T> duyetGiua(); }
  • NganXep dùng mảng — giải quyết vấn đề không tạo được new T[], giải thích cách bạn chọn.
  • HangDoi dùng đệm vòng (circular buffer), tự nới rộng.
  • Cay là cây tìm kiếm nhị phân, cần T so sánh được.
  • Cả ba đều implements Iterable<T>.
  • Thêm generic method static <T> NganXep<T> tuList(List<? extends T> ds) — chú ý wildcard.
Gợi ý

Hai cách xử lý mảng generic: (a) Object[] bên trong, ép kiểu khi lấy ra — đây là cách ArrayList làm; (b) (T[]) new Object[n] — ArrayDeque làm thế này. Cách (a) an toàn hơn vì phép ép kiểu nằm ở method get, nơi trình biên dịch đã chèn sẵn.
Câu cuối minh hoạ vì sao dùng ? extends T: nhờ nó, NganXep<Number> tạo được từ List<Integer>.

Thư viện tiện ích với PECS

Viết class TienIch với các generic method sau. Với mỗi method, quyết định wildcard và giải thích lý do:

Chữ ký cần hoàn thiện
static <T> void chepHet(Collection<___> nguon, Collection<___> dich);
static <T> T max(Collection<___> ds, Comparator<___> cmp);
static <T> List<T> loc(List<___> ds, Predicate<___> dk);
static <T, R> List<R> anhXa(List<___> ds, Function<___, ___> f);
static <T> void voiMoi(Iterable<___> ds, Consumer<___> hanhDong);
static <T> int dem(Collection<___> ds, Predicate<___> dk);
static <T> List<List<T>> chiaNhom(List<___> ds, int kichThuoc);

Sau đó viết test chứng minh tính linh hoạt: chepHet(List<Integer>, List<Number>) phải chạy được.

Gợi ý

Quy tắc: bất kỳ tham số nào bạn chỉ đọc từ → ? extends T; bất kỳ tham số nào bạn chỉ ghi vào → ? super T.
Với Function<? super T, ? extends R>: nó tiêu thụ T (nên super) và sản xuất R (nên extends). Đây chính xác là chữ ký của Stream.map trong JDK — hãy mở mã nguồn ra đối chiếu.

Chứng minh type erasure

Thực hiện các thí nghiệm sau, ghi lại kết quả và giải thích:

  1. Chứng minh new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass().
  2. Biên dịch một class generic rồi chạy javap -c. Chỉ ra chính xác chỗ T biến thành Object, và chỗ trình biên dịch chèn phép ép kiểu.
  3. Dùng phản chiếu để thêm Integer vào một List<String>. Chuyện gì xảy ra khi đọc ra? Ở đâu thì ClassCastException được ném?
  4. Viết hai method void f(List<String>) và void f(List<Integer>). Chép lại thông điệp lỗi và giải thích.
  5. Dùng getClass().getGenericSuperclass() trên một subclass ẩn danh (new TypeToken<List<String>>(){}) — vì sao ở đây thông tin kiểu vẫn còn?
Gợi ý

(3): List.class.getMethod("add", Object.class).invoke(ds, 42) — chạy trót lọt vì lúc chạy chẳng còn kiểu nào. Ngoại lệ chỉ nổ ở dòng String s = ds.get(0) — nơi trình biên dịch đã chèn (String).
(5): thông tin kiểu của lớp cha được ghi vào metadata class file (thuộc tính Signature) và không bị xoá. Đây chính là mẹo đứng sau TypeToken của Guava và TypeReference của Jackson — cách duy nhất để "lấy lại" thông tin generic lúc chạy.

Kho lưu trữ generic (mẫu Repository)

Thiết kế hệ thống lưu trữ generic dùng được cho mọi loại thực thể:

Khung
interface CoId<ID> { ID getId(); }

interface Kho<T extends CoId<ID>, ID> {
    void luu(T x);
    Optional<T> tim(ID id);
    List<T> tatCa();
    boolean xoa(ID id);
    List<T> tim(Predicate<? super T> dk);
    <R> List<R> chieu(Function<? super T, ? extends R> f);
}
  • Cài đặt KhoTrongBoNho<T, ID> dùng HashMap<ID, T>.
  • Dùng thử với hai thực thể: record SinhVien(String getId, String ten) và record DonHang(Long getId, long tongTien).
  • Thêm KhoCoCache<T, ID> bọc một Kho khác — mẫu decorator, dùng composition chứ không kế thừa.
  • Thêm generic method <T extends CoId<ID>, ID> void dongBo(Kho<T,ID> tu, Kho<T,ID> den).
Gợi ý

<T extends CoId<ID>, ID> là ví dụ về tham số kiểu phụ thuộc lẫn nhau: T bị ràng buộc bởi ID. Đây là mẫu Spring Data JPA dùng cho Repository<T, ID>.
Method chieu có tham số kiểu <R> riêng của method, độc lập với T và ID của interface — minh hoạ phạm vi tham số kiểu.

Sửa code vi phạm nguyên tắc generics

Đoạn code sau mắc bảy vấn đề về generics. Tìm, sửa, và giải thích từng cái.

CanSua.java
public class TienIch {

    public static List loc(List ds, Object dieuKien) {
        List kq = new ArrayList();
        for (Object o : ds) {
            if (((Predicate) dieuKien).test(o)) kq.add(o);
        }
        return kq;
    }

    @SuppressWarnings("unchecked")
    public static void themTatCa(List<Object> dich, List<Object> nguon) {
        for (Object o : nguon) dich.add(o);
    }

    public static <T> T[] taoMang(int n) {
        return (T[]) new Object[n];
    }

    public static <T> T lonNhat(List<T> ds) {
        T max = ds.get(0);
        for (T x : ds) if (((Comparable) x).compareTo(max) > 0) max = x;
        return max;
    }

    public static boolean laListChuoi(Object o) {
        return o instanceof List<String>;
    }
}
Gợi ý

Danh sách kiểm: (1) raw type List; (2) Object dieuKien thay vì Predicate<? super T>; (3) ép kiểu raw (Predicate); (4) themTatCa không nhận được List<Integer> — thiếu PECS; (5) @SuppressWarnings đặt sai chỗ (không có cảnh báo nào để chặn); (6) taoMang trả về Object[] đội lốt T[] — ném ClassCastException ngay khi người gọi gán vào String[]; (7) lonNhat thiếu bound, và instanceof List<String> không biên dịch được.

Quiz

Q1

Generics giải quyết vấn đề gì? Nêu lợi ích cụ thể.

Xem đáp án
✓ Đáp án

Nó chuyển lỗi kiểu từ lúc chạy sang lúc biên dịch. Trước Java 5, collection chứa Object: bạn có thể nhét bất cứ gì vào, và mỗi lần lấy ra phải ép kiểu bằng tay — sai thì ClassCastException nổ ở máy khách hàng.

Ba lợi ích cụ thể:

  • An toàn kiểu — ds.add(42) vào List<String> bị chặn ngay trong IDE.
  • Không phải ép kiểu — String s = ds.get(0) thay vì (String) ds.get(0). Code sạch hơn và không có chỗ để sai.
  • Code dùng lại được — viết NganXep<T> một lần thay vì NganXepString, NganXepInteger…

Một lợi ích ít người để ý: generics là tài liệu. Nhìn Map<String, List<DonHang>> là biết ngay cấu trúc dữ liệu, không cần đọc comment.

Q2

Type erasure là gì? Nêu ba hệ quả.

Xem đáp án
✓ Đáp án

Type erasure là cách Java cài đặt generics: trình biên dịch kiểm tra kiểu rất kỹ, sau đó xoá sạch thông tin kiểu và tự chèn phép ép kiểu vào những chỗ cần. Bytecode sinh ra gần như giống hệt code Java 1.4.

Erasure
List<String>        →  List
<T> void f(T x)     →  void f(Object x)
<T extends Number>  →  thay bằng Number

Hệ quả (chọn ba trong bảy):

  • new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass() → true.
  • Không dùng được instanceof List<String> — chỉ tới List<?>.
  • Không tạo được new T() hay new T[n].
  • Không nạp chồng f(List<String>) và f(List<Integer>) — cùng chữ ký sau khi xoá.

Lý do: tương thích ngược. Năm 2004 đã có hàng triệu dòng Java 1.4 đang chạy; erasure cho phép code mới và thư viện cũ dùng chung. C# ra sau nên chọn reified generics — giữ nguyên thông tin kiểu.

Q3

Vì sao List<String> không phải là con của List<Object>?

Xem đáp án
✓ Đáp án

Vì generic trong Java bất biến (invariant) — và đó là quyết định đúng đắn. Giả sử nó được phép:

GiaSu.java
List<String> chuoi = new ArrayList<>();
List<Object> obj = chuoi;   // nếu được phép...
obj.add(42);                 // hợp lệ với List<Object>
String s = chuoi.get(0);     // 💥 Integer trong List<String>

Toàn bộ lời hứa an toàn kiểu sẽ tan vỡ. Nên Java cấm ở bước biên dịch.

Đối lập: mảng thì hiệp biến — Object[] m = new String[3] biên dịch được, nhưng m[0] = 42 ném ArrayStoreException lúc chạy. Mảng có trước generics (Java 1.0) và cần hiệp biến để viết được Arrays.sort(Object[]). Đây cũng là lý do không nên trộn mảng với generic.

Cần linh hoạt? Dùng wildcard: List<? extends Object> nhận được List<String> — nhưng đổi lại, bạn không ghi vào nó được.

Q4

PECS là gì? Cho ví dụ.

Xem đáp án
✓ Đáp án

Producer Extends, Consumer Super — quy tắc chọn wildcard:

  • Producer (bạn đọc dữ liệu ra) → ? extends T. Bạn chắc chắn mọi thứ lấy ra ít nhất là T, nhưng không ghi vào được vì không biết chính xác kiểu gì.
  • Consumer (bạn ghi dữ liệu vào) → ? super T. Bạn chắc chắn nó chứa được T, nhưng đọc ra thì chỉ chắc là Object.
  • Vừa đọc vừa ghi → không dùng wildcard, dùng T thẳng.
PECS.java
// Chữ ký thật trong JDK:
static <T> void copy(List<? super T> dich,       // consumer
                      List<? extends T> nguon)    // producer

void sort(Comparator<? super E> c);           // comparator tiêu thụ E
<R> Stream<R> map(Function<? super T, ? extends R> f);  // cả hai!

Lợi ích thực tế: nhờ PECS, Collections.copy(List<Object>, List<Integer>) hợp lệ, và một Comparator<Object> dùng được để sắp List<String>.

Q5

Vì sao không tạo được new T[10]?

Xem đáp án
✓ Đáp án

Vì mảng giữ thông tin kiểu lúc chạy (để kiểm tra ArrayStoreException), còn generic thì bị xoá. Hai cơ chế mâu thuẫn nhau: JVM không biết phải tạo mảng kiểu gì khi T đã biến mất.

Ba cách xử lý, theo thứ tự nên dùng:

GiaiPhap.java
// ✅ 1. Dùng List<T> thay mảng — lựa chọn tốt nhất
List<T> ds = new ArrayList<>();

// ✅ 2. Object[] bên trong, ép kiểu khi lấy ra (ArrayList làm thế này)
private Object[] mang = new Object[10];
@SuppressWarnings("unchecked")
T get(int i) { return (T) mang[i]; }

// ⚠️ 3. Truyền Class<T> để tạo mảng đúng kiểu qua phản chiếu
@SuppressWarnings("unchecked")
T[] tao(Class<T> cls, int n) {
    return (T[]) Array.newInstance(cls, n);
}

Cách (2) có một bẫy: (T[]) new Object[n] rồi trả về cho người gọi sẽ ném ClassCastException ngay khi họ gán vào String[] — vì object thật vẫn là Object[]. Chỉ an toàn khi mảng nằm hoàn toàn bên trong class.

Q6

Raw type là gì? Vì sao không nên dùng?

Xem đáp án
✓ Đáp án

Raw type là dùng generic class mà không có tham số kiểu: List ds = new ArrayList();. Nó chỉ tồn tại để code Java 1.4 vẫn biên dịch được.

Vì sao tránh:

  • Mất hoàn toàn an toàn kiểu — nhét gì vào cũng được, lỗi nổ lúc chạy.
  • Lây lan — đây là điểm nguy hiểm nhất: gán một ArrayList<String> vào biến raw type rồi thao tác qua biến đó là mất hết kiểm tra, dù object gốc có generic đầy đủ.
  • Phải ép kiểu bằng tay ở mọi chỗ đọc.
Raw.java
List raw = new ArrayList<String>();
raw.add(42);           // ⚠️ chỉ CẢNH BÁO, vẫn biên dịch

List<?> anToan = new ArrayList<String>();
// anToan.add(42);   // ❌ bị chặn ✅

Thay thế: không biết kiểu gì thì dùng List<?> (wildcard), không bao giờ dùng raw. Bật -Xlint:rawtypes,unchecked và coi cảnh báo như lỗi.

Q7

<T extends Number> và <? extends Number> khác nhau thế nào?

Xem đáp án
✓ Đáp án

<T extends Number> là tham số kiểu có giới hạn — nó đặt tên cho kiểu. Nhờ có tên, bạn dùng lại T ở nhiều chỗ và đảm bảo chúng là cùng một kiểu. Ghi vào được.

<? extends Number> là wildcard — kiểu vô danh. Không dùng lại được, và không ghi vào được (trừ null).

SoSanh.java
// Cần T để đảm bảo hai list CÙNG kiểu và để ghi vào
static <T extends Number> void chep(List<T> a, List<T> b) {
    a.addAll(b);     // ✅ ghi được
}

// Chỉ đọc → wildcard gọn hơn và linh hoạt hơn
static double tong(List<? extends Number> ds) {
    double t = 0;
    for (Number n : ds) t += n.doubleValue();
    // ds.add(1);   // ❌ không ghi được
    return t;
}

Quy tắc chọn: nếu kiểu chỉ xuất hiện một lần trong chữ ký và bạn chỉ đọc → wildcard. Nếu cần dùng lại tên kiểu, hoặc cần ghi vào → tham số kiểu có tên.

Q8

Vì sao List<int> không hợp lệ?

Xem đáp án
✓ Đáp án

Vì type erasure. Sau khi xoá, mọi tham số kiểu trở thành Object (hoặc giới hạn của nó). Mà int là kiểu nguyên thủy — nó không phải object, không kế thừa Object, nên không thể được thay bằng Object.

Giải pháp: dùng wrapper — List<Integer>. Java tự autoboxing/unboxing nên viết vẫn tự nhiên.

Cái giá phải trả:

  • Bộ nhớ — List<Integer> tốn khoảng 4 lần so với int[] (mỗi số là một object có header, cộng thêm một tham chiếu).
  • Tốc độ — chậm hơn 4-6 lần do phải truy cập qua con trỏ và tạo rác cho GC.
  • Bẫy NullPointerException — int x = map.get(k) nổ khi khoá không tồn tại.

Vì vậy với dữ liệu số lượng lớn, mảng nguyên thủy hoặc IntStream (chương 10) vẫn tốt hơn. Dự án Valhalla đang nghiên cứu "generic đặc hoá" để cho phép List<int> thật, nhưng chưa có mốc thời gian.

Tổng kết

Sau chương 9, bạn đã nắm:

  • Mục đích: chuyển lỗi kiểu từ lúc chạy sang lúc biên dịch, bỏ ép kiểu thủ công.
  • Generic class & method; quy ước tên T, E, K, V, R.
  • Bounded type: <T extends X>, nhiều giới hạn với &, giới hạn đệ quy.
  • Bất biến: List<String> không phải con của List<Object> — và vì sao điều đó đúng.
  • Wildcard: ?, ? extends T (đọc), ? super T (ghi).
  • PECS — Producer Extends, Consumer Super; chính JDK dùng nguyên tắc này.
  • Type erasure và bảy hệ quả: không instanceof sâu, không new T(), không mảng generic, không nạp chồng theo tham số kiểu…
  • Raw type lây lan và vô hiệu hoá generics — không bao giờ dùng.
  • @SuppressWarnings đặt hẹp nhất có thể, kèm giải thích.
  • Đối chiếu với TypeScript (cũng xoá kiểu) và Python (chú thích tuỳ chọn).

Kết nối

  • Chương 7-8 (Collections & Map) — toàn bộ framework là generic; giờ bạn hiểu vì sao ArrayList dùng Object[] bên trong.
  • Chương 10 (Lambda & Stream) — Function<T,R>, Predicate<T>, Stream<T>, và chữ ký PECS ở khắp nơi.
  • Chương 5 (OOP nâng cao) — record Cap<A,B>, sealed interface KetQua<T>.
  • Chương 12 (Đa luồng) — Future<T>, CompletableFuture<T>, Callable<V>.
  • Chương 13 (JDBC) — mẫu Repository<T, ID>.
  • Chương 14 (Kiểm thử) — assertThrows(Class<T>, …), matcher generic của Mockito.
  • JavaScript Chương 10 — đối chiếu: TypeScript cũng xoá kiểu, nhưng có kiểu điều kiện và mapped type mà Java không có.
  • Python Chương 12 — TypeVar, Generic[T], và cú pháp PEP 695 (Python 3.12) gần giống Java.