本节目标:让
LoanResult和LoanState的消费方不再写「先判断类型、再强制转换、再调 accessor」的三段式,而是用一条switch完成类型分流与字段解构。
适用版本:JDK 25(Temurin 25.0.5)。record patterns 与switch模式匹配在 JDK 21 正式,未命名模式_在 JDK 22 正式。
承接的问题
libraryd 的借阅入口在领域层返回 LoanResult:
public sealed interface LoanResult
permits LoanResult.Opened, LoanResult.Rejected {
record Opened(Loan loan) implements LoanResult {
}
record Rejected(ErrorCode code, String detail) implements LoanResult {
}
}
HTTP 层要把这个结果翻译成状态码和响应体。没有模式匹配时,代码通常长这样:
// 反例:类型判断、强制转换、取值分成三步
if (result instanceof LoanResult.Opened) {
Loan loan = ((LoanResult.Opened) result).loan();
return Response.status(201).json(Map.of("id", loan.id()));
}
if (result instanceof LoanResult.Rejected) {
LoanResult.Rejected rejected = (LoanResult.Rejected) result;
ErrorCode code = rejected.code();
String detail = rejected.detail();
return Response.status(statusOf(code)).json(Map.of("code", code.name(), "detail", detail));
}
throw new IllegalStateException("unknown result: " + result);
这段代码有三个具体成本。第一,instanceof 与强制转换重复写了类型,类型改名时两处都要改。第二,loan()、code()、detail() 是三次独立调用,读者要在脑子里把它们和前面的类型判断对齐。第三,最后的 throw 是运行期兜底:将来给 LoanResult 增加第三个实现,编译器不会提醒这里漏了分支。
record pattern 把「判断是不是这个类型」和「把组件取出来」合并成一个语法单元。它不改变 LoanResult 的定义,只改变消费方的写法。
核心机制一:一条 switch 完成类型分流与解构
先看最小的可用形态。describe 接收 LoanResult,返回一行文本:
static String describe(LoanResult result) {
return switch (result) {
case LoanResult.Opened(Loan loan) ->
"opened #" + loan.id() + " due " + loan.dueOn();
case LoanResult.Rejected(var code, var detail) ->
code + ": " + detail;
};
}
三个细节值得逐个说明。
第一,case LoanResult.Opened(Loan loan) 是类型模式加 record 解构。它先判断 result 的运行时类型是不是 Opened,是的话调用 Opened.loan(),把结果绑定到 loan。整个动作在一次匹配里完成,不需要显式转换。
第二,组件顺序由 record 的规范构造器决定,不是由 accessor 的字母序决定。Rejected(ErrorCode code, String detail) 的第一个组件是 code,第二个是 detail,所以 Rejected(var code, var detail) 的绑定顺序与声明一致。组件数量必须完全匹配,少一个多一个都编译失败。
第三,var 只做类型推断,不能写 var code = ... 这种初始化形式。编译器知道第一个组件的静态类型是 ErrorCode,第二个是 String。如果写成 Rejected(String code, var detail),编译器会报类型不兼容,而不是在运行期做转换。
本机编译并运行 RecordPatternDemo 的输出:
opened #42 due 2026-11-22
missing book: isbn 9787111213826
LOAN_LIMIT_EXCEEDED: already 5 active loans
这三行分别走了 Opened 分支、带守卫的 BOOK_NOT_FOUND 分支和通用的 Rejected 分支。守卫的写法下一节展开,这里先注意:switch 没有 default,因为 LoanResult 是 sealed,编译器知道只有两个直接子类型。
核心机制二:嵌套解构与未命名模式
record pattern 可以嵌套。libraryd 的展示层有时需要同时拿图书和借阅信息,一个临时的 Shelf record 把两者放在一起:
record Shelf(Book book, Loan loan) {
}
static String shelfLine(Object value) {
if (value instanceof Shelf(Book(var isbn, var title, _, _),
Loan(var id, _, _, _, var dueOn, _, _))) {
return "loan #" + id + " of " + title + " (" + isbn + ") due " + dueOn;
}
return "not a shelf";
}
Shelf 的两个组件本身又是 record,所以模式继续往下拆。Book 有四个组件,但这里只关心 isbn 和 title;Loan 有七个组件,只关心 id 和 dueOn。_ 是未命名模式,表示「这个位置有组件,但我不绑定名字」。
未命名模式解决的是「为了凑齐组件数量,被迫起一串没用的名字」的问题。没有 _ 时,代码会写成 Book(var isbn, var title, var ignoredAuthor, var ignoredYear),四个变量里两个从不使用,IDE 会提示未使用变量,读者也要花时间确认它们真的没用。_ 明确表达「这里被解构了,但值被丢弃」,同一个模式里可以出现多次,因为不产生名字,也就没有重名问题。
本机运行结果:
loan #42 of Effective Java (978-0-13-468599-1) due 2026-11-22
not a shelf
第二行传入的是普通字符串,instanceof 返回 false,方法走兜底分支。注意 instanceof 里的模式变量 isbn、title、id、dueOn 只在 if 为 true 的分支内可见,离开 if 就不可见。这个作用域规则和普通局部变量不同,编译器用流程分析保证不会在未匹配时读取它们。
嵌套不是越深越好。两层通常还能一眼看清;三层以上,模式本身会变成一堵括号墙,读者需要数括号才能确认字段对应关系。后文的「常见错误与边界」会给出拆分建议。
核心机制三:守卫与「嵌套常量模式」的编译错误
Rejected 的第一个组件是 ErrorCode。直觉上会想把枚举常量直接写进 record 模式:
// 反例:把常量当成 record 组件模式
case LoanResult.Rejected(ErrorCode.BOOK_NOT_FOUND, var detail) ->
"missing book: " + detail;
本机 javac --release 25 的输出是:
RecordPatternDemo.java:19: 错误: 需要<标识符>
case LoanResult.Rejected(ErrorCode.BOOK_NOT_FOUND, var detail) ->
^
1 个错误
record 模式的组件位置只接受模式,不接受常量表达式。ErrorCode.BOOK_NOT_FOUND 是常量标签,只能出现在 case 的顶层,例如 case ErrorCode.BOOK_NOT_FOUND ->。要把常量判断放进 record 解构,正确写法是解构出变量再加守卫:
case LoanResult.Rejected(var code, var detail)
when code == ErrorCode.BOOK_NOT_FOUND ->
"missing book: " + detail;
case LoanResult.Rejected(var code, var detail) ->
code + ": " + detail;
when 后面是一个布尔表达式,可以使用刚刚绑定的 code 和 detail。第一个分支只在守卫为 true 时命中,第二个分支接住剩下的所有 Rejected。守卫不参与穷尽性判断,所以 sealed 类型的 switch 仍然需要一个无守卫的分支覆盖每个子类型,或者一个 default。
守卫里可以做比较、调用方法、组合条件,但不要在守卫里做有副作用的操作。守卫可能因为前面的分支已经匹配而根本不被求值,把「记录审计日志」这类动作放进去,会让日志取决于分支顺序。守卫应该是纯判断。
核心机制四:泛型 record 与擦除边界
record 可以带类型参数,但 record pattern 不能直接写参数化类型:
record Box<T>(T value) {
}
// 反例:参数化类型不能安全转换
case Box<String>(var text) -> "string:" + text;
本机编译器给出的错误是:
RecordPatternDemo.java:40: 错误: Object 无法安全地转换为 Box<String>
case Box<String>(var text) -> "string:" + text;
^
原因在第 1 章讲过:Box<String> 在运行期被擦除成 Box,模式无法验证组件是不是 String。如果允许这个写法,Box<Integer> 也会匹配 Box<String> 的模式,然后把 Integer 绑给 String 变量,类型系统就破了。
可用的写法是用通配符匹配外层 record,再对组件做一次模式判断:
static String unbox(Object value) {
return switch (value) {
case Box<?>(var inner) -> switch (inner) {
case String text -> "string:" + text;
case Integer number -> "int:" + number;
case null -> "null";
default -> "other:" + inner;
};
default -> "not a box";
};
}
Box<?> 是可具体化类型,模式只验证「这是一个 Box」,不承诺组件类型。内层 switch 对 inner 做运行期类型判断,String 和 Integer 分支是安全的。内层 switch 的 selector 是 Object,所以必须有 default;如果漏掉 case null,传入 new Box<>(null) 时内层 switch 会抛 NullPointerException。
本机输出:
string:hello
int:7
other:3.5
第三行走了 default,因为 Double 没有专门分支。这个例子说明:泛型 record 的解构能力受擦除限制,能拆的只是「外层是什么 record」,组件类型仍要靠模式或显式判断。
在 libraryd 里落地:把 LoanResult 翻译成 HTTP 视图
把上面的机制收拢成一个真实的展示层类。LoanResultPresenter 不依赖 Javalin,只负责把领域结果变成状态码、错误码和消息,Web 层再把它写成 JSON:
package com.plume.library.web;
import com.plume.library.domain.ErrorCode;
import com.plume.library.domain.Loan;
import com.plume.library.domain.LoanResult;
public final class LoanResultPresenter {
public record View(int status, String code, String message) {
}
public static View present(LoanResult result) {
return switch (result) {
case LoanResult.Opened(Loan loan) ->
new View(201, "OK", "loan " + loan.id() + " due " + loan.dueOn());
case LoanResult.Rejected(var code, var detail)
when code == ErrorCode.VALIDATION_FAILED ->
new View(400, code.name(), detail);
case LoanResult.Rejected(var code, var detail)
when code == ErrorCode.BOOK_NOT_FOUND
|| code == ErrorCode.MEMBER_NOT_FOUND
|| code == ErrorCode.LOAN_NOT_FOUND ->
new View(404, code.name(), detail);
case LoanResult.Rejected(var code, var detail)
when code == ErrorCode.DB_UNAVAILABLE
|| code == ErrorCode.INTERNAL ->
new View(503, code.name(), detail);
case LoanResult.Rejected(var code, var detail) ->
new View(409, code.name(), detail);
};
}
}
这个类比原来的 instanceof 链少了两类重复:类型只写一次,组件只解构一次。状态码映射集中在一个方法里,新增 ErrorCode 时不需要改类型判断,只需要确认它落到哪个守卫或兜底分支。最后一个无守卫的 Rejected 分支保证 sealed 穷尽性,编译器不会要求 default。
再看 LoanSummary。列表页不需要完整的 Loan,只需要借阅号、ISBN、当前状态和罚金。用 record pattern 直接把 LoanState 压平:
package com.plume.library.domain;
import java.time.LocalDate;
public record LoanSummary(long id, String isbn, long fineCents, String phase) {
public static LoanSummary of(Loan loan, LocalDate today) {
return switch (loan.state(today)) {
case LoanState.Active(var dueOn) ->
new LoanSummary(loan.id(), loan.isbn(), 0L, "ACTIVE");
case LoanState.Overdue(var dueOn, var days) ->
new LoanSummary(loan.id(), loan.isbn(),
days * Loan.FINE_CENTS_PER_DAY, "OVERDUE");
case LoanState.Returned(var returnedOn, var cents) ->
new LoanSummary(loan.id(), loan.isbn(), cents, "RETURNED");
};
}
}
LoanSummary.of 是纯函数:输入 Loan 和「今天」,输出一个扁平 record。它没有把 LoanState 继续往上层传,HTTP 层拿到的字段是固定的四个,序列化时不需要再判断类型。fineCents 的口径与 Loan.state 保持一致,避免列表页和详情页算出两个罚金。
常见错误与边界
错误一:以为 record pattern 会匹配 null。 case LoanResult.Opened(Loan loan) 对 null 不匹配,但如果 switch 的 selector 是 null 且没有 case null,整个 switch 会抛 NullPointerException,而不是走到某个分支。需要在 switch 顶部加 case null -> ...,或者在外层先做空值检查。
错误二:把守卫当成「可选优化」。 守卫为 false 时,匹配会继续尝试后面的分支;守卫本身不构成穷尽性覆盖。case Rejected(var code, var detail) when code == BOOK_NOT_FOUND 不能代替无守卫的 Rejected 分支。sealed 类型下漏掉无守卫分支,编译器会报「switch 表达式未覆盖所有可能输入值」。
错误三:在模式里绑定用不到的变量。 case Opened(Loan loan) 只用 loan.id(),却绑定了整个 loan,这没问题;但如果嵌套到 Loan(var id, var isbn, var memberId, ...) 只为了取 id,就会产生一串噪声。能用 _ 的地方用 _,能让读者一眼看出哪些字段被消费。
错误四:把复杂逻辑塞进守卫。 守卫适合做一到两个简单判断,例如 days > 30、code == BOOK_NOT_FOUND。如果守卫里出现三个 &&、两个方法调用和一次字符串拼接,读者很难判断分支的覆盖关系。把条件提取成有名字的谓词方法,守卫只调用它。
边界一:record pattern 调用的是 accessor。 编译器生成的模式匹配会调用组件的 accessor。如果 record 覆写了 accessor 并在里面做懒加载、校验或日志,模式匹配会触发这些行为。Loan、Book、LoanState 都是透明 record,没有覆写 accessor,所以解构是纯读取。
边界二:模式匹配不改变运行时形状。 record pattern 是编译期语法糖,不会生成新的类,也不会改变 LoanResult 的序列化形式。Jackson 仍然按 record 组件序列化,模式匹配只影响消费方代码。
小结
- record pattern 把类型判断、强制转换和 accessor 调用合并成一次解构,组件顺序与规范构造器一致。
- 嵌套模式可以继续拆解 record 组件,
_用来丢弃不关心的位置,避免无意义变量名。 - record 组件位置只接受模式,不接受常量;枚举常量要用
when守卫表达。 - 参数化类型不能直接写进 record pattern,擦除让
Box<String>无法安全匹配;用Box<?>加内层判断替代。 - 守卫不参与穷尽性,sealed 层次仍需要一个无守卫分支或
default。 LoanResultPresenter和LoanSummary.of是这套写法在libraryd里的两个落点:一个做 HTTP 映射,一个做列表压平。
record pattern 解决的是「一个 sealed 结果怎么被安全地拆开」。下一节把视角转到定义侧:如何设计 sealed 层次,让新增子类型时所有消费方立刻编译失败,而不是等到运行期才暴露漏分支。
阅读导航:上一节:6.3 虚拟线程的坑:pin、ThreadLocal 与池化 · 下一节:7.2 sealed 层次与穷尽 switch:把错误码变成类型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。