Wildcard `? extends`, `? super` — PECS rule
Invariance của generics, upper bound wildcard, lower bound wildcard, rule PECS (Producer Extends Consumer Super) và unbounded wildcard List<?>.
TL;DR: Generics trong Java có tính chất invariant (List<PrintedBook> không phải là con của List<LibraryItem>). Để xây dựng các phương thức linh hoạt cho thư viện, Java cung cấp bounded wildcard: dùng ? extends T khi phương thức chỉ đọc dữ liệu từ collection (Producer Extends - Covariance), và dùng ? super T khi phương thức cần ghi dữ liệu vào collection (Consumer Super - Contravariance). Nếu vừa đọc vừa ghi, bắt buộc giữ nguyên type parameter <T> cụ thể.
1. Vì sao List<PrintedBook> không thể gán cho List<LibraryItem>?
Khi xây dựng hệ thống quản lý thư viện, trực giác thường khiến chúng ta nghĩ rằng: vì PrintedBook kế thừa từ LibraryItem, nên một danh sách List<PrintedBook> hiển nhiên là một List<LibraryItem>. Tuy nhiên, trình biên dịch Java sẽ lập tức báo lỗi biên dịch (Compile Error).
List<PrintedBook> books = new ArrayList<>();
List<LibraryItem> items = books; // COMPILE ERROR: incompatible types
Nguyên nhân nằm ở tính toàn vẹn kiểu dữ liệu. Nếu Java cho phép phép gán này, lỗ hổng nghiêm trọng sẽ xuất hiện:
// If Java allowed this:
List<PrintedBook> books = new ArrayList<>();
List<LibraryItem> items = books; // Both reference the same list instance
items.add(new AudioBook("AB-102")); // Valid for List<LibraryItem>!
PrintedBook b = books.get(0); // ClassCastException at runtime: item is AudioBook!
Java ngăn chặn kịch bản này bằng nguyên tắc Invariance (Bất biến phân cấp): List<A> và List<B> là hai kiểu dữ liệu hoàn toàn độc lập, bất kể A và B có quan hệ kế thừa ra sao.
Điều này trái ngược với mảng (Array) vốn có tính Covariant (Hiệp biến) từ thời Java 1.0:
PrintedBook[] bookArr = new PrintedBook[10];
LibraryItem[] itemArr = bookArr; // OK at compile time (Covariant)
itemArr[0] = new AudioBook("AB-99"); // Fails with ArrayStoreException at runtime!
Mảng lưu thông tin kiểu thực tế tại runtime trong phần header của đối tượng nên phát hiện được xung đột khi ghi. Ngược lại, Generics bị xóa kiểu (Type Erasure) sau khi biên dịch. Nhằm ngăn chặn ClassCastException lọt ra môi trường sản xuất, Java áp đặt quy tắc Invariance ngay tại thời điểm biên dịch.
2. Upper Bound Wildcard (? extends T) đọc dữ liệu như thế nào?
Để viết các hàm xử lý chung (chẳng hạn như tính tổng trọng lượng lưu kho của mọi loại ấn phẩm), ta sử dụng Upper Bound Wildcard (? extends T) để khôi phục tính hiệp biến (Covariance).
public static double calculateTotalWeight(List<? extends LibraryItem> items) {
double total = 0.0;
for (LibraryItem item : items) {
total += item.getWeightKg();
}
return total;
}
// Client invocations:
List<PrintedBook> books = List.of(new PrintedBook("B1", 0.8), new PrintedBook("B2", 1.2));
List<AudioBook> audioMedia = List.of(new AudioBook("A1", 0.1));
calculateTotalWeight(books); // OK: PrintedBook extends LibraryItem
calculateTotalWeight(audioMedia); // OK: AudioBook extends LibraryItem
Kiểu List<? extends LibraryItem> truyền thông điệp cho compiler: "Đây là danh sách chứa các phần tử thuộc kiểu LibraryItem hoặc lớp con bất kỳ của nó". Do mọi phần tử chắc chắn đều là LibraryItem, bạn có thể duyệt qua danh sách và gọi các phương thức của LibraryItem một cách an toàn.
Hệ quả: Trình biên dịch cấm ghi dữ liệu
Khi sử dụng ? extends, danh sách đóng vai trò là nguồn cung cấp dữ liệu (Producer). Trình biên dịch sẽ chặn mọi thao tác thêm phần tử:
List<? extends LibraryItem> shelf = new ArrayList<PrintedBook>();
shelf.add(new PrintedBook("B3", 0.5)); // COMPILE ERROR
shelf.add(new LibraryItem("GEN-1")); // COMPILE ERROR
shelf.add(null); // Allowed: null is compatible with all references
Lý do: Tại thời điểm biên dịch, trình biên dịch không thể biết chính xác danh sách thực tế mà shelf đang tham chiếu là List<PrintedBook>, List<AudioBook> hay một lớp con nào khác. Để loại bỏ nguy cơ ghi sai kiểu, phương thức add() bị vô hiệu hóa hoàn toàn đối với mọi đối tượng thực tế.
3. Lower Bound Wildcard (? super T) ghi dữ liệu ra sao?
Ngược lại với bài toán đọc dữ liệu, khi muốn viết phương thức nhận một danh sách và nạp các cuốn sách mới vào đó, ta sử dụng Lower Bound Wildcard (? super T) nhằm kích hoạt tính nghịch biến (Contravariance).
public static void populateDefaultInventory(List<? super PrintedBook> destination) {
destination.add(new PrintedBook("Clean Code", 0.6));
destination.add(new PrintedBook("Effective Java", 0.7));
}
// Client calls:
List<PrintedBook> bookList = new ArrayList<>();
List<LibraryItem> itemList = new ArrayList<>();
List<Object> globalArchive = new ArrayList<>();
populateDefaultInventory(bookList); // OK: PrintedBook super PrintedBook
populateDefaultInventory(itemList); // OK: LibraryItem super PrintedBook
populateDefaultInventory(globalArchive); // OK: Object super PrintedBook
Cú pháp List<? super PrintedBook> có nghĩa là: danh sách này chứa các đối tượng thuộc kiểu PrintedBook hoặc một lớp cha bất kỳ của nó (LibraryItem, Object). Do PrintedBook luôn tương thích ngược với chính nó và mọi lớp cha, việc đưa một thể hiện PrintedBook vào danh sách luôn an toàn tuyệt đối.
Hệ quả: Dữ liệu đọc ra chỉ có kiểu Object
Khi collection đóng vai trò nhận dữ liệu (Consumer), bạn không thể đọc dữ liệu ra dưới kiểu cụ thể:
List<? super PrintedBook> storage = new ArrayList<LibraryItem>();
PrintedBook b = storage.get(0); // COMPILE ERROR
LibraryItem item = storage.get(0); // COMPILE ERROR
Object obj = storage.get(0); // OK: Object is the universal supertype
Bởi vì storage có thể đang trỏ tới một List<Object> hoặc List<LibraryItem>, kiểu phần tử cụ thể không được bảo đảm tại thời điểm đọc.
4. Áp dụng quy tắc PECS trong hệ thống thư viện như thế nào?
Quy tắc kinh điển do Joshua Bloch đúc kết trong cuốn Effective Java phát biểu ngắn gọn:
PECS: Producer Extends, Consumer Super
- Nếu tham số cung cấp dữ liệu cho bạn đọc (Producer) -> dùng
? extends T.- Nếu tham số tiêu thụ dữ liệu bạn ghi vào (Consumer) -> dùng
? super T.

