Java OO & Functional/Wildcard `? extends`, `? super` — PECS rule
19/38
Bài 19 / 38~18 phútGenerics & CollectionsMiễn phí lượt xem

Wildcard `? extends`, `? super` — PECS rule

Invariance của generics, upper bound wildcard, lower bound wildcard, rule PECS (Producer Extends Consumer Super) của Effective Java, và unbounded wildcard List<?>.

List<String> có phải List<Object> không? Trực giác trả lời "có, vì String là Object". Sai — Java generics invariant, List<String> không là subtype của List<Object>. Đây là rule đầu tiên làm dev mới bối rối.

Để lách cứng nhắc này, Java cung cấp wildcard ? với extends (upper bound) và super (lower bound). Dùng đúng cho API mềm dẻo; dùng sai gây compile error lạ. Rule kinh điển PECS (Producer Extends, Consumer Super) giúp chọn đúng.

Bài này giải thích invariance, 2 loại bounded wildcard, PECS, và khi nào dùng <?> không có bound.

1. Invariance — List<String> KHÔNG phải List<Object>

List<String> strings = new ArrayList<>();
List<Object> objects = strings;   // COMPILE ERROR

Lý do: nếu cho phép gán, chương trình sau sẽ phá type safety:

// Gia su hop le:
List<String> strings = new ArrayList<>();
List<Object> objects = strings;    // strings va objects cung list
objects.add(42);                    // Object[] nhan Integer
String s = strings.get(0);          // ClassCastException — la Integer

Java cấm để ngăn. List<A>List<B> là 2 kiểu độc lậpA là subtype của B. Đây gọi là invariance của generics.

So sánh với array — Java array covariant (lịch sử):

String[] strings = new String[10];
Object[] objects = strings;   // OK voi array — covariance
objects[0] = 42;              // RUNTIME: ArrayStoreException

Array Java kiểm tra type tại runtime (có type info lưu trong object). Generics erase → không check runtime → Java chọn invariance compile-time để tránh ClassCastException.

2. Upper bound wildcard — ? extends T (Covariance — Hiệp biến)

Trong lý thuyết ngôn ngữ lập trình, Covariance (Hiệp biến) mô tả khả năng duy trì mối quan hệ phân cấp lớp của các kiểu generic cùng chiều với các kiểu tham số thành phần của chúng.

Cụ thể: Nếu Integer là một subtype của Number, và chúng ta mong muốn List<Integer> cũng có thể được xem là một kiểu con hợp lệ để gán cho List<? extends Number>, ta sử dụng cú pháp Upper Bound Wildcard (? extends T) để hiện thực hóa tính hiệp biến.

Đôi khi bạn muốn method nhận bất kỳ subtype nào của T. Dùng ? extends:

public static double sumOfList(List<? extends Number> list) {
    double sum = 0;
    for (Number n : list) sum += n.doubleValue();
    return sum;
}

sumOfList(List.<Integer>of(1, 2, 3));    // OK — Integer extends Number
sumOfList(List.<Double>of(1.5, 2.5));     // OK — Double extends Number
sumOfList(List.<Number>of(1, 2.5));       // OK — Number extends Number (chinh no)

List<? extends Number> = "list của một kiểu nào đó là subtype Number (hoặc Number)". Compiler không biết chính xác T là gì, chỉ biết T ≤ Number.

2.1 Hệ quả — không add được

List<? extends Number> list = new ArrayList<Integer>();
list.add(42);            // COMPILE ERROR
list.add(new Integer(42));   // COMPILE ERROR
list.add(null);           // OK — null tuong thich moi reference

Compiler không biết list chính xác là List<Integer> hay List<Double> hay List<Number>. Add Integer có thể vi phạm nếu list là List<Double>. Để an toàn, compiler cấm mọi add trừ null.

Kết luận: ? extendsread-only — collection lúc này đóng vai producer: bạn lấy ra được (produce), không đặt vào được (consume).

3. Lower bound wildcard — ? super T (Contravariance — Nghịch biến)

Trái ngược hoàn toàn với hiệp biến, Contravariance (Nghịch biến) mô tả cơ chế đảo ngược mối quan hệ kế thừa của kiểu generic so với kiểu tham số thành phần gốc của chúng.

Cụ thể: Nếu Integer là một subtype của Number, nhưng chúng ta muốn gán ngược một List<Number> (kiểu cha) vào một biến đại diện cho danh sách chấp nhận Integer (kiểu con) để thực hiện ghi dữ liệu an toàn, ta sử dụng cú pháp Lower Bound Wildcard (? super T) để kích hoạt tính nghịch biến.

Ngược lại: ? super T = "kiểu nào đó là supertype của T (hoặc T)".

