本节目标:把 libraryd 里重复的仓储方法收敛成
Repository<T, ID>,并说清楚泛型能挡住哪些错误、挡不住哪些错误。
适用版本:JDK 25(Temurin 25.0.5),javac --release 25。
承接的问题
卷二结束时,libraryd 的 persistence 模块里有三套几乎一样的仓储:BookRepository 按 ISBN 查书,MemberRepository 按会员号查人,LoanRepository 按借阅号查借阅记录。三个类的 findById、findAll、save、deleteById 方法体复制粘贴,差别只有实体类型和主键类型。
复制粘贴的直接后果不是代码多,而是参数类型可以悄悄换错。BookRepository.findById(String isbn) 和 LoanRepository.findById(long id) 各自成立,但调用方很容易把会员号传给借阅仓储,编译器只能看到「都是字符串」或「都能转成 long」,帮不上忙。更麻烦的是,新增 Reservation 实体时,又要复制第四套。
这一节要回答三个问题:
Repository<T, ID>的两个类型参数分别约束什么,为什么不能合并成一个。- 什么时候需要给类型参数加上界,上界如何改变方法签名。
- 编译器为泛型子类生成的桥方法是什么,为什么
javap里会多出几个方法。
最后会把泛型边界整理成一张可操作的检查表,后面 1.2 的型变和 1.3 的擦除都从这里继续。
两个类型参数各自约束什么
先把仓储端口写成两个参数:
package com.plume.library.domain;
import java.util.List;
import java.util.Optional;
public interface Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
void save(T entity);
void deleteById(ID id);
}
T 是实体类型,ID 是主键类型。它们没有继承关系,也不应该被合并成 Repository<T> 再用 Object id。合并之后,findById 的签名退化成 findById(Object id),调用方可以传任何对象,编译器不再检查主键类型。两个参数的价值就在于把「Book 的主键是 String、Loan 的主键是 long」写进类型。
具体实体接口只需要绑定一次:
public interface BookRepository extends Repository<Book, String> {
List<Book> findByAuthor(String author);
}
此时 BookRepository 继承了 Optional<Book> findById(String)、void save(Book) 等方法。调用方拿到的是具体类型,不需要强制转换。LoanRepository 则写成 extends Repository<Loan, Long>,主键用包装类型而不是 long,原因在后面的常见错误里说明。
泛型不是只在接口上摆样子。实现类必须给出具体类型,否则编译器会要求它继续声明类型参数:
public final class InMemoryBookRepository implements BookRepository {
private final Map<String, Book> books = new LinkedHashMap<>();
@Override
public Optional<Book> findById(String isbn) {
return Optional.ofNullable(books.get(isbn));
}
@Override
public List<Book> findAll() {
return List.copyOf(books.values());
}
@Override
public void save(Book entity) {
books.put(entity.isbn(), entity);
}
@Override
public void deleteById(String isbn) {
books.remove(isbn);
}
@Override
public List<Book> findByAuthor(String author) {
return findAll().stream()
.filter(book -> book.author().equals(author))
.toList();
}
}
这里没有任何反射或强制转换,Map<String, Book> 的键和值都由类型参数固定。findByAuthor 是 Book 特有的查询,放在子接口而不是 Repository 上,避免所有实体都被迫实现用不到的方法。
有界类型参数:把可比较性写进签名
泛型方法的类型参数可以带上界。最常见的需求是排序:不是所有 T 都能比较,只有实现了 Comparable 的类型才可以。把上界写进签名,错误会在编译期暴露:
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public final class LibrarySorter {
public static <T extends Comparable<? super T>> T max(List<? extends T> values) {
T best = values.getFirst();
for (T value : values) {
if (value.compareTo(best) > 0) {
best = value;
}
}
return best;
}
}
T extends Comparable<? super T> 的意思是:T 必须能与自身或自身的父类型比较。写成 T extends Comparable<T> 也能工作,但对「父类实现 Comparable、子类继承」的类型更苛刻;? super T 放宽了这个限制。List<? extends T> 则表示传入 List<Book>、List<SubBook> 都可以,方法只读取不写入。
如果排序规则由调用方提供,上界应该落在 Comparator 上,而不是要求实体实现 Comparable:
public static <T> List<T> sortedCopy(List<? extends T> values,
Comparator<? super T> order) {
List<T> copy = new ArrayList<>(values);
copy.sort(order);
return List.copyOf(copy);
}
Comparator<? super T> 允许传入 Comparator<Object>、Comparator<Book>,只要它能比较 T。libraryd 的图书列表可以按年份、标题或作者排序,不需要 Book 实现 Comparable,也不需要在实体里塞一个「默认排序」。
本机把这几段放在一起跑:
BookRepository repository = new InMemoryBookRepository();
repository.save(new Book("9780132350884", "Clean Code", "Robert C. Martin", 2008));
repository.save(new Book("9780134685991", "Effective Java", "Joshua Bloch", 2018));
List<Book> recent = sortedCopy(repository.findAll(),
Comparator.comparingInt(Book::year).reversed());
System.out.println("recent=" + recent.getFirst().title());
List<Integer> years = repository.findAll().stream().map(Book::year).toList();
System.out.println("maxYear=" + max(years));
本机输出:
recent=Effective Java
maxYear=2018
max 的实参是 List<Integer>,Integer 实现了 Comparable<Integer>,满足上界。把 Book 直接传给 max 会编译失败,因为 Book 没有实现 Comparable;这正是上界要挡住的情况。
桥方法与擦除的账单
泛型只存在于编译期。BookRepository 继承 Repository<Book, String> 之后,接口方法在字节码里被擦除成 findById(Object)、save(Object)。实现类写的是 findById(String)、save(Book),两者签名不一致,编译器会额外生成桥方法,把 Object 参数强转后转发给具体方法。
用 javap -c -p 看 InMemoryBookRepository 的真实输出(节选):
public java.util.Optional<com.plume.library.domain.Book> findById(java.lang.String);
Code:
0: aload_0
1: getfield #10 // Field books:Ljava/util/Map;
4: aload_1
5: invokeinterface #16, 2 // InterfaceMethod java/util/Map.get:(Ljava/lang/Object;)Ljava/lang/Object;
10: checkcast #22 // class com/plume/library/domain/Book
13: invokestatic #24 // Method java/util/Optional.ofNullable:(Ljava/lang/Object;)Ljava/util/Optional;
16: areturn
public java.util.Optional findById(java.lang.Object);
Code:
0: aload_0
1: aload_1
2: checkcast #72 // class java/lang/String
5: invokevirtual #82 // Method findById:(Ljava/lang/String;)Ljava/util/Optional;
8: areturn
第二个 findById(Object) 就是桥方法。它没有写在源码里,是编译器为了满足接口的擦除签名而补的。save、deleteById 也有同样的桥方法。桥方法里的 checkcast 是泛型在运行时的最后一道检查:如果通过原始类型把 Integer 塞进 BookRepository.save,强转会抛 ClassCastException,而不是悄悄写进 Map。
这也解释了为什么泛型方法不能按「参数化类型」重载:
src/lab/ErasureErrors.java:10: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure
两个 print 在字节码里都是 print(List),编译器无法区分。重载只能靠参数个数或擦除后不同的原始类型,不能靠泛型实参。
在 libraryd 里落地
libraryd-domain 只放端口和领域类型,libraryd-persistence 放实现。落地时遵守三条:
Repository<T, ID>只放四个通用方法,实体特有查询放在子接口。- 主键类型用包装类型或不可变值对象,不用基本类型。
- 需要排序、过滤的通用算法写成泛型方法,上界和通配符写在签名上,不写在注释里。
Page<T> 是另一个必须提前定下来的泛型类型:
public record Page<T>(List<T> items, long total, int page, int size) {
public Page {
items = List.copyOf(items);
}
public int totalPages() {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
}
Page<Book> 和 Page<Member> 在源码里是不同类型,调用方不能把 Page<Book> 传给需要 Page<Member> 的方法。但到了运行时,两者都是 Page,items 都是 List。1.3 会利用这个差异做类型令牌,也会说明为什么不能靠 getClass() 恢复 Book 这个实参。
最后把通用查询收敛到一个地方。BookRepository 可以继承默认方法,但默认方法只能调用 Repository 已有的方法:
public interface BookRepository extends Repository<Book, String> {
List<Book> findByAuthor(String author);
default List<Book> findAllOrEmpty() {
return findAll();
}
}
默认方法适合「所有实现都一样的薄封装」,不适合放数据库逻辑。JDBC 实现会覆盖 findAll,默认方法自然调用到覆盖后的版本;但默认方法里不能假设 T 有某个字段,因为 T 在 Repository 层面是未知的。
常见错误与边界
「用原始类型接收泛型返回值。」 写 List books = repository.findAll(); 能编译,但之后 books.add("not a book") 也能编译,直到某处读取才抛 ClassCastException。原始类型会把泛型检查整体关掉,编译器只给一条 unchecked 警告。除非在跟遗留 API 交界,否则不要写原始类型。
「把基本类型当类型实参。」 Repository<Book, int> 直接编译失败:
error: unexpected type
class Prim { Repository<Book, int> repo; }
^
required: reference
found: int
类型参数只能接受引用类型,主键用 Integer 或 Long。这也意味着 findById(null) 在包装类型下是合法的,仓储实现要明确是否接受 null,不要把「找不到」和「参数为空」混成一个 Optional.empty()。
「在静态成员里使用类的类型参数。」 Repository<T, ID> 里写 static T create() 编译失败,因为静态成员不属于某个具体实例,运行时没有 T 的信息。需要静态工厂时,把类型参数声明在方法上:static <T> T create(Class<T> type)。
「以为 T 在运行时可用。」 new T()、new T[10]、T.class 都不成立。泛型擦除后 T 只剩上界(默认 Object)。需要在运行时创建数组或实例,必须把 Class<T> 或工厂对象显式传进来。
「把桥方法当成业务方法。」 用反射遍历 getDeclaredMethods() 时会看到 findById(Object) 这样的桥方法,Method.isBridge() 返回 true。做注解扫描或审计时应该跳过桥方法,否则同一个方法会被处理两次。
小结
Repository<T, ID>用两个类型参数分别固定实体和主键,合并成Object会把主键类型检查让给运行时。- 实体特有查询放在子接口,通用方法留在
Repository,避免所有实体被迫实现用不到的方法。 - 有界类型参数把「可比较」写进签名,
<T extends Comparable<? super T>>和Comparator<? super T>是两种不同的约束方式。 - 泛型在字节码里被擦除,编译器会为具体实现生成桥方法,桥方法里的
checkcast是运行时的最后一道检查。 - 原始类型、基本类型实参、静态上下文、
new T[]是四个高频边界,遇到时先看擦除后的签名。 Page<T>在源码里保留元素类型,运行时只剩Page;需要恢复实参时,1.3 的类型令牌才有用。
泛型解决了「一个仓储适配多种实体」的问题,但查询 API 还会遇到另一个问题:方法参数到底该写 List<Book>、List<? extends Book> 还是 List<? super Book>。下一节用 PECS 把这三个选择定下来。
阅读导航:上一节:《Java 高级编程》目录 · 下一节:1.2 型变与通配符:PECS 在查询 API 里的落地 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。