本节目标:看懂
? extends T、? super T和?各自允许读什么、写什么,能按 PECS 规则设计方法签名,并说清List<Book>为什么不能当List<Object>用。
适用版本:JDK 25 LTS(Temurin 25.0.5);通配符语法在 JDK 21 上没有差异。
上一节留下的问题
Repository<T, ID> 解决了仓储重复的问题,但马上会遇到新的编译错误。假设我们想写一个方法,把一组书复制到另一个容器里:
static void copyAll(List<Object> target, List<Book> source) {
target.addAll(source);
}
调用方手里只有一个书架 List<Book> shelf,想把它自己的书复制到另一个书架:
List<Book> shelf = new ArrayList<>();
copyAll(shelf, shelf);
错误: 不兼容的类型: List<Book>无法转换为List<Object>
copyAll(shelf, shelf);
^
List<Book> 不能传给 List<Object> 参数,哪怕 Book 是 Object 的子类型。如果强行赋值,同样编译失败:
List<Object> objects = new ArrayList<Book>();
错误: 不兼容的类型: ArrayList<Book>无法转换为List<Object>
这不是编译器故意刁难,而是泛型为了保证类型安全做的取舍。理解这个取舍,才能理解通配符为什么存在。
数组协变留下的坑
在泛型之前,Java 的数组是协变的:String[] 是 Object[] 的子类型。
Object[] objects = new String[1];
objects[0] = 42;
这段代码能编译,但运行时报错:
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
at ArrayCovProbe.main(ArrayCovProbe.java:4)
数组在运行时知道自己装的是 String,所以拒绝存入 Integer。问题是错误发生在运行时,而且写代码的人可能离赋值语句很远。
泛型选择了相反的策略:不型变(invariant)。List<Book> 和 List<Object> 没有子类型关系,把 List<Book> 赋给 List<Object> 直接编译失败。代价是写法更啰嗦,收益是类型错误在编译期就被拦住——如果 List<Book> 能当 List<Object> 用,你就能往里塞一个 Member,然后所有读 Book 的地方都会在运行时炸掉。
三个通配符,三种读写能力
泛型不型变,但业务代码确实需要「接受 Book 及其子类的列表」或「接受能装 Book 的容器」。通配符就是为这种需求开的口子。
| 写法 | 含义 | 能读成什么 | 能写入什么 |
|---|---|---|---|
List<? extends Book> | Book 或它的子类型 | Book | 除 null 外不能写 |
List<? super Book> | Book 或它的父类型 | Object | Book 及其子类型 |
List<?> | 未知类型 | Object | 除 null 外不能写 |
? extends T:上界,适合读
List<? extends Number> source = List.of(1, 2, 3);
Number first = source.get(0);
// source.add(4); // 编译错误
source 的实际类型可能是 List<Integer>,也可能是 List<Double>。编译器只知道「元素至少是 Number」,所以读出来的东西一定能赋给 Number;但写入时无法确定这个列表到底允许 Double 还是只允许 Integer,因此一律拒绝。
? super T:下界,适合写
List<? super Book> target = new ArrayList<Object>();
target.add(new Book("978-7-111-54742-6", "Effective Java", "Joshua Bloch", 2018));
Object value = target.get(0); // 只能读成 Object
// Book book = target.get(0); // 编译错误
target 的实际类型可能是 List<Book> 或 List<Object>。编译器知道「它至少能装 Book」,所以写入 Book 是安全的;但读出来时无法确定具体是哪个父类型,只能当成 Object。
?:完全未知
List<?> 等价于 List<? extends Object>,但语义更弱:你连「元素是 Object 的子类型」之外的任何信息都没有。能调用的方法只有不依赖元素类型的那几个,比如 size()、isEmpty()、clear()。它适合写「只关心集合大小或遍历打印」的方法。
PECS:Producer Extends, Consumer Super
把上面的规律压缩成一句口诀,就是 PECS:
- 如果参数是生产者(方法从里面读 T),用
? extends T。 - 如果参数是消费者(方法往里面写 T),用
? super T。 - 如果既要读又要写,或者类型要出现在返回值里,就用具体的
T。
JDK 里到处都是这条规则:
| API | 签名片段 | 角色 |
|---|---|---|
Collection.addAll | Collection<? extends E> | 生产者 |
Collections.copy | List<? super T> dest, List<? extends T> src | 消费者 + 生产者 |
Iterable.forEach | Consumer<? super T> | 消费者 |
List.sort | Comparator<? super E> | 消费者 |
Stream.max | Comparator<? super T> | 消费者 |
一个最小的复制方法可以写成:
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (T item : src) {
dest.add(item);
}
}
src 只负责产出 T,所以用 ? extends T;dest 只负责接收 T,所以用 ? super T。调用时 T 可以由两个参数共同推断,既能把 List<Book> 复制到 List<Object>,也能把 List<Book> 复制到 List<Book>。
注意方法上的 <T> 不能省。T 出现在两个参数之间,负责建立「源元素能写进目标」这层关系;如果只写 List<? super Object> 和 List<? extends Object>,就丢掉了这层关系。
在 library 里落地
library 的查询工具类正好需要这三种形状。下面三个方法分别处理「找最大」「复制」「遍历」:
public final class LibraryQueries {
private LibraryQueries() {
}
public static <T> T maxBy(Collection<? extends T> items,
Comparator<? super T> order) {
Iterator<? extends T> it = items.iterator();
if (!it.hasNext()) {
throw new IllegalArgumentException("empty collection");
}
T best = it.next();
while (it.hasNext()) {
T candidate = it.next();
if (order.compare(candidate, best) > 0) {
best = candidate;
}
}
return best;
}
public static <T> void copyInto(Collection<? super T> target,
Collection<? extends T> source) {
for (T item : source) {
target.add(item);
}
}
public static void eachBook(Collection<? extends Book> books,
Consumer<? super Book> action) {
for (Book book : books) {
action.accept(book);
}
}
}
逐个看签名:
maxBy返回T,所以T必须是方法级类型参数;items只读,用? extends T;order只消费T,用Comparator<? super T>。这样Comparator<Object>也能传给maxBy,因为它能比较任何对象,当然也能比较Book。copyInto的target是消费者,用Collection<? super T>;source是生产者,用Collection<? extends T>。调用方可以把List<Book>复制进List<Object>。eachBook只读图书,所以Collection<? extends Book>;回调只消费图书,所以Consumer<? super Book>。传一个Consumer<Object>也能工作。
在服务层可以再包一层,把仓储里的书收集到调用方提供的容器:
public void collectAllBooks(Collection<? super Book> target) {
LibraryQueries.copyInto(target, books.findAll());
}
这个方法不关心 target 到底是 List<Book>、List<Object> 还是 Set<Object>,只要它能接收 Book 即可。
运行一个完整例子:
Book latest = LibraryQueries.maxBy(DemoData.BOOKS,
Comparator.comparingInt(Book::year));
System.out.println("latest = " + latest.title() + " (" + latest.year() + ")");
List<Object> sink = new ArrayList<>();
LibraryQueries.copyInto(sink, DemoData.BOOKS);
System.out.println("copied = " + sink.size());
Consumer<Object> printer = item -> System.out.println("item: " + item);
LibraryQueries.eachBook(DemoData.BOOKS.subList(0, 2), printer);
Comparator<Object> byToString = Comparator.comparing(Object::toString);
Book last = LibraryQueries.maxBy(DemoData.BOOKS, byToString);
System.out.println("toString max = " + last.title());
本机 JDK 25 输出:
latest = 深入理解 Java 虚拟机 (2019)
copied = 6
item: Book[isbn=978-7-111-54742-6, title=Effective Java, author=Joshua Bloch, year=2018]
item: Book[isbn=978-7-111-11091-5, title=Java Puzzlers, author=Joshua Bloch, year=2005]
toString max = 深入理解 Java 虚拟机
Comparator<Object> 能传给 maxBy,是 ? super T 最直观的价值:比较器只需要能比较 Book,而一个能比较所有 Object 的比较器当然满足这个条件。
常见错误与边界
错误一:往 ? extends 里写
static void addAll(List<? extends String> target) {
target.add("x");
}
错误: 不兼容的类型: String无法转换为CAP#1
target.add("x");
^
其中, CAP#1是新类型变量:
CAP#1从? extends String的捕获扩展String
CAP#1 是编译器为通配符生成的捕获类型。它可能是 String 的任意子类型,编译器不能假设 String 能放进去。需要写入时,参数应该声明成 List<? super String> 或 List<String>。
错误二:从 ? super 里读成具体类型
static Book first(List<? super Book> books) {
return books.get(0);
}
错误: 不兼容的类型: CAP#1无法转换为Book
return books.get(0);
^
其中, CAP#1是新类型变量:
CAP#1从? super Book的捕获扩展Object 超 Book
? super Book 只保证「能装 Book」,不保证「装的都是 Book」。实际列表可能是 List<Object>,里面完全可能有一个 Member。读出来只能当 Object。
错误三:把通配符写进返回值
public List<? extends Book> findAll() {
return List.copyOf(store.values());
}
这个签名能编译,但调用方拿到 List<? extends Book> 后既不能 add,也不能把它传给需要 List<Book> 的方法。除非你确实想表达「只读视图」,否则返回值用具体的 List<Book>。通配符主要用在参数上,不滥用返回值。
边界:? 和 Object 不是一回事
List<?> 和 List<Object> 看起来都「什么都能装」,实际能力完全不同:
List<?> unknown = List.of("a", "b");
// unknown.add("c"); // 编译错误
List<Object> objects = new ArrayList<>();
objects.add("c"); // 可以
List<Object> 明确表示「元素类型是 Object」,所以什么都能放;List<?> 表示「元素类型未知」,所以除了 null 什么都不能放。
小结
- 泛型是不型变的,
List<Book>不是List<Object>;这换来了编译期的类型安全,避免数组协变那种运行时ArrayStoreException。 ? extends T是上界,适合读,读出来是T,写入一律拒绝。? super T是下界,适合写,能写入T,读出来只能当Object。- PECS 的口诀是「生产者用 extends,消费者用 super」;类型出现在返回值或多个参数之间时,用方法级
<T>。 maxBy、copyInto、eachBook分别覆盖了返回值、消费者、回调三种场景,签名可以直接套到别的领域类型上。List<?>不是List<Object>:前者只能读,后者能写。
通配符解决的是「怎么安全地读写」,但还有一个问题没回答:这些 <T> 在运行时到底去哪了?为什么不能 new T(),也不能 instanceof List<String>?下一节拆类型擦除。
阅读导航:上一节:11.1 泛型类与方法 · 下一节:11.3 类型擦除与运行时真相 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。