Java OO & Functional/Bounded Types và Generic Invariance — vì sao List<Integer> không phải List<Number>
23/33
Bài 23 / 33~22 phútGenerics & CollectionsMiễn phí lượt xem

Bounded Types và Generic Invariance — vì sao List<Integer> không phải List<Number>

Upper bounded type parameters, multiple bounds, generic invariance vs array covariance, workaround qua wildcard, và cách JVM xử lý erasure đến bound thay vì Object.

TL;DR: Bounded type parameter <T extends Number> giới hạn T chỉ được là Number hoặc subtype của nó — giúp gọi T.doubleValue() mà không cần cast. Generic invariance nghĩa là List<Integer> KHÔNG phải subtype của List<Number>Integer extends Number — Java chủ đích thiết kế vậy để tránh type safety break lúc runtime. Ngược lại, Java arrays là covariant (Integer[] có thể gán cho Number[]) nhưng đây là thiết kế cũ có lỗ hổng (ArrayStoreException). Bài này giải thích invariance từ góc độ type safety, cách workaround qua wildcard, và khi nào bounded type parameter ảnh hưởng bytecode.

1. Scenario — hàm tính tổng generic

Bạn muốn viết một hàm tính tổng các phần tử trong List. Thử với type parameter không bị giới hạn:

public static <T> double sum(List<T> list) {
    double total = 0;
    for (T item : list) {
        total += item.doubleValue();  // compile error: T khong co method doubleValue()
    }
    return total;
}

Compiler từ chối vì T có thể là bất kỳ type nào, kể cả String, Boolean — những type này không có doubleValue(). Để nói với compiler "T chỉ được là kiểu số", cần bounded type parameter.

2. Bounded type parameter — <T extends Bound>

Bounded type parameter (JLS §4.5.1) là cú pháp giới hạn kiểu T phải là subtype (hoặc chính xác là) một kiểu cụ thể. Từ khoá extends dùng cho cả class lẫn interface:

// T phai la Number hoac subtype cua Number (Integer, Long, Double, BigDecimal...)
public static <T extends Number> double sum(List<T> list) {
    double total = 0;
    for (T item : list) {
        total += item.doubleValue();  // OK -- Number co doubleValue()
    }
    return total;
}

// Su dung:
List<Integer> ints = List.of(1, 2, 3);
List<Double> doubles = List.of(1.5, 2.5, 3.5);
List<BigDecimal> bigs = List.of(new BigDecimal("1.1"), new BigDecimal("2.2"));

System.out.println(sum(ints));     // 6.0
System.out.println(sum(doubles));  // 7.5
System.out.println(sum(bigs));     // 3.3

Khi bạn viết <T extends Number>, compiler cho phép gọi tất cả public method của Number trên biến kiểu T. Đây là mấu chốt: bounded type mở rộng những gì bạn có thể làm với T trong method body.

2.1 Ví dụ thực tế: bounded trong Collections

Comparable là interface được dùng rất nhiều làm bound:

// Tim phan tu nho nhat trong list -- yeu cau T co the so sanh voi chinh no
public static <T extends Comparable<T>> T min(List<T> list) {
    if (list.isEmpty()) throw new NoSuchElementException();
    T result = list.get(0);
    for (T item : list) {
        if (item.compareTo(result) < 0) {
            result = item;
        }
    }
    return result;
}

// Hoat dong voi moi Comparable type
String minStr = min(List.of("banana", "apple", "cherry"));  // "apple"
Integer minInt = min(List.of(3, 1, 4, 1, 5));               // 1

3. Multiple bounds — <T extends A & B>

T có thể bị giới hạn bởi nhiều type cùng lúc bằng &:

// T phai implement ca Comparable va Serializable
public static <T extends Comparable<T> & Serializable> void processAndStore(List<T> list) {
    Collections.sort(list);   // OK vi T extends Comparable
    // serialize(list)         // OK vi T extends Serializable
}

Quy tắc quan trọng với multiple bounds:

  • Tối đa 1 class, các phần còn lại phải là interface.
  • Class phải đứng đầu tiên trong danh sách.
