本节目标:能把「存哪里」从业务代码里抽成接口,写出一个可替换的内存实现;能区分接口、default 方法与抽象类,知道每种工具该在什么场景用。
适用版本:JDK 25 LTS(Temurin 25.0.5);default 方法与接口静态方法在 JDK 21 上同样可用。
问题:业务代码不该知道数据存在哪
8.1 的 LibraryService.borrow 里有一行 repository.findByIsbn(isbn)。如果 LibraryService 直接 new 一个 ArrayList 来存书,它会同时承担三件事:借阅规则、数据存储、集合操作。后果是测试时必须准备真实数据,换存储时(比如第 14 章要写 CSV)必须改业务类。
把「存储」抽出来的办法是定义一个接口:业务代码只依赖接口,不依赖实现。接口描述「能做什么」,实现决定「怎么做」。这样 LibraryService 可以在内存实现、文件实现、测试替身之间切换,而不改一行借阅逻辑。
这一节会建立两个类型:
LibraryRepository:接口,定义图书和借阅的存取契约。InMemoryLibraryRepository:实现,用ArrayList把数据放在内存里。
第 10 章会把实现换成 Map,届时接口不变,只换实现。
接口定义契约
先写接口。方法名用业务语言,不用 insertRow、selectAll 这种数据库词:
package com.plume.library.repository;
import com.plume.library.domain.Book;
import com.plume.library.domain.Loan;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public interface LibraryRepository {
void save(Book book);
Optional<Book> findByIsbn(String isbn);
List<Book> findAll();
boolean deleteByIsbn(String isbn);
default List<Book> findByAuthor(String author) {
List<Book> matched = new ArrayList<>();
for (Book book : findAll()) {
if (book.author().equals(author)) {
matched.add(book);
}
}
return List.copyOf(matched);
}
void saveLoan(Loan loan);
Optional<Loan> findActiveLoanByIsbn(String isbn);
boolean removeLoan(String isbn);
List<Loan> findLoansByMember(String memberId);
}
几个设计点:
- 查询用
Optional表达「可能没有」,而不是返回null。调用方必须显式处理空值,忘记处理会编译失败,而不是在运行时抛NullPointerException。 - 列表返回
List.copyOf,不是内部ArrayList。调用方拿到的是只读快照,改不动仓储内部状态;这条封装规则在 7.3 已经立过。 save是 upsert 语义:ISBN 已存在就替换,不存在就插入。是否允许重复由LibraryService.addBook判断,仓储只负责存。- 接口方法默认是
public abstract,不需要写public。实现类必须写public,否则是可见性收窄,编译失败。
接口里只放行为,不放字段。需要共享状态时,用抽象类或组合,不要往接口里塞 public static 可变字段。
实现类:内存仓储
实现类的职责很窄:把对象放进集合,按条件取出来。
package com.plume.library.repository;
import com.plume.library.domain.Book;
import com.plume.library.domain.Loan;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class InMemoryLibraryRepository implements LibraryRepository {
private final List<Book> books = new ArrayList<>();
private final List<Loan> loans = new ArrayList<>();
@Override
public void save(Book book) {
for (int i = 0; i < books.size(); i++) {
if (books.get(i).isbn().equals(book.isbn())) {
books.set(i, book);
return;
}
}
books.add(book);
}
@Override
public Optional<Book> findByIsbn(String isbn) {
for (Book book : books) {
if (book.isbn().equals(isbn)) {
return Optional.of(book);
}
}
return Optional.empty();
}
@Override
public List<Book> findAll() {
return List.copyOf(books);
}
@Override
public boolean deleteByIsbn(String isbn) {
return books.removeIf(book -> book.isbn().equals(isbn));
}
@Override
public void saveLoan(Loan loan) {
loans.add(loan);
}
@Override
public Optional<Loan> findActiveLoanByIsbn(String isbn) {
for (Loan loan : loans) {
if (loan.getIsbn().equals(isbn)) {
return Optional.of(loan);
}
}
return Optional.empty();
}
@Override
public boolean removeLoan(String isbn) {
return loans.removeIf(loan -> loan.getIsbn().equals(isbn));
}
@Override
public List<Loan> findLoansByMember(String memberId) {
List<Loan> matched = new ArrayList<>();
for (Loan loan : loans) {
if (loan.getMemberId().equals(memberId)) {
matched.add(loan);
}
}
return List.copyOf(matched);
}
}
这份实现刻意用了线性扫描:findByIsbn 每次从头找,save 每次从头比对。它不追求性能,只追求语义正确,方便把测试和业务跑通。第 10 章会解释为什么 ArrayList 在数据量上来后不合适,并换成 Map;接口不变,LibraryService 也不用改。
findByAuthor 没有在实现类里出现,因为接口给了 default 实现。这是下一节要讲的机制。
default 方法:接口里的默认实现
findByAuthor 是一个很典型的「可以基于已有方法拼出来」的查询:遍历 findAll(),按作者过滤。每个实现类都写一遍很啰嗦,接口可以用 default 提供默认版本:
default List<Book> findByAuthor(String author) {
List<Book> matched = new ArrayList<>();
for (Book book : findAll()) {
if (book.author().equals(author)) {
matched.add(book);
}
}
return List.copyOf(matched);
}
实现类如果觉得线性扫描够用,什么都不用写;如果第 10 章有了作者索引,可以覆写它,把复杂度降下来。这就是 default 方法的价值:在不破坏已有实现的前提下,给接口增加一个带默认行为的方法。
default 方法有两条边界:
- 它只能调用接口里的其他方法,不能读实现类的字段。上面的代码只依赖
findAll(),所以任何实现都能用。 - 多个接口提供同名 default 方法时,实现类必须显式解决冲突。
第二个边界很容易踩。下面两个接口都提供了 label():
interface Named {
default String label() {
return "Named";
}
}
interface Versioned {
default String label() {
return "Versioned";
}
}
class Combined implements Named, Versioned {
}
本机 javac 25.0.5 报:
错误: 类型 Named 和 Versioned 不兼容;
类 Combined从类型 Named 和 Versioned 中继承了label() 的不相关默认值
解决办法是实现类覆写 label(),并用 接口名.super.方法() 选择要调用哪一个:
class Combined implements Named, Versioned {
@Override
public String label() {
return Named.super.label() + "+" + Versioned.super.label();
}
}
本机输出:
Named+Versioned
这里的 Named.super 不是父类,而是「接口的默认实现」。一个类只能有一个直接父类,但可以实现多个接口,所以需要这种限定语法来消除歧义。
default 方法不适合承载复杂业务。它没有状态、不能声明构造器、也不该调用实现类特有的方法。如果默认实现需要多个步骤、需要缓存、需要依赖注入,说明它更像一个抽象类或独立服务。
抽象类:共享状态与模板方法
接口适合定义「能做什么」,抽象类适合定义「部分已经怎么做,剩下交给子类」。library 里的罚金策略就是一个例子:不同会员的每日罚金和宽限天数不同,但「逾期天数怎么算、什么时候开始计费」是固定的。
package com.plume.library.service;
import com.plume.library.domain.Loan;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
public abstract class FinePolicy {
public final long fineCents(Loan loan, LocalDate returnedOn) {
long overdueDays = Math.max(0, ChronoUnit.DAYS.between(loan.getDueOn(), returnedOn));
long chargeableDays = Math.max(0, overdueDays - graceDays());
return chargeableDays * dailyRateCents();
}
protected abstract long dailyRateCents();
protected int graceDays() {
return 0;
}
}
fineCents 是 final 的模板方法:算法骨架固定,子类只能填两个空。普通会员没有宽限:
public final class StandardFinePolicy extends FinePolicy {
@Override
protected long dailyRateCents() {
return 50;
}
}
员工策略只需要再覆写 dailyRateCents() 和 graceDays(),把每日费率改成 25 分、宽限改成 3 天。protected abstract 的方法对子类可见、对调用方不可见,正好表达「这是扩展点,不是公开 API」;graceDays() 给了默认实现,子类不关心就可以不写。
把接口和抽象类放在一起对比:
| 维度 | 接口 | 抽象类 |
|---|---|---|
| 能放什么 | 常量、抽象方法、default/static/private 方法 | 字段、构造器、普通方法、抽象方法 |
| 继承数量 | 一个类可实现多个接口 | 一个类只能继承一个抽象类 |
| 状态 | 不能有实例字段 | 可以有实例字段和状态 |
| 适用场景 | 定义契约、跨实现解耦 | 共享部分实现、模板方法、受控扩展点 |
| 版本演进 | 加 default 方法可兼容旧实现 | 加抽象方法会破坏所有子类 |
选择顺序可以简单记成:先想接口,需要共享状态或固定算法骨架时再上抽象类。 不要因为「接口不能写字段」就退回抽象类;字段通常意味着实现细节,应该放进实现类,用组合注入。
在 library 里落地
LibraryService 的构造器接收 LibraryRepository,而不是自己创建实现:
public final class LibraryService {
private final LibraryRepository repository;
private final FinePolicy finePolicy;
public LibraryService(LibraryRepository repository, FinePolicy finePolicy) {
this.repository = Objects.requireNonNull(repository, "repository");
this.finePolicy = Objects.requireNonNull(finePolicy, "finePolicy");
}
public void addBook(Book book) {
if (repository.findByIsbn(book.isbn()).isPresent()) {
throw new IllegalStateException("ISBN 已存在: " + book.isbn());
}
repository.save(book);
}
public List<Book> booksByAuthor(String author) {
return repository.findByAuthor(author);
}
}
组装发生在 main 或测试里:
LibraryRepository repository = new InMemoryLibraryRepository();
LibraryService service = new LibraryService(repository, new StandardFinePolicy());
service.addBook(new Book("9787111213826", "Java 编程思想", "Bruce Eckel", 2007));
service.addBook(new Book("9787020024759", "红楼梦", "曹雪芹", 1791));
service.addBook(new Book("9787536692930", "三体", "刘慈欣", 2008));
System.out.println("总数: " + repository.findAll().size());
System.out.println("曹雪芹: " + service.booksByAuthor("曹雪芹").getFirst().describe());
本机 JDK 25 输出:
总数: 3
曹雪芹: 红楼梦 / 曹雪芹 (1791)
这段代码没有出现 ArrayList,LibraryService 完全不知道数据存在内存里。第 14 章把实现换成文件仓储时,只需要在 main 里换一行;第 16 章写测试时,可以注入一个返回固定数据的假仓储,不用碰真实文件。
常见错误与边界
实现接口方法时漏写 public。 接口方法隐式公开,实现类不能收窄:
void save(Book book) {
}
本机 javac 25.0.5 报:
错误: Weak中的save(Book)无法实现LibraryRepository中的save(Book)
正在尝试分配更低的访问权限; 以前为public
把可变集合直接返回。 return books; 会让调用方拿到内部 ArrayList,对方一个 clear() 就能清空仓储。统一用 List.copyOf,或者返回不可变视图。注意 List.copyOf 遇到 null 元素会抛 NullPointerException,仓储里不应该存 null。
抽象类构造器里调用抽象方法。 和 8.1 的坑一样:子类字段还没初始化。FinePolicy 的模板方法在对象构造完成后才被调用,所以安全;如果放在构造器里,就会读到默认值。
小结
- 接口定义契约,业务代码依赖接口而不是具体实现,存储才能替换、测试才能替身。
- 查询返回
Optional,列表返回List.copyOf,是仓储接口的两条基本封装规则。 - default 方法让接口在不破坏旧实现的前提下增加默认行为,但它不能读实现类字段。
- 多个 default 方法冲突时,实现类必须覆写,并用
接口名.super明确调用哪一个。 - 抽象类适合共享状态和模板方法;一个类只能继承一个抽象类,接口可以 implements 多个。
library用LibraryRepository+InMemoryLibraryRepository把存储隔离在业务之外,第 10 章只换实现、不换接口。
接口让「谁来做」可以在运行时决定。下一节把这件事拆开:当变量声明为接口、对象是具体实现时,JVM 到底怎么找到要执行的方法?
阅读导航:上一节:8.1 继承与 super:复用不是目的 · 下一节:8.3 多态与动态分派 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。