Java OO & Functional
19/38
Bài 19 / 38~12 phútGenerics & CollectionsLộ trình · chặng 05/21Miễ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) 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).

Java
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:

Java
// 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:

Java
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).

Java
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ử:

Java
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).

Java
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ể:

Java
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.

Cây quyết định chọn wildcard: chỉ đọc thì dùng extends, chỉ ghi thì dùng super, còn vừa đọc vừa ghi thì không dùng wildcard

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ữ:

Java
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:

  1. source đóng vai trò Producer: Ta trích xuất dữ liệu kiểu T từ source -> khai báo List<? extends T>.
  2. destination đóng vai trò Consumer: Ta nạp các phần tử item vào destination -> khai báo List<? super T>.
  3. 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.
Java
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ếtHiệp biến (Covariance)Nghịch biến (Contravariance)Bi-variant / Không xác định
Vai trò trong PECSProducer (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 TChỉ đọc được kiểu ObjectChỉ đọ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ìnhTính toán, duyệt dữ liệu, trích xuấtThu thập, nạp phần tử, tích lũysize(), 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.

Java
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:

  1. 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()).
  2. 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:

Java
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

Java
// 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

Java
// 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

Tài liệu tham khảo chính thức

10. Tóm tắt các điểm mấu chốt

  • Generics là invariant nhằm bảo đảm không có ClassCastException lọ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ểu T (Consumer), khi đọc ra chỉ nhận được kiểu Object.
  • 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ùng super.
  • 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

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì sao dòng lệnh List<LibraryItem> items = new ArrayList<PrintedBook>(); bị compiler từ chối dù PrintedBook kế thừa từ LibraryItem?
  2. Q2
    Phươ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)
  3. 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));
  4. Q4
    Khi nào bạn bắt buộc phải dùng <T> thay vì sử dụng Wildcard (? extends hoặc ? super)?
  5. 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.

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

Bài tiếp theo

List và Queue — ArrayList, ArrayDeque, PriorityQueue