// DUNG: class truoc, interface sau
<T extends AbstractList<E> & Serializable & Cloneable>

// SAI: 2 class -- compile error (T khong the extend 2 class)
<T extends ArrayList<E> & LinkedList<E>>  // compile error

Lý do không thể có 2 class: Java không có multiple class inheritance. T extend 2 class đồng nghĩa với multiple inheritance — không cho phép.

4. Bounded type và erasure — ảnh hưởng bytecode

Khi compiler xử lý generic type, nó thực hiện type erasure — xoá thông tin generic và thay bằng type thực tế. Điều quan trọng: với bounded type parameter, erasure đến bound, không phải Object:

// Source code:
public static <T extends Number> double sum(List<T> list) {
    for (T item : list) {
        total += item.doubleValue();
    }
}

// Sau erasure (bytecode tuong duong):
public static double sum(List list) {
    for (Number item : list) {          // T -> Number (bound), khong phai Object
        total += item.doubleValue();    // goi truc tiep, khong can cast
    }
}

So sánh với unbounded <T>:

// Source:
public static <T> void print(List<T> list) {
    for (T item : list) { System.out.println(item); }
}

// Sau erasure:
public static void print(List list) {
    for (Object item : list) {           // T -> Object (khong co bound)
        System.out.println(item);
    }
}

Hệ quả thực tế: bounded type parameter giúp compiler tạo bytecode hiệu quả hơn (không cần checkcast instruction với method call trên bound), và bắt lỗi type sớm hơn tại compile time.

5. Generic invariance — định nghĩa và lý do

Generic invariance là tính chất: nếu A là subtype của B, thì Generic<A> không phải subtype của Generic<B>. Cụ thể:

  • Integer extends Number — đúng
  • Nhưng List<Integer> KHÔNG phải subtype của List<Number>

Điều này nghe có vẻ phi lý. Vì sao Java thiết kế vậy?

Xét đoạn code giả định nếu invariance KHÔNG được áp dụng:

// Gia su (SAI -- Java khong cho phep):
List<Integer> ints = new ArrayList<>(List.of(1, 2, 3));
List<Number> nums = ints;                     // neu cho phep...

nums.add(3.14);   // Double la Number, nen add OK...
nums.add(Long.MAX_VALUE);  // Long cung la Number...

// Gio ints la List<Integer> nhung chua Double va Long
Integer x = ints.get(3);  // ClassCastException tai runtime!

Nếu Java cho phép List<Integer> gán cho List<Number>, bạn có thể thêm Double (hợp lệ với List<Number>) vào một list thực tế chứa Integer — và đọc ra bằng Integer sẽ ném ClassCastException lúc runtime. Generic invariance ngăn điều này xảy ra, đảm bảo lỗi được bắt tại compile time.

// Thuc te -- Java tu choi ngay tai compile time:
List<Integer> ints = new ArrayList<>(List.of(1, 2, 3));
List<Number> nums = ints;  // compile error: incompatible types
Lỗi thường gặp

Người mới Java hay thắc mắc "tại sao method của tôi nhận List<Number> nhưng không nhận được List<Integer>?". Đây chính là invariance. Giải pháp: dùng upper bounded wildcard List<? extends Number> — sẽ thấy ở section 7.

6. Arrays là covariant — thiết kế cũ, lỗ hổng runtime

Java arrays được thiết kế covariant (từ Java 1.0): nếu A extends B thì A[] là subtype của B[]. Đây là thiết kế trước khi generics ra đời (Java 5), nhằm giúp viết generic algorithms:

Integer[] ints = {1, 2, 3};
Number[] nums = ints;       // OK -- covariant, compiler cho phep

nums[0] = 3.14;             // ArrayStoreException tai runtime!
// -- nguoi ta dang ghi Double vao Integer[], JVM phat hien va throw

JVM bắt lỗi này bằng arraystore check — mỗi lần ghi vào array, JVM kiểm tra xem kiểu thực của phần tử có compatible với kiểu thực của array không. Nếu không: ArrayStoreException.

