Generics trong Java: wildcard ? extends và ? super

1. Tổng quan

Generics giúp code Java an toàn kiểu ngay lúc compile. Nhưng khi viết một method nhận List<Product>, bạn sẽ thấy nó không chấp nhận List<Book>, dù Book là con của Product. Bài này giải thích vì sao lại như vậy. Sau đó bài hướng dẫn cách dùng wildcard ? extends và ? super để viết API linh hoạt hơn. Đọc xong, bạn sẽ biết chọn wildcard nào theo nguyên tắc PECS và tránh được các lỗi compile khó hiểu.

2. Vấn đề: generics là invariant

Ta dùng một class hierarchy đơn giản cho toàn bài:

public class Product {
    private final String name;
    private final double price;

    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }

    public String name() { return name; }
    public double price() { return price; }
}

public class Book extends Product {
    public Book(String name, double price) {
        super(name, price);
    }
}

Book là một Product. Vậy List<Book> có phải là List<Product> không?

List<Book> books = new ArrayList<>();
List<Product> products = books; // Lỗi compile: incompatible types

Câu trả lời là không. Generics trong Java là invariant. Với A là con của B, List<A> và List<B> vẫn là hai kiểu không liên quan.

Lý do nằm ở chính sự an toàn kiểu. Giả sử dòng trên compile được, ta có thể viết:

products.add(new Product("Bút bi", 5_000)); // thêm Product vào list "chỉ chứa Book"
Book first = books.get(0);                    // ClassCastException!

Mảng thì khác. Mảng trong Java là covariant, nên lỗi tương tự chỉ lộ ra lúc runtime:

Object[] items = new String[2];
items[0] = 42; // compile OK, runtime ném ArrayStoreException

Generics chọn cách chặn lỗi ngay từ lúc compile. Cái giá phải trả là kém linh hoạt. Wildcard được sinh ra để lấy lại sự linh hoạt đó.

3. Upper bounded wildcard: ? extends T

3.1. Đọc dữ liệu từ một collection

Giả sử bạn cần tính tổng giá của một danh sách sản phẩm. Phiên bản đầu tiên thường trông như sau:

static double totalPrice(List<Product> products) { ... }

Method này không nhận List<Book>. Ta sửa bằng ? extends:

public final class CatalogUtils {

    public static double totalPrice(List<? extends Product> products) {
        double total = 0;
        for (Product p : products) {
            total += p.price();
        }
        return total;
    }
}

List<? extends Product> nghĩa là “một list chứa phần tử thuộc một kiểu nào đó là Product hoặc con của Product”. Nhờ vậy:

  • Bạn truyền được List<Product>, List<Book>, hay List<Ebook> nếu có.
  • Mỗi phần tử đọc ra chắc chắn là một Product, nên vòng for (Product p : products) hợp lệ.
List<Book> books = List.of(
        new Book("Clean Code", 300_000),
        new Book("Effective Java", 450_000));

System.out.println(CatalogUtils.totalPrice(books));
750000.0

3.2. Vì sao không thêm được phần tử?

Thử thêm một phần tử vào list có ? extends:

static void addBook(List<? extends Product> products) {
    products.add(new Book("Refactoring", 400_000)); // Lỗi compile
    products.add(null);                              // OK, nhưng vô nghĩa
}

Compiler không biết ? thực sự là gì. List truyền vào có thể là List<Ebook>. Khi đó thêm một Book thường vào sẽ phá vỡ an toàn kiểu. Vì vậy compiler cấm mọi thao tác add, trừ null.

Quy tắc dễ nhớ: ? extends T chỉ để đọc ra T. Collection lúc này đóng vai producer, tức là nơi cung cấp dữ liệu cho bạn.

4. Lower bounded wildcard: ? super T

Bây giờ làm ngược lại. Bạn muốn một method nạp sách mặc định vào bất kỳ list nào có thể chứa Book:

