Java 中的泛型:通配符 ? extends 和 ? super

1. 概述

泛型帮助 Java 代码在编译时实现类型安全。但是当你写一个接收 List<Product> 的方法时,你会发现它不接受 List<Book>,即使 Book 是 Product 的子类。本文将解释为什么会这样。然后本文指导如何使用通配符 ? extends 和 ? super 来编写更灵活的 API。读完本文后,你将学会根据 PECS 原则选择合适的通配符,并避免难以理解的编译错误。

2. 问题:泛型是不变的

我们使用一个简单的类层次结构来贯穿全文:

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 是一个 Product。那么 List<Book> 是 List<Product> 吗?

List<Book> books = new ArrayList<>();
List<Product> products = books; // 编译错误:不兼容的类型

答案是 不是。Java 中的泛型是 不变的(invariant)。其中 A 是 B 的子类,List<A> 和 List<B> 仍然是两个不相关的类型。

原因在于类型安全本身。假设上面那行编译通过,我们可以编写:

products.add(new Product("钢笔", 5_000)); // 向只包含 Book 的列表中添加 Product
Book first = books.get(0);                  // ClassCastException!

数组则不同。Java 中的数组是 协变的(covariant),所以类似的错误只在运行时才会暴露出来:

Object[] items = new String[2];
items[0] = 42; // 编译通过,运行时抛出 ArrayStoreException

泛型选择在编译时就阻止错误。代价是灵活性降低。通配符的出现就是为了恢复这种灵活性。

3. 上界通配符:? extends T

3.1. 从集合中读取数据

假设你需要计算一个产品列表的总价。第一个版本通常看起来像这样:

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

这个方法不接受 List<Book>。我们可以用 ? 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> 的意思是“一个列表,其元素属于 某个 是 Product 或其子类的类型”。因此:

  • 你可以传递 List<Product>、List<Book> 或 List<Ebook>(如果有的话)。
  • 每个读出的元素肯定是一个 Product,所以 for (Product p : products) 循环是合法的。
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. 为什么无法添加元素?

尝试向一个带有 ? extends 的列表中添加元素:

static void addBook(List<? extends Product> products) {
    products.add(new Book("Refactoring", 400_000)); // 编译错误
    products.add(null);                              // 可以,但没有意义
}

编译器不知道 ? 真正是什么。传入的列表可能是 List<Ebook>。那样的话,添加一个 Book 会破坏类型安全。因此,编译器禁止所有 add 操作,除了 null。

容易记住的规则:? extends T 只用于读取 T。此时集合扮演 生产者(producer) 的角色,即为你提供数据的来源。

4. 下界通配符:? super T

现在来做相反的事情。你想要一个方法,能够将默认书籍添加到任何可以包含 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> 的意思是“一个列表,其元素类型是 Book 或 Book 的 父类”。所有这样的类型都能包含一个 Book,所以 add(new Book(...)) 总是安全的。

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

相反,当读取元素时,你只能确定元素是 Object:

static void printFirst(List<? super Book> target) {
    Object first = target.get(0); // OK
    Book book = target.get(0);    // 编译错误
}

列表可能是包含各种东西的 List<Object>,所以编译器只能保证 Object 类型。

规则:? super T 用于写入 T。此时集合是 消费者(consumer),即接收你提供的数据的地方。

5. PECS 原则

上面两条规则可以总结为 PECS:Producer Extends, Consumer Super。

  • 如果参数 为你提供 T,则使用 ? extends T。
  • 如果参数 接收 来自你的 T,则使用 ? super T。
  • 如果参数既要读又要写,则不使用通配符,而是声明精确的类型 T。

5.1. 示例:在两个列表之间复制

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

source 是生产者,所以使用 extends。target 是消费者,所以使用 super。结果是这个方法非常灵活:

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

CatalogUtils.<Book>copy(books, cart);   // T = Book:读取 Book,写入其父类列表
CatalogUtils.<Product>copy(books, cart); // T = Product 也是合法的

JDK 正确应用了这个原则。Collections.copy 的签名是:

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

5.2. 示例:为父类编写的 Comparator

List.sort 的签名是 sort(Comparator<? super E> c)。Comparator 是消费者,因为它接收元素进行比较。由于有 ? super,你可以使用为 Product 编写的 comparator 来排序 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

你会在 Stream API 中频繁看到这种模式。例如 map(Function<? super T, ? extends R> mapper):function 的输入是消费者(super),输出是生产者(extends)。

6. 无界通配符:?

当方法不关心元素的类型时,使用 ?:

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

List<?> 接受任何列表:List<Book>、List<String>、List<Integer>。读出的元素是 Object,除了 null 外你无法添加任何内容。

不要混淆 List<?> 和 List<Object>。接收 List<Object> 的方法 不会 接受 List<String>,原因还是因为第 2 节中讲到的不变性。

7. 行为测试

将 JUnit 5 和 AssertJ 添加到 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>

如果你的项目使用 Spring Boot 3.x,spring-boot-starter-test 已经包含了这两个库。

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("不是书");

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

前三个测试确认了方法如预期那样接受子类型和父类型。第二个测试表明 List<Object> 保留了原有元素并接收了额外的 Book。最后一个测试提醒一件重要的事:通配符只在编译时检查 类型。它不知道列表是否允许修改。

8. 常见错误和何时不应使用

在返回类型中使用通配符。

public static List<? extends Product> findAll() { ... } // 不应该

调用者会收到一个几乎只能读的列表,并且必须在各处处理 ?。通配符应该出现在方法的 参数 中,而不是返回类型中。

使用 ? extends 然后试图调用 add。 如果方法需要写入集合,extends 是错误的选择。请回顾 PECS 原则。

当需要同时读和写且类型正确时使用通配符。 例如交换两个元素:

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

直接在 List<?> 上调用 list.set(i, list.get(j)) 会编译错误。泛型 helper <T> “捕获”(capture)? 的真实类型以进行操作。这种技术叫做 通配符捕获(wildcard capture)。如果公共 API 不一定要是 List<?>,直接声明 <T> void swap(List<T> list, ...) 会更简单。

过度使用通配符处理内部类。 通配符在共享的 API 中最有用,比如库或工具类。对于只从一个地方调用的私有方法,具体的类型通常更易读。

注意: ? extends 不会使列表变成不可变的。方法仍然可以调用 clear() 或 remove(...),因为这些操作不需要知道元素的类型。如果需要保护数据,请使用 List.copyOf(...) 或 Collections.unmodifiableList(...)。

注意: 不能在同一个通配符上同时使用 extends 和 super。也不能为在类或方法中声明的类型参数写 ? super(<T super Book> 是非法的)。

9. 结论

Java 中的泛型是不变的。因此 List<Book> 不是 List<Product>,这有助于避免运行时类型错误。通配符为 API 恢复了灵活性:

  • ? extends T 用于 提供 数据的参数。你可以读取 T,但无法添加。
  • ? super T 用于 接收 数据的参数。你可以添加 T,但只能读取 Object。
  • ? 用于当你不关心元素的类型时。

当困惑时,你只需记住 PECS:Producer Extends, Consumer Super。此外,将通配符放在方法参数中,而不是返回类型中。

javagenericswildcardpecscollections