So sánh với generics:

ArraysGenerics
CovarianceCó (Integer[] extends Number[])Không (List<Integer> không phải List<Number>)
Kiểm tra typeRuntime (ArrayStoreException)Compile time (compile error)
Hiệu năngOverhead mỗi lần ghi (arraystore check)Không overhead runtime
Khi nào phát hiện lỗiMuộn (runtime)Sớm (compile time)

Generics rút kinh nghiệm từ covariant arrays và chọn invariance để bắt lỗi sớm hơn. Đây là quyết định thiết kế có chủ đích.

flowchart TD
    A["Integer extends Number"] --> B["Arrays: Integer[] extends Number[]<br/>(covariant)"]
    A --> C["Generics: List&lt;Integer&gt; NOT subtype of List&lt;Number&gt;<br/>(invariant)"]
    B --> D["Error: ArrayStoreException at runtime"]
    C --> E["Error: compile error -- safe"]

7. Workaround invariance — wildcard

Nếu cần viết method nhận cả List<Integer> lẫn List<Double> (và mọi list số), dùng upper bounded wildcard ? extends Number:

// Voi invariance, method nay chi nhan dung List<Number>:
public static double sumNumbers(List<Number> list) {
    return list.stream().mapToDouble(Number::doubleValue).sum();
}

sumNumbers(List.of(1, 2, 3));          // compile error: List<Integer> khong phai List<Number>
sumNumbers(List.of(1.5, 2.5));         // compile error: List<Double> khong phai List<Number>

// FIX: dung upper bounded wildcard
public static double sumNumbers(List<? extends Number> list) {
    return list.stream().mapToDouble(Number::doubleValue).sum();
}

sumNumbers(List.of(1, 2, 3));          // OK -- List<Integer>
sumNumbers(List.of(1.5, 2.5));         // OK -- List<Double>
sumNumbers(List.of(new BigDecimal("1"))); // OK -- List<BigDecimal>

List<? extends Number> đọc là "list của phần tử có kiểu không rõ, nhưng biết là subtype của Number". Đây là covariance qua wildcard — an toàn hơn array covariance vì compiler ngăn ghi vào list (chỉ đọc được).

Tại sao không thể ghi vào List<? extends Number>?

List<? extends Number> list = new ArrayList<Integer>();
list.add(3.14);   // compile error -- compiler khong biet exact type la gi
list.add(1);      // compile error -- tuong tu

Compiler không biết ?Integer hay Double hay Long — nếu cho add 3.14 vào List<Integer>, là sai. Nên compiler từ chối tất cả write operations trên List<? extends T>.

Đây là PECS (Producer Extends, Consumer Super) đã học ở bài 03 — ? extends T phù hợp cho producer (chỉ đọc ra), ? super T phù hợp cho consumer (chỉ ghi vào).

8. Pitfall 1 — cố gắng ghi vào List<? extends Number>

public static void addNumbers(List<? extends Number> list) {
    list.add(42);       // compile error!
    list.add(null);     // null la ngoai le, cho phep -- nhung it dung
}

Nếu cần method vừa đọc vừa ghi, phải dùng:

  • Type parameter cụ thể: <T extends Number> void addToList(List<T> list, T item)
  • Hoặc lower bounded wildcard List<? super Integer> nếu chỉ cần ghi Integer vào list
// Vua doc vua ghi -- dung type parameter
public static <T extends Number> void addIfPositive(List<T> list, T item) {
    if (item.doubleValue() > 0) {
        list.add(item);  // OK -- list la List<T>, item la T
    }
}

9. Pitfall 2 — recursive bound <T extends Comparable<T>>

Một dạng bounded type đặc biệt thường gây nhầm:

// Sai cach doc: "T extend Comparable cua T"
// Dung cach doc: "T la type co the compare voi chinh no"
public static <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) >= 0 ? a : b;
}

<T extends Comparable<T>>recursive generic bound — T phải implement Comparable<T>, tức là T biết cách so sánh với đối tượng cùng kiểu T. String, Integer, LocalDate đều satisfy bound này.