public static void addDefaultBooks(List<? super Book> target) {
    target.add(new Book("Clean Code", 300_000));
    target.add(new Book("Effective Java", 450_000));
}

List<? super Book> nghĩa là “một list có kiểu phần tử là Book hoặc cha của Book”. Mọi kiểu như vậy đều chứa được một Book, nên add(new Book(...)) luôn an toàn.

List<Book> books = new ArrayList<>();
List<Product> products = new ArrayList<>();
List<Object> anything = new ArrayList<>();

CatalogUtils.addDefaultBooks(books);    // OK
CatalogUtils.addDefaultBooks(products); // OK
CatalogUtils.addDefaultBooks(anything); // OK

System.out.println(products.size());
2

Đổi lại, khi đọc ra, bạn chỉ biết chắc phần tử là Object:

static void printFirst(List<? super Book> target) {
    Object first = target.get(0); // OK
    Book book = target.get(0);    // Lỗi compile
}

List có thể là List<Object> chứa đủ thứ, nên compiler chỉ đảm bảo được kiểu Object.

Quy tắc: ? super T để ghi T vào. Collection lúc này là consumer, tức là nơi tiêu thụ dữ liệu bạn đưa vào.

5. Nguyên tắc PECS

Hai quy tắc trên gộp lại thành PECS: Producer Extends, Consumer Super.

  • Tham số nào cung cấp T cho bạn thì dùng ? extends T.
  • Tham số nào nhận T từ bạn thì dùng ? super T.
  • Tham số vừa đọc vừa ghi thì không dùng wildcard, khai báo kiểu chính xác T.

5.1. Ví dụ: copy giữa hai list

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

source là producer nên dùng extends. target là consumer nên dùng super. Kết quả là method rất linh hoạt:

List<Book> books = List.of(new Book("DDD", 500_000));
List<Product> cart = new ArrayList<>();

CatalogUtils.<Book>copy(books, cart);   // T = Book: đọc Book, ghi vào list cha
CatalogUtils.<Product>copy(books, cart); // T = Product cũng hợp lệ

JDK áp dụng đúng nguyên tắc này. Chữ ký của Collections.copy là:

public static <T> void copy(List<? super T> dest, List<? extends T> src)

5.2. Ví dụ: Comparator dùng chung cho class cha

List.sort có chữ ký sort(Comparator<? super E> c). Comparator là consumer, vì nó nhận phần tử vào để so sánh. Nhờ ? super, bạn có thể dùng một comparator viết cho Product để sắp xếp List<Book>:

Comparator<Product> byPrice = Comparator.comparingDouble(Product::price);

List<Book> books = new ArrayList<>(List.of(
        new Book("Effective Java", 450_000),
        new Book("Clean Code", 300_000)));

books.sort(byPrice);
books.forEach(b -> System.out.println(b.name()));
Clean Code
Effective Java

Bạn sẽ gặp mẫu này rất nhiều trong Stream API. Ví dụ map(Function<? super T, ? extends R> mapper): input của function là consumer (super), output là producer (extends).

6. Unbounded wildcard: ?

Khi method không quan tâm kiểu phần tử, dùng ?:

public static int countNonNull(List<?> items) {
    int count = 0;
    for (Object item : items) {
        if (item != null) count++;
    }
    return count;
}

List<?> nhận mọi list: List<Book>, List<String>, List<Integer>. Phần tử đọc ra là Object, và bạn không thêm được gì ngoài null.

Đừng nhầm List<?> với List<Object>. Method nhận List<Object> không nhận List<String>, cũng vì tính invariant ở mục 2.

7. Kiểm thử hành vi