public static void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
    list.add(3);
}

List<Integer> ints = new ArrayList<>();
List<Number> nums = new ArrayList<>();
List<Object> objs = new ArrayList<>();

addNumbers(ints);    // OK — Integer super Integer
addNumbers(nums);    // OK — Number super Integer
addNumbers(objs);    // OK — Object super Integer

List<? super Integer> có thể là List<Integer>, List<Number>, List<Object>. Tất cả đều chấp nhận add Integer (vì Integer "lên cao" hợp lệ).

3.1 Hệ quả — đọc ra Object

List<? super Integer> list = ...;
Integer x = list.get(0);   // COMPILE ERROR — khong biet la Integer hay Number hay Object
Object y = list.get(0);     // OK — chac chan la Object (supertype cua tat ca)

Compiler không biết chính xác kiểu element — chỉ biết là supertype của Integer. Lấy ra về Object là lựa chọn an toàn duy nhất.

Kết luận: ? superwrite-only cho kiểu cụ thể — add được, nhưng lấy ra chỉ được Object.

4. PECS — Producer Extends, Consumer Super

Rule Effective Java:

  • Nếu tham số sản xuất (produce) giá trị T → dùng ? extends T (đọc ra T).
  • Nếu tham số tiêu thụ (consume) giá trị T → dùng ? super T (ghi vào).

Ví dụ kinh điển — Collections.copy:

public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (int i = 0; i < src.size(); i++) {
        dest.set(i, src.get(i));
    }
}
  • srcproducer — nơi bạn lấy T ra? extends T (Covariance - Hiệp biến).
  • destconsumer — nơi bạn đặt T vào? super T (Contravariance - Nghịch biến).

Dùng:

List<Integer> src = List.of(1, 2, 3);
List<Number> dest = new ArrayList<>(List.of(0, 0, 0));
Collections.copy(dest, src);    // OK — Integer extends T=Integer, Number super T=Integer

Nếu viết cả hai bên List<T> (không wildcard), caller phải truyền đúng List<Integer> cho cả hai — mất đi tính linh hoạt (flexibility).

4.1 Vai trò liên kết kiểu của <T> trong Collections.copy

Hãy nhìn kỹ vào chữ ký của phương thức Collections.copy ở trên. Tại sao chúng ta cần khai báo tham số kiểu <T> ở đầu phương thức thay vì viết trực tiếp bằng các wildcard không định danh?

Tham số <T> ở đây đóng vai trò cực kỳ quan trọng làm Type Link (Liên kết kiểu) ở cấp độ biên dịch:

  1. Thiết lập cầu nối kiểu tương thích: <T> đóng vai trò làm trung gian ràng buộc giữa kiểu nguồn dữ liệu (src) và kiểu đích chứa dữ liệu (dest).
  2. Kiểm tra loại an toàn tối đa (Type-Safe Enforcement): Nhờ mối liên kết qua <T>, compiler đảm bảo tuyệt đối rằng bất kỳ đối tượng nào đọc ra từ src (chắc chắn thuộc kiểu T hoặc các subtype của T nhờ ? extends T) đều sẽ hoàn toàn tương thích và ghi vào được dest (vốn chấp nhận kiểu T hoặc các supertype của T nhờ ? super T).
  3. Loại bỏ ép kiểu thủ công: Nếu không có <T> làm cầu nối liên kết, compiler sẽ mất đi tham chiếu chung và từ chối lời gọi dest.set(i, src.get(i)) vì không thể chứng minh tính an toàn. Sự hiện diện của <T> giúp compiler tự tin cho phép gán trực tiếp dữ liệu mà không bắt dev phải viết bất kỳ dòng ép kiểu thủ công nào, loại bỏ hoàn toàn nguy cơ ClassCastException ở runtime.

4.2 Ma trận tóm tắt nhanh 2x2 về Wildcard

Để ghi nhớ nhanh và không bao giờ bị nhầm lẫn giữa hai loại wildcard này trong thực chiến, học viên có thể đối chiếu qua bảng ma trận 2x2 dưới đây:

Loại WildcardĐọc dữ liệu (Read / Get)Ghi dữ liệu (Write / Add)
? extends T
(Hiệp biến - Covariance)
HỢP LỆ
(Đọc ra với kiểu compile-time là T — phần tử thực tế là T hoặc subtype của T)
BỊ CẤM
(Chỉ được ghi giá trị null)
? super T
(Nghịch biến - Contravariance)
HẠN CHẾ
(Chỉ đọc được kiểu chung Object)
HỢP LỆ
(Ghi được đối tượng kiểu T hoặc con của T)

4.3 PECS thực tế — Stream.collect

Signature thật trong JDK (method instance của Stream<T>):

// java.util.stream.Stream<T>:
<R> R collect(Supplier<R> supplier,
              BiConsumer<R, ? super T> accumulator,
              BiConsumer<R, R> combiner);

Để ý accumulator có kiểu BiConsumer<R, ? super T>: tham số thứ hai tiêu thụ element T của stream → ? super T đúng theo PECS. Nhờ vậy một BiConsumer<List<Object>, Object> (consumer "rộng" hơn) vẫn dùng được cho Stream<String>. Các vị trí R giữ nguyên kiểu chính xác vì method vừa ghi vào container kết quả vừa trả nó về cho caller. Rule PECS áp dụng khắp JDK — đọc signature Collections.copy hay Comparator.comparing là cách nhanh nhất để thuộc.

5. Unbounded wildcard — List<?>

List<?> = "list của kiểu gì đó, không biết và không quan tâm".

public static void printAll(List<?> list) {
    for (Object o : list) {
        System.out.println(o);
    }
}

printAll(List.of(1, 2, 3));
printAll(List.of("a", "b"));
printAll(List.of(1.5, 2.5));

Khác List<Object>List<?> nhận mọi List<X> (không invariance).

Hạn chế: không add được element nào (trừ null).

Dùng khi:

  • Method chỉ đọc (không add), không quan tâm kiểu element.
  • Vd: size(), isEmpty(), contains(Object), print, log.

6. Khi nào dùng wildcard vs type parameter?

// Phien ban 1 — type parameter
public static <T> void swap(List<T> list, int i, int j) {
    T tmp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, tmp);
}

// Phien ban 2 — wildcard
public static void swap(List<?> list, int i, int j) {
    swapHelper(list, i, j);
}
private static <T> void swapHelper(List<T> list, int i, int j) {
    T tmp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, tmp);
}

Phiên bản 2 có signature public sạch hơn (List<?>) — không lộ type parameter cho caller, nhưng nội bộ vẫn cần <T> cho compile logic.

Rule Effective Java Item 31: API public ưu tiên wildcard; internal dùng type parameter.

7. Wildcard capture

Compiler có cơ chế "capture" wildcard — khi đọc element, tạo type variable tạm:

public static void reverse(List<?> list) {
    // compiler "capture" ? thanh CAP#1 (internal type)
    List<Object> tmp = new ArrayList<>(list);
    for (int i = 0; i < list.size(); i++) {
        list.set(i, tmp.get(list.size() - 1 - i));   // COMPILE ERROR
    }
}

list.set(i, Object) fail vì compiler không chắc element type. Workaround: helper method generic capture wildcard:

public static void reverse(List<?> list) { reverseHelper(list); }
private static <T> void reverseHelper(List<T> list) {
    List<T> tmp = new ArrayList<>(list);
    for (int i = 0; i < list.size(); i++) {
        list.set(i, tmp.get(list.size() - 1 - i));   // OK — T co dinh
    }
}

Pattern phổ biến — chấp nhận wildcard public, chuyển sang type parameter internal.

8. Pitfall tổng hợp

Nhầm 1: Gán List<String> cho List<Object>.

List<Object> objs = new ArrayList<String>();   // COMPILE ERROR

✅ Dùng List<? extends Object> hoặc List<?> nếu chỉ đọc.

Nhầm 2: Add vào List<? extends T>.

List<? extends Number> list = ...;
list.add(42);   // COMPILE ERROR

✅ Đổi sang List<? super Integer> nếu cần add, hoặc không wildcard.

Nhầm 3: Lấy ra T từ List<? super T>.

List<? super Integer> list = ...;
Integer x = list.get(0);   // COMPILE ERROR

✅ Lấy ra Object. Hoặc dùng List<? extends T>.

Nhầm 4: Wildcard trong return type — khó dùng.

public static List<? extends Number> getNumbers() { ... }
// Caller: List<? extends Number> list = ...; list.add(42) — fail

✅ Return type nên có type parameter cụ thể để caller dùng được.

Nhầm 5: Nhầm List<Object> với List<?>.

void foo(List<Object> list) { ... }
foo(new ArrayList<String>());   // COMPILE ERROR

List<?> nếu chỉ đọc và nhận mọi kiểu.

9. 📚 Deep Dive Oracle

📚 Deep Dive Oracle (optional)

Spec / reference chính thức:

Ghi chú: PECS do Joshua Bloch phổ biến trong Effective Java. Rule đơn giản nhưng cứu dev khỏi lỗi wildcard phức tạp — áp được 90% trường hợp. Collection API JDK (Collections.copy, Stream.collect, Comparator.thenComparing) đều thể hiện PECS rõ ràng — đọc signature của chúng là cách nhanh nhất để thuộc.

10. Tóm tắt

  • Generics invariant: List<String> không phải List<Object>. Khác array (covariant).
  • ? extends T — upper bound wildcard. Producer — lấy ra T, không add.
  • ? super T — lower bound wildcard. Consumer — add T, lấy ra Object.
  • ? unbounded — đọc về Object, không add (trừ null).
  • PECS rule: Producer Extends, Consumer Super.
  • API public ưu tiên wildcard; internal dùng type parameter.
  • Wildcard trong return type khó dùng → tránh, trả kiểu cụ thể.
  • Wildcard capture qua helper method — pattern phổ biến.

11. Tự kiểm tra

Tự kiểm tra
Q1
Đoạn sau compile không?
List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = ints;

Không compile — "incompatible types". Generics invariant: List<Integer> không phải subtype của List<Number>Integer là subtype của Number.

Lý do: nếu cho phép, compiler mất khả năng enforce type safety — caller có thể nums.add(new Double(1.5)) làm corrupted ints.

Fix:

  • Nếu chỉ đọc: List<? extends Number> nums = ints; — OK, chấp nhận List<Integer>.
  • Nếu cần add: đổi logic — không thể gán List<Integer> thành List<Number> trực tiếp vì semantics khác.
Q2
Áp dụng PECS — sign method sau đúng thế nào?
public static <T> void copy(List<T> dest, List<T> src) {
    for (T item : src) dest.add(item);
}

Method hiện tại yêu cầu destsrccùng type parameter. Gán linh hoạt hơn với PECS:

public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (T item : src) dest.add(item);
}

Lý do:

  • srcproducer (lấy T ra) → ? extends T.
  • destconsumer (đặt T vào) → ? super T.

Với signature mới:

List<Integer> src = List.of(1, 2, 3);
List<Number> dest = new ArrayList<>();
copy(dest, src);   // OK — before would fail

Đây chính xác là signature Collections.copy trong JDK.

Q3
Đoạn sau dòng nào compile OK, dòng nào fail?
List<? extends Number> list = new ArrayList<Integer>();
list.add(42);
Number n = list.get(0);
list.add(null);
list.size();
DòngKết quả
List<? extends Number> list = new ArrayList<Integer>();OK — ArrayList<Integer> thoả ? extends Number (Covariance).
list.add(42)FAIL — compiler không biết T chính xác; có thể là List<Double> → từ chối ghi để đảm bảo an toàn.
Number n = list.get(0)OK — mọi element là subtype Number, lấy về Number an toàn (đọc hiệp biến).
list.add(null)OK — null tương thích với mọi reference.
list.size()OK — không phụ thuộc element type.

Rule: ? extends là "read-only" cho element type (trừ null). Muốn add, dùng ? super (Contravariance).

Q4
Khi nào dùng List<?> thay vì List<Object>?

List<?>List<Object> khác nhau:

  • List<?> — "list của kiểu gì đó, không quan tâm". Nhận được mọi List<X>: List<Integer>, List<String>...
  • List<Object> — "list khai báo chính xác là List<Object>". Chỉ nhận List<Object> — không nhận List<String> (invariance).
static void printAll(List<?> list) { list.forEach(System.out::println); }
static void printAllObj(List<Object> list) { list.forEach(System.out::println); }

List<String> s = List.of("a", "b");
printAll(s);      // OK
printAllObj(s);   // COMPILE ERROR — invariance

Dùng List<?> khi method chỉ đọc element, không quan tâm kiểu — ngắn, linh hoạt, không bị invariance chặn.

Dùng List<Object> khi thực sự cần một list khai Object (hiếm trong API hiện đại).

Q5
Method sau compile không? Nếu không, fix thế nào?
public static <T> void swap(List<?> list, int i, int j) {
    T tmp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, tmp);
}

Không compile. List<?> không cho set/add element (trừ null). T ở signature không liên quan đến ? — hai type parameter độc lập.

Fix — pattern "wildcard capture qua helper":

public static void swap(List<?> list, int i, int j) {
    swapHelper(list, i, j);
}

private static <T> void swapHelper(List<T> list, int i, int j) {
    T tmp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, tmp);
}

Public signature có List<?> — caller dùng với bất kỳ List<X>. Private helper có <T> — compiler "capture" wildcard thành T cụ thể khi forward call, cho phép set trong body.

Đây là pattern Effective Java Item 31 khuyến nghị: API public dùng wildcard, internal dùng type parameter.

Bài tiếp theo: List và Queue — ArrayList, LinkedList, ArrayDeque, PriorityQueue

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