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。此外,将通配符放在方法参数中,而不是返回类型中。