Thêm JUnit 5 và AssertJ vào pom.xml:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Nếu dự án dùng Spring Boot 3.x, spring-boot-starter-test đã có sẵn cả hai thư viện.

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class CatalogUtilsTest {

    @Test
    void totalPrice_acceptsListOfSubtype() {
        List<Book> books = List.of(
                new Book("Clean Code", 300_000),
                new Book("Effective Java", 450_000));

        assertThat(CatalogUtils.totalPrice(books)).isEqualTo(750_000.0);
    }

    @Test
    void addDefaultBooks_worksWithSupertypeList() {
        List<Object> anything = new ArrayList<>();
        anything.add("không phải sách");

        CatalogUtils.addDefaultBooks(anything);

        assertThat(anything).hasSize(3);
        assertThat(anything.get(1)).isInstanceOf(Book.class);
    }

    @Test
    void copy_movesBooksIntoProductList() {
        List<Book> books = List.of(new Book("DDD", 500_000));
        List<Product> cart = new ArrayList<>();

        CatalogUtils.copy(books, cart);

        assertThat(cart).extracting(Product::name).containsExactly("DDD");
    }

    @Test
    void addDefaultBooks_failsAtRuntimeOnImmutableList() {
        List<Product> immutable = List.of();

        assertThatThrownBy(() -> CatalogUtils.addDefaultBooks(immutable))
                .isInstanceOf(UnsupportedOperationException.class);
    }
}

Ba test đầu xác nhận các method nhận được kiểu con và kiểu cha như mong đợi. Test thứ hai cho thấy List<Object> vẫn giữ phần tử cũ và nhận thêm Book. Test cuối nhắc một điều quan trọng: wildcard chỉ kiểm tra kiểu lúc compile. Nó không biết list có cho phép sửa hay không.

8. Lỗi hay gặp và khi nào không nên dùng

Dùng wildcard ở kiểu trả về.

public static List<? extends Product> findAll() { ... } // không nên

Người gọi sẽ nhận một list gần như chỉ đọc được, và phải mang theo ? khắp nơi. Wildcard nên nằm ở tham số của method, không nằm ở kiểu trả về.

Dùng ? extends rồi cố gọi add. Nếu method cần ghi vào collection, extends là lựa chọn sai. Hãy xem lại PECS.

Dùng wildcard khi cần cả đọc lẫn ghi đúng kiểu. Ví dụ swap hai phần tử:

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);
}

Gọi list.set(i, list.get(j)) trực tiếp trên List<?> sẽ lỗi compile. Helper generic <T> “bắt” (capture) kiểu thật của ? để thao tác được. Kỹ thuật này gọi là wildcard capture. Nếu API public không bắt buộc phải là List<?>, khai báo thẳng <T> void swap(List<T> list, ...) sẽ đơn giản hơn.

Lạm dụng wildcard cho class nội bộ. Wildcard có ích nhất ở API dùng chung như thư viện hay utility. Với method private chỉ gọi từ một chỗ, kiểu cụ thể thường dễ đọc hơn.

Lưu ý: ? extends không biến list thành immutable. Method vẫn gọi được clear() hay remove(...), vì các thao tác này không cần biết kiểu phần tử. Nếu cần bảo vệ dữ liệu, hãy dùng List.copyOf(...) hoặc Collections.unmodifiableList(...).

Lưu ý: Không thể dùng cả extends và super trên cùng một wildcard. Cũng không thể viết ? super cho type parameter khai báo ở class hay method (<T super Book> là không hợp lệ).

9. Kết luận

Generics trong Java là invariant. Vì thế List<Book> không phải là List<Product>, và điều này giúp tránh lỗi kiểu lúc runtime. Wildcard trả lại sự linh hoạt cho API:

  • ? extends T dùng cho tham số cung cấp dữ liệu. Bạn đọc ra T được nhưng không thêm vào được.
  • ? super T dùng cho tham số nhận dữ liệu. Bạn thêm T vào được nhưng chỉ đọc ra Object.
  • ? dùng khi bạn không quan tâm kiểu phần tử.

Khi phân vân, bạn chỉ cần nhớ PECS: Producer Extends, Consumer Super. Ngoài ra, hãy giữ wildcard ở tham số method, không đặt ở kiểu trả về.

javagenericswildcardpecscollections