Phân biệt với <T extends Comparable> (raw type, không nên dùng) — mất type safety của compareTo.

Một trường hợp khó hơn:

// Cho phep T compare voi supertype cua no (flexible hon)
public static <T extends Comparable<? super T>> T max(List<T> list) { ... }

Comparable<? super T> nghĩa là T có thể implement Comparable với supertype của nó. Ví dụ class Dog extends Animal, Animal implements Comparable<Animal>Dog không implement Comparable<Dog> trực tiếp nhưng bound ? super Dog bắt được Comparable<Animal>. Pattern này thấy trong Collections.sort JDK.

10. Tổng kết — bảng so sánh

Bounded <T extends B>Wildcard ? extends BWildcard ? super B
Dùng làm type parameter?Không (? không phải type)Không
Gọi method của B?Có (khi đọc)Không (type không rõ)
Có thể ghi vào?Không
Phù hợp choMethod body cần gọi method của boundParameter nhận nhiều subtype (producer)Parameter nhận supertype (consumer)
Erasure đếnBound (B)Bound (B)Object (lower bounded)

11. 📚 Deep Dive

  • JLS §4.5.1 — Type Arguments and Wildcardsdocs.oracle.com/.../jls-4.html#jls-4.5.1 — spec chính thức về wildcard và bounded type arguments, subtyping rules.
  • JLS §10.10 — Array Store Exceptiondocs.oracle.com/.../jls-10.html#jls-10.10 — spec về covariant arrays và ArrayStoreException.
  • Angelika Langer Generics FAQangelikalanger.com/GenericsFAQ — tài liệu tham khảo đầy đủ nhất về Java Generics, đặc biệt section về wildcards và variance.
  • Effective Java item 31 (Joshua Bloch, 3rd edition) — "Use bounded wildcards to increase API flexibility" — giải thích PECS, khi nào dùng extends vs super, và lý do wildcard làm API linh hoạt hơn.
  • JEP 300 (project Valhalla context) — giải thích vì sao generic invariance là thiết kế đúng và Valhalla không thay đổi điều đó dù có value types.

12. Self-check

