本节目标:能用
sealed接口把一组命令封闭成有限类型,能用switch模式匹配解构记录、加守卫并保证穷尽,能说清permits、模式顺序和null分支的规则。
适用版本:JDK 25 LTS(Temurin 25.0.5);sealed 类型与 switch 模式匹配在 JDK 21 上已是正式特性。
字符串命令的分发问题
第 5 章给 library 写了 CLI 分发骨架,命令是字符串:add、list、borrow、return、search、quit。LibraryApp 里大概是这样:
switch (args[0]) {
case "add" -> handleAdd(args);
case "list" -> handleList();
case "borrow" -> handleBorrow(args);
case "return" -> handleReturn(args);
case "search" -> handleSearch(args);
case "quit" -> running = false;
default -> System.out.println("未知命令: " + args[0]);
}
这段代码能工作,但命令的参数散落在各个 handleXxx(String[]) 方法里:add 要 4 个参数,borrow 要 2 个,return 要 1 个 ISBN 加 1 个日期。参数个数、类型、顺序都靠约定,解析和执行业务混在一起。更麻烦的是,新增命令时编译器不会提醒你漏了哪个分支,default 会把错误吞掉。
更好的做法是先把命令行解析成命令对象,再对命令对象做分发。命令的种类是有限的,正好可以用 sealed 接口封闭;每种命令携带的参数不同,正好可以用 record 表达;分发时用 switch 模式匹配,一次完成类型判断和字段解构。
sealed 接口:把子类型写进契约
sealed 接口用 permits 明确列出允许的实现者:
package com.plume.library.cli;
import com.plume.library.domain.Book;
import java.time.LocalDate;
public sealed interface Command
permits Command.AddBook, Command.ListBooks, Command.BorrowBook,
Command.ReturnBook, Command.SearchBooks, Command.Quit {
record AddBook(Book book) implements Command {
}
record ListBooks() implements Command {
}
record BorrowBook(String isbn, String memberId) implements Command {
}
record ReturnBook(String isbn, LocalDate returnedOn) implements Command {
}
record SearchBooks(String keyword) implements Command {
}
record Quit() implements Command {
}
}
这组类型把「命令只有六种」写进了语言层面:
Command是 sealed 的,除了permits列出的六个记录,没有别的类能实现它。- 六个记录都是
final的(record 隐式 final),所以子类型层次到此为止。 - 每个记录携带自己的参数,
AddBook有Book,ReturnBook有 ISBN 和归还日期,不需要再看String[]的下标。 permits里的名字可以和接口写在同一个文件里,用Command.AddBook这种限定名更清楚。
sealed 的规则不止「列个清单」:
- 允许的子类型必须在同一个模块里;没有模块时,必须在同一个包里。跨包、跨模块的实现会编译失败。
- 每个允许的子类型必须是
final、sealed或non-sealed之一。record 默认final,普通类要显式声明。 permits可以省略,编译器会从同一个文件里的子类型推断;但显式写出来更容易读。
如果绕过 permits 直接实现:
public final class FakeCommand implements Command {
}
本机 javac 25.0.5 报:
错误: 类不得扩展密封类:Command(因为它未列在其 'permits' 子句中)
public final class FakeCommand implements Command {
^
注意 sealed 只封闭「谁能实现」,不保证实现者不可变。记录子类型是 final 的,但如果某个允许的子类型是普通类,它仍然可以有 setter。要不可变,还得自己把字段设成 final。
用 switch 模式匹配分发
有了命令类型,分发就可以写成对 Command 的 switch。每个分支用记录模式(record pattern)解构字段:
public String execute(Command command) {
return switch (command) {
case Command.AddBook(var book) -> {
service.addBook(book);
yield "已添加: " + book.title();
}
case Command.ListBooks() -> format(service.listBooks());
case Command.BorrowBook(var isbn, var memberId) -> {
Member member = members.get(memberId);
if (member == null) {
yield "未知会员: " + memberId;
}
yield "已借出: " + service.borrow(isbn, member, today).dueOn();
}
case Command.ReturnBook(var isbn, var returnedOn) -> {
LibraryService.ReturnReceipt receipt = service.returnBook(isbn, returnedOn);
yield "已归还: " + receipt.loan().isbn() + ",罚金(分): " + receipt.fineCents();
}
case Command.SearchBooks(var keyword) when keyword.isBlank() -> "搜索关键词不能为空";
case Command.SearchBooks(var keyword) -> format(service.search(keyword));
case Command.Quit() -> "bye";
};
}
几个语法点:
case Command.AddBook(var book)是记录模式,var book绑定到AddBook.book()的返回值,不需要先instanceof再强转。Command.ListBooks()是零组件的记录模式,匹配「是ListBooks且没有字段」;也可以写成类型模式case Command.ListBooks ignored。when是守卫,在模式匹配成功后继续判断条件。带守卫的分支必须放在不带守卫的同类型分支之前,否则后面的分支永远到不了。switch表达式要求穷尽。Command是 sealed,编译器知道只有六种实现,少写一个就报错。
本机跑一遍完整的命令序列:
$ add 9787536692930 三体 刘慈欣 2008
已添加: 三体
$ add 9787020024759 红楼梦 曹雪芹 1791
已添加: 红楼梦
$ list
9787536692930 三体
9787020024759 红楼梦
$ borrow 9787536692930 M-001
已借出: 2026-10-26
$ search 三体
9787536692930 三体
$ return 9787536692930 2026-11-15
已归还: 9787536692930,罚金(分): 1000
$ quit
bye
每个分支只处理自己关心的字段,不再需要检查 args.length,也不需要在 handleBorrow 里再解析一次 ISBN 和会员 ID。解析的职责被推到了 CommandParser,执行的职责留在 CommandExecutor,两边可以分别测试。
穷尽性:漏一个都不行
sealed + switch 最大的价值是穷尽检查。假设新加了 Command.RenewBook,但分发代码没更新,switch 会直接编译失败:
示意输出(真实错误文案,变量名按正文写作 command):
错误: switch 表达式不包含所有可能的输入值
return switch (command) {
^
这正是我们想要的效果:新增命令时,编译器会把所有需要更新的分发点列出来。 对比字符串 switch 的 default 分支,新命令会被悄悄当成「未知命令」,直到用户报告才发现。
穷尽检查依赖 sealed。如果 Command 不是 sealed,编译器不知道还有没有别的实现,switch 就必须有 default,穷尽性也就没了。sealed 是把「类型集合封闭」这件事告诉编译器,模式匹配才能做静态检查。
模式顺序也会影响编译。下面这样写:
return switch (command) {
case Command ignored -> "any";
case Command.Quit() -> "quit";
};
本机 javac 25.0.5 报:
错误: 此 case 标签由前一个 case 标签支配
case Command.Quit() -> "quit";
^
Command 是总模式,匹配所有命令,后面的 Quit 永远到不了。规则很简单:具体的模式在前,宽泛的模式在后;带守卫的模式在前,不带守卫的同类型模式在后。
null 是另一个边界。对 Command 做 switch 时,如果 command 是 null,没有 case null 就会抛 NullPointerException。需要显式处理时:
return switch (command) {
case null -> "缺少命令";
case Command.Quit() -> "bye";
default -> "other";
};
本机 JDK 25 输出:
缺少命令
quit
case null 必须放在最前面(或至少在其他模式之前),否则也会被支配检查拦住。实际项目里,CommandParser 应该在入口就拒绝 null,分发层再把 case null 当作防御性分支。
在 library 里落地
解析器负责把 String[] 变成 Command,所有参数校验集中在这里:
public Command parse(String[] args) {
if (args.length == 0) {
throw new IllegalArgumentException("缺少命令");
}
return switch (args[0]) {
case "add" -> new Command.AddBook(parseBook(args));
case "list" -> new Command.ListBooks();
case "borrow" -> new Command.BorrowBook(required(args, 1), required(args, 2));
case "return" -> new Command.ReturnBook(required(args, 1), LocalDate.parse(required(args, 2)));
case "search" -> new Command.SearchBooks(required(args, 1));
case "quit" -> new Command.Quit();
default -> throw new IllegalArgumentException("未知命令: " + args[0]);
};
}
private static Book parseBook(String[] args) {
if (args.length != 5) {
throw new IllegalArgumentException("add 需要 4 个参数: isbn title author year");
}
return new Book(args[1], args[2], args[3], Integer.parseInt(args[4]));
}
private static String required(String[] args, int index) {
if (index >= args.length) {
throw new IllegalArgumentException("缺少第 " + index + " 个参数");
}
return args[index];
}
解析失败抛 IllegalArgumentException,由 LibraryApp 的入口统一捕获并打印用法。这里没有引入自定义异常,第 13 章会把它们统一成 LibraryException 体系。
CommandExecutor 持有一个 Map<String, Member>,按会员 ID 找到借阅人;找不到就返回一条用户能读的消息,而不是抛异常。这样 CLI 不会因为一个拼错的会员 ID 退出,用户可以继续输入下一条命令。
本机验证的两个错误路径:
未知命令: unknown
搜索关键词不能为空
第一条来自解析器的 default 分支,第二条来自 SearchBooks 的守卫分支。两条都在进入业务方法之前被拦住,仓储和借阅状态没有被污染。
常见错误与边界
以为 sealed 等于不可变。 sealed 只限制「谁能实现」,不限制实现者内部状态。记录子类型是 final 的,但如果允许一个普通类实现,它仍然可以可变。要不可变,字段用 final,集合做防御性拷贝。
把 permits 的子类型写到别的包。 没有模块时,sealed 接口和所有允许的子类型必须在同一个包里;有模块时必须在同一个模块里。跨包会报「类不得扩展密封类」。
在 switch 里先写宽泛模式。 总模式、Object 模式、Command 模式都要放在具体模式之后,否则后面的分支被支配,编译失败。
新增子类型后忘了更新分发。 这是好事:编译器会报「switch 表达式不包含所有可能的输入值」。不要为了让它编译过而随手加 default,那会把穷尽检查关掉;应该补上对应分支。
把模式匹配当成类型转换的替代品。 模式匹配适合「有限类型集合 + 每类处理不同」的场景。如果类型是开放的、第三方可以扩展,sealed 不适用,还是应该用接口 + 多态或访问者模式。
小结
sealed接口用permits封闭子类型集合,允许的实现者必须是final、sealed或non-sealed。- 记录子类型天然适合表达命令:每种命令一个 record,参数字段即类型。
switch记录模式一次完成类型判断和字段解构,when守卫处理附加条件。- sealed + switch 提供穷尽检查:新增命令时,漏掉的分支会编译失败。
- 模式顺序必须具体在前、宽泛在后;
null需要显式的case null,否则抛NullPointerException。 CommandParser负责把String[]变成命令对象,CommandExecutor负责分发,解析和执行可以分开测试。
第 9 章把领域模型和命令都换成了更短、更封闭的类型。第 10 章回到数据:当图书从几条变成几万条,ArrayList 的线性扫描还撑得住吗?List、Set、Map 各自该在什么场景用?
阅读导航:上一节:9.2 enum:把常量写成类型 · 下一节:10.1 List、Set、Map:三种容器的选择 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。