Hãy quan sát cách áp dụng nguyên tắc này khi sao chép ấn phẩm giữa các kho lưu trữ:
public static <T> void transferInventory(List<? super T> destination, List<? extends T> source) {
for (T item : source) {
destination.add(item);
}
}
Trong phương thức trên:
sourceđóng vai trò Producer: Ta trích xuất dữ liệu kiểuTtừsource-> khai báoList<? extends T>.destinationđóng vai trò Consumer: Ta nạp các phần tửitemvàodestination-> khai báoList<? super T>.- Kiểu tham số
<T>ở đầu phương thức hoạt động như chiếc cầu nối bảo đảm rằng mọi thứ lấy ra từ nguồn đều tương thích tuyệt đối với kho nhận.
List<PrintedBook> newBooks = List.of(new PrintedBook("Refactoring", 0.8));
List<LibraryItem> generalCatalog = new ArrayList<>();
// Transfer books into general catalog:
transferInventory(generalCatalog, newBooks); // T inferred as PrintedBook, fully valid
Nếu không sử dụng Wildcard mà giữ nguyên transferInventory(List<T> dest, List<T> src), lời gọi trên sẽ lập tức bị từ chối vì List<LibraryItem> và List<PrintedBook> không cùng một kiểu tham số.
5. Bảng ma trận so sánh các dạng Wildcard
| Tiêu chí | List<? extends T> (Upper Bound) | List<? super T> (Lower Bound) | List<?> (Unbounded) |
|---|---|---|---|
| Bản chất lý thuyết | Hiệp biến (Covariance) | Nghịch biến (Contravariance) | Bi-variant / Không xác định |
| Vai trò trong PECS | Producer (Nguồn cung cấp) | Consumer (Nơi tiêu thụ) | Đọc siêu tổng quát |
Thao tác đọc (get) | Đọc ra an toàn kiểu T | Chỉ đọc được kiểu Object | Chỉ đọc được kiểu Object |
Thao tác ghi (add) | Bị cấm (chỉ chấp nhận null) | Ghi được phần tử kiểu T (hoặc con của T) | Bị cấm (chỉ chấp nhận null) |
| Trường hợp điển hình | Tính toán, duyệt dữ liệu, trích xuất | Thu thập, nạp phần tử, tích lũy | size(), clear(), logging |
6. Khi nào nên dùng Unbounded Wildcard List<?>?
Unbounded Wildcard List<?> biểu thị một danh sách có kiểu phần tử không xác định. Nó khác với List<Object> ở chỗ: List<?> có thể tham chiếu tới bất kỳ loại List<X> nào.
public static void inspectCatalogSize(List<?> catalog) {
System.out.println("Item count: " + catalog.size());
for (Object obj : catalog) {
System.out.println("Catalog entry: " + obj);
}
}
List<PrintedBook> books = List.of(new PrintedBook("B1", 0.5));
List<String> barcodes = List.of("BC-001", "BC-002");
inspectCatalogSize(books); // Valid
inspectCatalogSize(barcodes); // Valid
Nếu bạn đổi tham số thành List<Object> catalog, trình biên dịch sẽ từ chối cả books lẫn barcodes do tính chất Invariance.
List<?> là lựa chọn tối ưu khi:
- Thao tác chỉ dựa trên các phương thức của bản thân interface
List(nhưsize(),isEmpty(),clear()). - Mã nguồn chỉ cần xử lý đối tượng thông qua các phương thức của lớp
Object(nhưtoString(),hashCode()).
7. Cơ chế Wildcard Capture xử lý phép gán phần tử ra sao?
Xét bài toán hoán đổi vị trí hai ấn phẩm trong một danh sách chưa rõ kiểu:
public static void swapItems(List<?> list, int i, int j) {
// Compiler disallows direct set() because element type is unknown:
// list.set(i, list.get(j)); // COMPILE ERROR: capture#1 of ?
swapHelper(list, i, j);
}
// Helper method captures unknown wildcard into explicit type parameter T:
private static <T> void swapHelper(List<T> list, int i, int j) {
T temp = list.get(i);
list.set(i, list.get(j));
list.set(j, temp);
}
Ở phương thức công khai swapItems, client nhận được chữ ký hàm gọn gàng List<?>. Bên trong hàm, ta ủy quyền việc xử lý cho phương thức trợ giúp swapHelper. Khi đó, trình biên dịch thực hiện cơ chế Capture Conversion: tạm thời gắn nhãn kiểu ẩn danh ? thành một type parameter cụ thể <T>, cho phép thực hiện việc đọc và ghi trơn tru mà không làm tổn hại tính an toàn kiểu.
8. Các cạm bẫy thường gặp khi làm việc với Wildcard là gì?
❌ Cạm bẫy 1: Sử dụng Wildcard trong kiểu trả về của phương thức
// Anti-pattern in API design:
public List<? extends LibraryItem> getArchivedItems() {
return new ArrayList<PrintedBook>();
}
// Client tries to add items:
List<? extends LibraryItem> result = getArchivedItems();
// result.add(new PrintedBook("B5", 0.4)); // COMPILE ERROR! Client cannot add anything.
✅ Giải pháp: Không dùng wildcard ở kiểu trả về. Hãy trả về kiểu cụ thể List<LibraryItem> hoặc tham số hóa phương thức với <T extends LibraryItem> List<T>.
❌ Cạm bẫy 2: Cố gắng dùng cả extends và super trên cùng một biến
// SYNTAX ERROR: Wildcards cannot combine multiple bounds with &
public void processBatch(List<? extends LibraryItem & Cloneable> items) { }
✅ Giải pháp: Wildcard không hỗ trợ multiple bounds (ký hiệu &). Khi cần nhiều ràng buộc kiểu, bắt buộc phải dùng Generic Type Parameter ở cấp độ phương thức: <T extends LibraryItem & Cloneable> void processBatch(List<T> items).
❌ Cạm bẫy 3: Dùng Wildcard cho collection vừa đọc vừa ghi
Nếu một logic nghiệp vụ cần đọc phần tử ra, biến đổi nó và ghi lại vào chính danh sách đó, không có wildcard nào phù hợp cả. Bạn phải sử dụng tham số kiểu cụ thể List<T>.
9. Đọc thêm từ tài liệu Oracle
- JLS §4.5.1 — Type Arguments of Parameterized Types: Định nghĩa chuẩn về cú pháp Wildcard và giới hạn trên/dưới.
- JLS §5.1.10 — Capture Conversion: Quy tắc compiler chuyển đổi wildcard thành type variable nội bộ.
- Bài 01 — Generics cơ bản: Khởi đầu của Type Safety trong Java.
- Effective Java (Joshua Bloch), Item 31: Use bounded wildcards to increase API flexibility.
10. Tóm tắt các điểm mấu chốt
- Generics là invariant nhằm bảo đảm không có
ClassCastExceptionlọt qua compile-time. ? extends T(Covariance): Chỉ cho phép đọc dữ liệu (Producer), cấm thêm phần tử mới.? super T(Contravariance): Cho phép ghi phần tử kiểuT(Consumer), khi đọc ra chỉ nhận được kiểuObject.- Quy tắc PECS: Tham số đóng vai trò cung cấp dữ liệu dùng
extends, tham số tiêu thụ dữ liệu dùngsuper. - Tránh sử dụng wildcard trong kiểu dữ liệu trả về của public method để không gây phiền toái cho client.
11. Câu hỏi tự kiểm tra
- Q1Vì sao dòng lệnh List<LibraryItem> items = new ArrayList<PrintedBook>(); bị compiler từ chối dù PrintedBook kế thừa từ LibraryItem?
- Q2Phương thức sau nhận tham số src chỉ để duyệt phần tử. Theo nguyên tắc PECS, chữ ký nào giúp hàm linh hoạt nhất? void exportLabels(List<LibraryItem> src)
- Q3Đoạn mã sau có biên dịch được không? Nếu có lỗi, giải thích nguyên nhân: List<? extends LibraryItem> shelf = new ArrayList<PrintedBook>(); shelf.add(new PrintedBook("B1", 0.5));
- Q4Khi nào bạn bắt buộc phải dùng <T> thay vì sử dụng Wildcard (? extends hoặc ? super)?
- Q5Điểm khác biệt cốt lõi giữa List<?> và List<Object> là gì?
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?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Type erasure — bản chất thực thi của generics Java
Bài tiếpList và Queue — ArrayList, ArrayDeque, PriorityQueue
Lộ trình Junior Java Backend Developer
Chặng 5/21 · Generics & Collections — bài 3/10 của chặng
Ôn phỏng vấn
Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.
Thảo luận & góp ý
Hỏi đáp về bài này