Tự kiểm tra
Q1
Vì sao `<T>` không đủ khi muốn gọi `item.doubleValue()` trên `T`? Cần thêm gì?
`<T>` không giới hạn gì — `T` có thể là `String`, `Boolean`, bất kỳ type nào. Compiler không thể đảm bảo `T` có method `doubleValue()` nên từ chối gọi. Cần `<T extends Number>` — bound là `Number`, compiler biết mọi `T` đều có method của `Number` (bao gồm `doubleValue()`, `intValue()`, `longValue()`). Sau erasure, `T` được thay bằng `Number` trong bytecode nên gọi `item.doubleValue()` không cần cast thêm. Bounded type mở rộng interface của T mà compiler cho phép gọi trong method body.
Q2
Vì sao `List<Integer>` không phải subtype của `List<Number>` mặc dù `Integer extends Number`?
Nếu `List<Integer>` là subtype của `List<Number>`, ta có thể gán `List<Integer> ints` cho `List<Number> nums`, rồi `nums.add(3.14)` (Double là Number, hợp lệ với `List<Number>`). Nhưng thực tế bên dưới là `ArrayList<Integer>` — lúc đọc `ints.get(...)` và cast sang `Integer`, ta nhận `ClassCastException`. Generic invariance ngăn điều này bằng compile error thay vì runtime crash. Đây là quyết định thiết kế có chủ đích: bắt lỗi sớm tại compile time thay vì muộn tại runtime. Arrays không có bảo vệ này và phải dùng arraystore check mỗi lần ghi — tốn performance và vẫn chỉ phát hiện lúc runtime.
Q3
Tại sao `Integer[] ints = 3; Number[] nums = ints; nums[0] = 3.14;` ném `ArrayStoreException` thay vì compile error?
Arrays Java là covariant (`Integer[]` là subtype của `Number[]`) — quyết định từ Java 1.0 trước khi có generics. Gán `nums = ints` hợp lệ về kiểu. Tại compile time, compiler chỉ biết `nums` là `Number[]` và `3.14` là `Double extends Number` — hợp lệ. Nhưng JVM biết array thực tế là `Integer[]` và `Double` không compatible với slot `Integer`. JVM thực hiện arraystore check mỗi lần ghi vào array (chi phí runtime) và ném `ArrayStoreException` khi phát hiện không tương thích. Generics chọn invariance để tránh overhead này và bắt lỗi sớm hơn tại compile time.
Q4
Bạn có method `void printAll(List<Number> list)`. Bạn gọi `printAll(new ArrayList<Integer>())` — compile error hay OK?
Compile error. `List<Integer>` không phải subtype của `List<Number>` vì generic invariance. Method signature `List<Number>` chỉ nhận đúng `List<Number>`, không nhận `List<Integer>` hay `List<Double>`. Fix: đổi signature thành `void printAll(List<? extends Number> list)` — upper bounded wildcard cho phép nhận `List<Integer>`, `List<Double>`, `List<BigDecimal>`, bất kỳ `List<? extends Number>` nào. Trong body method, `? extends Number` chỉ cho phép đọc phần tử ra dưới dạng `Number`, không cho phép ghi — phù hợp với print (chỉ đọc).
Q5
Phân biệt `<T extends Comparable<T>>` và `<T extends Comparable<? super T>>`. Khi nào cần dạng thứ hai?
`<T extends Comparable<T>>` yêu cầu T tự implement `Comparable<T>` với chính nó. `<T extends Comparable<? super T>>` mở rộng hơn: T có thể implement `Comparable` với supertype của nó. Cần dạng thứ hai khi làm việc với class kế thừa — ví dụ `Dog extends Animal`, `Animal implements Comparable<Animal>`. `Dog` không implement `Comparable<Dog>` trực tiếp nhưng thừa kế `compareTo` từ `Animal`. Bound `Comparable<? super Dog>` bắt được `Comparable<Animal>`. `Collections.sort` trong JDK dùng `<T extends Comparable<? super T>>` chính vì lý do này — để sort được cả list của subclass. Dùng `<T extends Comparable<T>>` sẽ từ chối `List<Dog>` nếu `Dog` không tự implement `Comparable<Dog>`.
Q6
`<T extends A & B>` yêu cầu gì về A và B? Vì sao class phải đứng trước interface?
A và B là các type mà T phải là subtype của cả hai. Quy tắc: tối đa 1 class trong danh sách, phần còn lại phải là interface, và class phải đứng đầu. Lý do class đứng trước: sau type erasure, `T` bị thay bằng type đầu tiên trong bound (class hoặc interface). JVM dispatch method call dựa trên erasure type — nếu class đứng trước, dispatch đến class implementation là hiệu quả nhất. Nếu interface đứng trước khi có class ở sau, compiler vẫn báo lỗi ở source level. Lý do chỉ 1 class: Java không có multiple class inheritance — T không thể extend 2 class cùng lúc.
Q7
Sau type erasure, bytecode của `<T extends Comparable<T>> T max(T a, T b)` trông như thế nào?
Sau erasure, `T` được thay bằng bound đầu tiên là `Comparable` (raw type). Bytecode tương đương: Comparable max(Comparable a, Comparable b) { return a.compareTo(b) >= 0 ? a : b; }. Compiler chèn thêm checkcast instruction tại các điểm return để đảm bảo kiểu trả về đúng. Điều này giải thích vì sao generic code không tạo ra class file riêng cho mỗi type argument — tất cả dùng chung 1 bytecode với `Comparable` (hay `Object` với unbounded). Performance implication: không có overhead tạo class mới (không như C++ templates), nhưng có overhead checkcast và không thể tối ưu specialization cho primitive types (lý do Valhalla đang giải quyết).

Bài tiếp theo

Bài 11 là Mini Challenge — LRU Cache: áp dụng tổng hợp LinkedHashMap, bounded generics, và immutable collections để implement LRU Cache generic từ đầu với complexity O(1) get và put.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên