本节目标:能写出泛型类、泛型方法和泛型接口,理解有界类型参数的作用,并把
library的仓储层改造成Repository<T, ID>,同时引入Result<T>表达「成功或失败」。
适用版本:JDK 25 LTS(Temurin 25.0.5);泛型语法在 JDK 21 上没有差异。
上一节留下的问题
第 10 章结束时,library 的仓储层能按 ISBN 存书、按会员号存人、按借阅号存借阅记录。为了做到这一点,我们写了三个很像的接口:
public interface BookRepository {
void save(Book book);
Book findByIsbn(String isbn);
List<Book> findAll();
}
public interface MemberRepository {
void save(Member member);
Member findById(String id);
List<Member> findAll();
}
LoanRepository 还要再写一遍,区别只是类型不同。方法体也几乎是复制粘贴:一个 Map、一次 put、一次 get、一次 values()。新增一种实体就要新增一套接口和实现,想写一个「给任何仓储都加统计」的工具方法也没有统一的类型可依赖。
一个直觉方案是把值统一存成 Object:
public final class ObjectRepository {
private final Map<String, Object> store = new HashMap<>();
public void save(String id, Object value) {
store.put(id, value);
}
public Object findById(String id) {
return store.get(id);
}
}
类型信息在 get 的那一刻就丢了,调用方必须自己强转:
ObjectRepository repo = new ObjectRepository();
repo.save("M001", new Member("M001", "张三", "zhang@example.com"));
Book book = (Book) repo.findById("M001");
本机 JDK 25 运行这段代码的输出是:
Exception in thread "main" java.lang.ClassCastException: class com.plume.library.domain.Member cannot be cast to class com.plume.library.domain.Book (com.plume.library.domain.Member and com.plume.library.domain.Book are in unnamed module of loader 'app')
at com.plume.library.demo.Demo11_0.main(Demo11_0.java:25)
编译器完全没意见,因为 Object 转任何引用类型都合法;错误被推迟到运行时,而且离真正写错的地方很远。泛型要解决的正是这件事:把容器里装的是什么类型写进类型系统,让编译器替你检查。
泛型类:把类型写成参数
先看一个最小的泛型类。它只做一件事:装一个值。
public final class Box<T> {
private final T value;
public Box(T value) {
this.value = value;
}
public T get() {
return value;
}
}
T 是类型参数(type parameter),Book、String 这些是类型实参(type argument)。使用时:
Box<Book> bookBox = new Box<>(new Book("978-7-111-54742-6", "Effective Java", "Joshua Bloch", 2018));
Book book = bookBox.get();
bookBox.get() 的返回类型是 Book,不需要强转。右边 new Box<>(...) 里的 <> 叫钻石运算符(diamond),让编译器根据左边的 Box<Book> 推断类型实参。
泛型方法:类型参数放在返回值前
类型参数不一定属于整个类。只在一个方法里用到的类型,可以声明在方法上。下面这个方法找列表里的最大值:
public static <T extends Comparable<T>> T max(List<T> values) {
if (values.isEmpty()) {
throw new IllegalArgumentException("empty list");
}
T best = values.get(0);
for (T value : values) {
if (value.compareTo(best) > 0) {
best = value;
}
}
return best;
}
<T extends Comparable<T>> 是上界(upper bound):类型实参必须能和自己比较。String、Integer、LocalDate 都满足,调用时不需要写类型实参:
System.out.println(max(List.of("b", "a", "c")));
System.out.println(max(List.of(3, 1, 2)));
输出:
c
3
有界类型参数的价值在于:如果传入没有实现 Comparable 的类型,比如自定义的 Member,编译器会在调用点报错,而不是等到 compareTo 执行时才发现。上界也可以写多个,但类只能有一个且必须写在最前面,例如 <T extends Comparable<T> & Serializable>。
泛型接口:Repository<T, ID>
把上面的思路用到仓储层,接口可以收成一个:
public interface Repository<T, ID> {
T save(T entity);
Optional<T> findById(ID id);
List<T> findAll();
boolean deleteById(ID id);
long count();
}
两个类型参数各有分工:T 是实体类型,ID 是主键类型。Book 用 ISBN 作主键,主键是 String;Loan 用自增编号作主键,主键是 Long。同一个接口能同时表达这两种仓储。
具体仓储只需要继承并补充领域查询:
public interface BookRepository extends Repository<Book, String> {
List<Book> findByAuthor(String author);
}
通用实现也不需要知道具体实体是什么:
public class InMemoryRepository<T, ID> implements Repository<T, ID> {
private final Map<ID, T> store = new LinkedHashMap<>();
private final Function<T, ID> idOf;
public InMemoryRepository(Function<T, ID> idOf) {
this.idOf = idOf;
}
@Override
public T save(T entity) {
store.put(idOf.apply(entity), entity);
return entity;
}
@Override
public Optional<T> findById(ID id) {
return Optional.ofNullable(store.get(id));
}
@Override
public List<T> findAll() {
return List.copyOf(store.values());
}
@Override
public boolean deleteById(ID id) {
return store.remove(id) != null;
}
@Override
public long count() {
return store.size();
}
}
这里有一个看起来多余的设计:为什么把 idOf 作为构造参数传进来,而不是在 save 里直接调用 entity.getId()?因为 T 在运行时已经被擦除了,编译器不知道 T 有没有 getId(),甚至不知道 T 是不是一个类。Function<T, ID> 把「怎么从实体取主键」变成一段显式传入的代码,既绕开擦除限制,也让主键策略可以按实体定制。
图书仓储的实现就很薄了:
public final class InMemoryBookRepository
extends InMemoryRepository<Book, String>
implements BookRepository {
public InMemoryBookRepository() {
super(Book::isbn);
}
@Override
public List<Book> findByAuthor(String author) {
List<Book> result = new ArrayList<>();
for (Book book : findAll()) {
if (book.author().equals(author)) {
result.add(book);
}
}
return List.copyOf(result);
}
}
Result:把成功和失败写进返回类型
泛型不只用于容器。「注册图书」有两种正常结果:注册成功,或者 ISBN 已存在。后者是用户输入问题,不是程序故障,适合放进返回值。先定义一个只可能有两种形态的结果类型:
public sealed interface Result<T> permits Result.Ok, Result.Err {
record Ok<T>(T value) implements Result<T> { }
record Err<T>(String message) implements Result<T> { }
static <T> Result<T> ok(T value) {
return new Ok<>(value);
}
static <T> Result<T> err(String message) {
return new Err<>(message);
}
default <R> Result<R> map(Function<? super T, ? extends R> mapper) {
if (this instanceof Ok<T> ok) {
return Result.ok(mapper.apply(ok.value()));
}
if (this instanceof Err<T> err) {
return Result.err(err.message());
}
throw new AssertionError("unreachable: " + getClass());
}
}
Result<T> 是泛型接口,Ok<T> 和 Err<T> 是它的两个泛型实现;permits 把实现限定在这两个 record 上。map 把 Result<T> 变成 Result<R>,成功时转换值,失败时原样传递错误消息。这是泛型方法在接口默认方法里的典型用法。
LibraryService.registerBook 返回 Result<Book>:
public Result<Book> registerBook(Book book) {
if (books.findById(book.isbn()).isPresent()) {
return Result.err("ISBN 已存在: " + book.isbn());
}
return Result.ok(books.save(book));
}
调用方不需要猜「返回 null 是成功还是失败」,也不需要捕获一个只用于表达业务分支的异常。类型签名已经写清楚了:要么拿到 Book,要么拿到一条消息。
在 library 里落地
命名说明:从本节起,示例里的
Book组件名写作year,对应 9.1–10.3 的publishedYear;后面的 JSON 与 CLI 也统一用year,两者指同一个字段。
把上面的零件串起来:
Repository<Book, String> books = new InMemoryRepository<>(Book::isbn);
books.save(new Book("978-7-111-54742-6", "Effective Java", "Joshua Bloch", 2018));
Optional<Book> found = books.findById("978-7-111-54742-6");
System.out.println("found = " + found);
System.out.println("missing = " + books.findById("978-7-000-00000-0"));
Result<String> title = Result.ok(found.orElseThrow()).map(Book::title);
System.out.println("title = " + title.orElseThrow());
Result<Book> duplicate = Result.err("ISBN 已存在: 978-7-111-54742-6");
System.out.println("mapped = " + duplicate.map(Book::title));
本机 JDK 25 输出:
found = Optional[Book[isbn=978-7-111-54742-6, title=Effective Java, author=Joshua Bloch, year=2018]]
missing = Optional.empty
title = Effective Java
mapped = Err[message=ISBN 已存在: 978-7-111-54742-6]
在真正的服务里,registerBook 返回的 Result<Book> 可以直接用 map(Book::title) 取标题。第 13 章会讨论 Result<T> 和异常的分工:哪些失败应该返回结果,哪些应该抛出异常。现在先记住一点:泛型让「成功值」和「失败消息」都能带上具体类型,而不是退化成 Object。
常见错误与边界
错误一:使用原始类型
泛型类可以不带类型实参使用,这叫原始类型(raw type):
List<String> names = new ArrayList<>();
List raw = names;
raw.add(42);
String first = names.get(0);
编译时只有一个警告:
警告: [unchecked] 对作为原始类型List的成员的add(E)的调用未经过检查
raw.add(42);
^
运行时会在 names.get(0) 抛出:
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
原始类型把泛型检查全部关掉了。除非调用老 API,否则不要写裸类型;需要装任意类型时显式写 List<Object>。
错误二:基本类型不能做类型参数
List<int> values;
错误: 意外的类型
List<int> values;
^
需要: 引用
找到: int
类型参数必须是引用类型。需要装 int 时用 Integer,自动装箱让你能写 List<Integer> values = List.of(1, 2, 3),但类型实参本身仍然是 Integer。
错误三:静态成员不能使用类级类型参数
public class Box<T> {
static T value;
}
错误: 无法从静态上下文中引用非静态 类型变量 T
static T value;
^
T 是每个 Box 实例在编译期的类型参数,静态成员属于类本身,没有「哪个 Box 的 T」可言。需要泛型的静态工具方法时,把 <T> 声明在方法上。
小结
- 泛型把类型实参写进类型系统,让
Box<Book>的get直接返回Book,把强转错误从运行时提前到编译期。 - 泛型方法把
<T>声明在返回值前,类型通常由参数推断;有界类型参数用extends约束能力。 Repository<T, ID>用一个接口覆盖图书、会员、借阅三类仓储,领域查询放在子接口里。- 通用实现需要
Function<T, ID>取主键,因为运行时拿不到T的方法;这是擦除带来的设计约束。 Result<T>用 sealed 接口和两个泛型 record 表达成功与失败,map可以在不拆包的情况下转换成功值。- 原始类型会关闭泛型检查,基本类型不能做类型参数,静态成员不能引用类级类型参数。
Repository<Book, String> 能装书,但一个「接受任意图书集合」的方法仍然会卡住:List<Book> 不是 List<Object>。下一节讲通配符,以及 PECS 这条几乎每个 Java API 都在用的规则。
阅读导航:上一节:10.3 遍历、排序与比较器 · 下一节:11.2 通配符与 PECS 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。