本节目标:把
List<Book>、List<? extends Book>、List<? super Book>的选择变成一条可执行的规则,并用到 libraryd 的查询与批量接口上。
适用版本:JDK 25(Temurin 25.0.5),javac --release 25。
承接的问题
1.1 把仓储收敛成了 Repository<T, ID>,但只解决了「一个实现适配多种实体」。真正写查询 API 时,第二个问题马上出现:方法参数到底该声明成什么类型。
libraryd 有两个具体场景。第一个是分页结果的映射:Page<Book> 要转成 Page<BookSummary>,方法既要接受 Page<Book>,也要接受 Page<SubBook> 这类更具体的页。第二个是批量导入:调用方手里可能是 List<Book>、List<ImportedBook>,要写进一个 List<Book> 或 List<Object> 的接收集合。
如果所有参数都写成 List<Book>,前一个场景无法接受子类型;如果写成 List<Object>,后一个场景连 List<Book> 都传不进去。Java 的泛型默认是不变的,List<Book> 不是 List<Object> 的子类型。这一节要把这个限制拆开,给出参数位置的写法。
数组协变与泛型不变
数组是协变的:String[] 是 Object[] 的子类型。这看起来方便,代价是类型错误被推迟到运行时:
Object[] objects = new String[1];
try {
objects[0] = 42;
} catch (ArrayStoreException e) {
System.out.println("arrayStore=" + e.getClass().getSimpleName());
}
本机输出:
arrayStore=ArrayStoreException
编译器允许 objects[0] = 42,因为左边声明的是 Object[];运行时发现实际数组是 String[],抛 ArrayStoreException。数组在创建时记住了元素类型,所以能在写入时检查。
泛型选择了相反的方向:编译期严格,运行时不保留实参。List<Book> 不能赋给 List<Object>:
static void printAll(List<Object> values) {
values.forEach(System.out::println);
}
List<Book> books = new ArrayList<>();
printAll(books);
本机编译错误:
error: incompatible types: List<Book> cannot be converted to List<Object>
printAll(books);
^
如果允许这行通过,printAll 里的 values.add("not a book") 就会把一个字符串塞进原本的 List<Book>,而泛型在运行时没有数组那样的元素类型检查,错误会漂移到很远的地方才爆。泛型不变性是用「参数位置不灵活」换「编译期就能发现写入错误」。
PECS:extends 是生产者,super 是消费者
通配符用来在特定位置放宽不变性。规则通常缩写成 PECS:Producer Extends, Consumer Super。
如果一个集合只被读取(生产元素给调用方),用 ? extends T:
static double totalYears(List<? extends Book> books) {
return books.stream().mapToInt(Book::year).average().orElse(0);
}
List<Book>、List<SubBook> 都能传进来。方法内部只能读,不能 add,因为编译器不知道实际元素类型是 Book 还是 SubBook;往里写一个 Book 可能破坏 List<SubBook>。
如果一个集合只被写入(消费调用方提供的元素),用 ? super T:
static <T> void copy(List<? super T> destination, List<? extends T> source) {
for (T value : source) {
destination.add(value);
}
}
destination 声明为 List<? super T>,可以接收 List<T>、List<Object>、List<Number>(当 T 是 Integer 时)。方法内部能 add(T),因为目标集合至少能装 T;但读取只能得到 Object,因为编译器不知道上界具体是谁。
把两个方向放进 libraryd 的场景:
List<Book> books = List.of(
new Book("9780132350884", "Clean Code", "Robert C. Martin", 2008),
new Book("9780134685991", "Effective Java", "Joshua Bloch", 2018));
List<? extends Book> producer = books;
Book first = producer.getFirst();
List<Object> sink = new ArrayList<>();
copy(sink, books);
本机输出:
producer.get=Clean Code
copied=2
producer 只能读,sink 只能写。copy 的签名把「源可读、目标可写」同时表达出来,调用方不需要强转,也不需要为 List<Object> 和 List<Book> 各写一个重载。
返回值位置与参数位置的取舍
PECS 不是「所有地方都加通配符」。通配符出现在参数位置通常有益,出现在返回值位置通常有害。
先看 libraryd 的分页映射。Page<T> 是不可变 record,映射只读源页、产生新页:
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
public final class Pages {
public static <T, R> Page<R> map(Page<? extends T> page,
Function<? super T, ? extends R> mapper) {
List<R> mapped = new ArrayList<>(page.items().size());
for (T item : page.items()) {
mapped.add(mapper.apply(item));
}
return new Page<>(mapped, page.total(), page.page(), page.size());
}
}
三个通配符各有位置:
Page<? extends T>:源页可以是Page<Book>或Page<SubBook>,方法只读items。Function<? super T, ? extends R>:函数能接受T的父类型,返回R的子类型。- 返回
Page<R>:具体类型,调用方拿到后可以直接用,不需要再处理通配符。
如果把返回类型写成 Page<? extends R>,调用方每次都要面对一个捕获类型,既不能把结果传给需要 Page<BookSummary> 的方法,也不能方便地再映射一次。返回值应该尽量具体,参数才需要放宽。
批量保存是另一个方向。Repository<T, ID> 只有单条 save(T),批量接口可以单独定义:
public interface BatchRepository<T, ID> extends Repository<T, ID> {
default void saveAll(Iterable<? extends T> entities) {
for (T entity : entities) {
save(entity);
}
}
}
参数是 Iterable<? extends T>,所以 List<Book>、Set<ImportedBook> 都能传。方法内部只读取迭代器并调用 save(T),不会往调用方的集合里写,extends 是安全的。
BookRepository 可以同时继承两个端口:
public interface BookRepository extends Repository<Book, String>, BatchRepository<Book, String> {
List<Book> findByAuthor(String author);
}
这时 saveAll 的 T 被绑定为 Book,调用方传 List<ImportedBook>(如果它是 Book 的子类型)也能工作。注意 Iterable 而不是 Collection,因为调用方可能只有一个流式迭代器,不需要先物化成集合。
在 libraryd 里落地
把上面的规则落到三个具体位置。
第一,分页映射统一走 Pages.map,不要在 controller 里手写循环:
Page<Book> page = new Page<>(books, books.size(), 1, 10);
Page<String> titles = Pages.map(page, Book::title);
System.out.println("mapped=" + titles.items());
本机输出:
mapped=[Clean Code, Effective Java]
Book::title 是 Function<Book, String>,满足 Function<? super Book, ? extends String>。如果映射函数写成 Function<Object, String>,也能传,因为 Object 是 Book 的父类型;这正是 ? super T 的用途。
第二,查询条件用 Predicate<? super T>,让通用过滤器接受更宽的谓词:
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
public final class BookQueries {
public static <T extends Book> List<T> filter(List<? extends T> books,
Predicate<? super T> predicate) {
List<T> result = new ArrayList<>();
for (T book : books) {
if (predicate.test(book)) {
result.add(book);
}
}
return List.copyOf(result);
}
}
Predicate<Book> 可以过滤 List<SubBook>,因为 Book 是 SubBook 的父类型。反过来,Predicate<SubBook> 不能过滤 List<Book>,因为不是每本 Book 都是 SubBook。编译器会拒绝后者,这比运行时 ClassCastException 便宜。
第三,仓储的批量写入只暴露 Iterable<? extends T>,不暴露 List<T>。调用方用什么集合实现、是否懒加载,都不影响端口:
BookRepository repository = new InMemoryBookRepository();
repository.saveAll(List.of(
new Book("9780132350884", "Clean Code", "Robert C. Martin", 2008),
new Book("9780134685991", "Effective Java", "Joshua Bloch", 2018)));
System.out.println("all=" + repository.findAll().size());
本机输出:
all=2
saveAll 是默认方法,JDBC 实现如果要做批处理,可以覆盖它,用一条 insert ... values (...), (...) 或 PreparedStatement.addBatch()。端口签名不变,调用方不需要知道底层是逐条还是批量。
常见错误与边界
「往 List<? extends Book> 里 add。」 下面的代码编译失败:
List<? extends Book> books = new ArrayList<Book>();
books.add(new Book("X", "X", "X", 2026));
错误是 capture of ? extends Book 不能接受 Book。编译器不知道这个列表实际是 List<Book> 还是 List<SubBook>,写入可能破坏子类型列表。需要写入时,参数应该声明成 List<? super Book>,或者直接使用具体类型 List<Book>。
「从 List<? super Book> 里读成 Book。」 List<? super Book> 的 get(0) 返回 Object,不是 Book。因为目标列表可能是 List<Object>,里面装的未必都是书。读取需要强转,而强转把检查推回运行时;如果方法只需要读,就不该用 super。
「同时读写时硬套 PECS。」 一个方法既要从集合读 Book,又要往同一个集合写 Book,? extends 和 ? super 都不合适,应该直接用 List<Book>。PECS 的前提是「只读」或「只写」;两边都做时,通配符只会制造捕获类型。
「返回类型写通配符。」 List<? extends Book> findAll() 让调用方无法 add,也无法把结果传给 List<Book> 参数。返回值应该尽量具体,List<Book> 比 List<? extends Book> 更好用;只有在实现内部需要隐藏具体类型时才用通配符。
「比较器写成 Comparator<Book> 后拒绝父类型比较器。」 sort(List<T>, Comparator<T>) 会让 Comparator<Object> 无法传给 List<Book>。签名写成 Comparator<? super T>,调用方才能复用通用的、按 Object 工作的比较器。
小结
- 数组协变把类型检查推到运行时,
ArrayStoreException是代价;泛型不变把检查留在编译期。 - PECS:只读用
? extends T,只写用? super T,同时读写就直接用T。 - 参数位置放宽能提高复用,返回值位置收紧能降低调用方负担;
Page.map的签名同时体现了这两点。 Iterable<? extends T>适合批量写入端口,Predicate<? super T>适合通用过滤,Comparator<? super T>适合排序。- 通配符捕获类型不能 add 到
extends列表,也不能从super列表读出具体类型,遇到时先判断读写方向。 - 泛型不变性不是缺陷,它用「少一点灵活」换「写入错误在编译期暴露」。
型变解决的是「同一个算法接受哪些集合」,但还有一类问题它管不到:运行时怎样知道 Page<Book> 里的 Book。下一节从类型擦除讲起,用类型令牌把泛型实参保留到运行时。
阅读导航:上一节:1.1 泛型边界:从 Book 到 Repository<T, ID> · 下一节:1.3 类型擦除与类型令牌:TypeReference 与反射补偿 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。