本节目标:把模式匹配从「能编译」推进到「在 null、边界条件和团队阅读习惯下仍然可靠」,并明确哪些写法在 JDK 25 里还不能用于生产。
适用版本:JDK 25(Temurin 25.0.5)。case null与守卫when在 JDK 21 正式;primitive patterns 是 JDK 25 的预览特性(JEP 507)。
承接的问题
7.2 的穷尽 switch 有一个前提:selector 不是 null。LoanState 本身不会为 null,但 libraryd 的边界上会出现 null:
- 旧版 DAO 在
returned_on为空时返回null,而不是LoanState.Active。 - 缓存反序列化失败时,
Optional被误用成orElse(null)。 - HTTP 请求体里的
state字段缺失,DTO 映射后是 null。
把 null 传给 7.2 的 bucket 方法,会发生什么?先看一个不带 case null 的版本:
static String unsafe(LoanState state) {
return switch (state) {
case LoanState.Active active -> "active";
case LoanState.Overdue overdue -> "overdue";
case LoanState.Returned returned -> "returned";
};
}
本机运行 NullSwitchDemo,捕获到的异常是:
java.lang.NullPointerException: null
这不是「没有匹配到分支」,而是 switch 在进入匹配前就对 selector 做了空检查。即使三个类型分支都在,null 也不会被任何一个接住。模式匹配的规则是「模式不匹配 null」,instanceof 也一样:null instanceof LoanState 返回 false,不会抛异常。
第二个问题是守卫的顺序。when 后面的条件只在模式匹配成功后求值,多个分支可能同时满足类型条件,靠守卫和顺序区分。顺序写反,后面的分支永远不可达,编译器会报「被前面的 case 支配」。
第三个问题是可读性。模式匹配让解构变短,但嵌套三层以后,括号和变量名会挤在一行里。编译器不关心可读性,代码评审要关心。
核心机制一:case null 的三种写法
最直接的写法是把 null 当成一个独立分支:
static String safe(LoanState state) {
return switch (state) {
case null -> "none";
case LoanState.Active active -> "active";
case LoanState.Overdue overdue -> "overdue";
case LoanState.Returned returned -> "returned";
};
}
case null 不需要类型,也不需要模式变量。它必须出现在某个类型模式之前吗?不必须,但类型模式不匹配 null,所以放在哪里都不会被「抢走」。真正会抢走 null 的是无条件的 default 或总模式(比如 case Object o),如果它们排在 case null 前面,case null 会被支配。
第二种写法是把 null 和 default 合并:
static String nullOrDefault(Object value) {
return switch (value) {
case String text -> "string:" + text;
case null, default -> "other";
};
}
case null, default 表示「null 和其他所有未匹配的值走同一分支」。它适合「null 没有特殊语义,和未知值一样处理」的场景。注意这个写法里 default 仍然存在,所以 switch 不再要求穷尽所有类型;如果 selector 是 sealed 类型,这等于主动放弃穷尽性检查。
第三种写法是在 switch 外面先归一化:
static LoanState normalize(LoanState state) {
return state == null ? new LoanState.Active(LocalDate.now()) : state;
}
这种写法把 null 转成一个合法的领域状态。它适合「null 在业务上等价于某个默认值」的场景,不适合「null 表示数据缺失,必须报错」的场景。选择哪种,取决于 null 的语义,而不是语法偏好。
本机运行结果:
instanceof null = false
safe(null) = none
nullOrDefault(null) = other
nullOrDefault(42) = other
nullOrDefault(42) 也走 other,因为 42 是 Integer,不匹配 String,落到 null, default。这个分支的名字容易让人以为只处理 null,实际它处理的是「null 加所有其他值」。
核心机制二:守卫 when 的求值规则与顺序
守卫写在模式后面:
case LoanState.Overdue(var dueOn, var days)
when days > 30 && dueOn.getMonth() == Month.DECEMBER ->
"long-overdue-year-end:" + days;
求值顺序是:先匹配类型和组件,再求值 when 表达式。如果类型不匹配,守卫根本不会执行;如果守卫抛异常,异常会从 switch 传播出去,不会尝试下一个分支。守卫里可以引用刚刚绑定的 dueOn 和 days,作用域覆盖守卫本身和整个分支体。
守卫不是「过滤器」,它不改变匹配结果,只决定这个分支是否被选中。多个守卫可以叠加:
static String label(LoanState state) {
return switch (state) {
case null -> "no-state";
case LoanState.Active(var dueOn) when dueOn.equals(LocalDate.of(2026, 11, 10)) ->
"due-today";
case LoanState.Active(var dueOn) -> "active:" + dueOn;
case LoanState.Overdue(var dueOn, var days)
when days > 30 && dueOn.getMonth() == Month.DECEMBER ->
"long-overdue-year-end:" + days;
case LoanState.Overdue(var dueOn, var days) -> "overdue:" + days;
case LoanState.Returned(var returnedOn, var cents) ->
"returned:" + returnedOn + ":" + cents;
};
}
本机 PatternLimitsDemo 的输出:
due-today
active:2026-11-11
long-overdue-year-end:40
overdue:2
returned:2026-11-10:150
no-state
第一行 Active(today) 命中 due-today;第二行 Active(tomorrow) 不满足守卫,落到下一个 Active 分支;第三行 Overdue(2026-12-01, 40) 同时满足 days > 30 和十二月,命中长逾期分支;第四行 Overdue(today-2, 2) 不满足守卫,落到普通逾期。顺序在这里是语义的一部分:如果把无守卫的 Overdue 放在前面,后面的长逾期分支永远不可达。
守卫里的布尔表达式可以用 &&、||、!,但要注意优先级。days > 30 && dueOn.getMonth() == DECEMBER || cents > 0 会按 (days > 30 && month == DECEMBER) || cents > 0 求值。复杂条件建议加括号,或者提取成有名字的谓词:
private static boolean isYearEndLongOverdue(LoanState.Overdue overdue) {
return overdue.days() > 30
&& overdue.dueOn().getMonth() == Month.DECEMBER;
}
守卫调用这个方法,分支本身只保留 when isYearEndLongOverdue(overdue)。方法名承担了注释的职责,测试也可以直接覆盖谓词,不需要构造整个 switch。
守卫的另一条规则是「不要有副作用」。守卫可能因为前面的分支已经匹配而根本不执行,也可能在一个请求里被执行多次(如果 switch 放在循环里)。在守卫里写日志、改计数器、发消息,会让副作用取决于分支顺序和输入分布。守卫应该是纯判断。
核心机制三:模式支配与不可达分支
编译器会检查 case 的支配关系。一个 case 如果被前面的 case 完全覆盖,就是「被支配」,编译失败。最简单的例子是把父类型模式放在子类型模式前面:
// 反例:后面的 Active 分支永远不可达
static String dominated(Object value) {
return switch (value) {
case LoanState state -> "any";
case LoanState.Active active -> "active";
default -> "other";
};
}
本机 javac --release 25 的输出:
Dominance.java:6: error: this case label is dominated by a preceding case label
case LoanState.Active active -> "active";
^
1 error
case LoanState state 匹配所有 LoanState,包括 Active,所以后面的 Active 分支不可达。编译器直接拒绝,而不是让它在运行期永远不执行。
支配检查也适用于守卫吗?不完全是。带守卫的 case 不被视为完全覆盖,因为守卫可能为 false。所以下面这段是合法的:
case LoanState.Active active when active.dueOn().isBefore(today) -> "expired";
case LoanState.Active active -> "active";
第一个分支有守卫,编译器知道它可能不匹配,第二个分支仍然可达。反过来,如果第一个分支没有守卫,第二个就会被支配。
支配错误在重构时很常见:先把一个宽泛的 case Object o 放在最前面做日志,再在后面写具体类型分支,结果所有具体分支都不可达。解决办法是把宽泛分支放到最后,或者把它拆成一个前置的 if,不要占用 switch 的 case 位置。
核心机制四:可读性的三条边界
模式匹配的代码密度很高,一行里可能同时有类型判断、组件解构、守卫和返回值。密度本身不是问题,问题是读者需要在工作记忆里同时保持多少个绑定。三条实用边界:
第一,嵌套不超过两层。两层模式还能按「外层 record → 内层 record」的顺序读;三层以上,括号开始嵌套,读者要数括号才能确认字段属于谁。下面这个模式能编译,但很难评审:
record LoanView(Loan loan, LoanState state) {
}
record DetailedShelf(Book book, LoanView view) {
}
// 反例:三层嵌套,字段归属需要数括号
if (value instanceof DetailedShelf(Book(var isbn, var title, _, _),
LoanView(Loan(var id, _, _, _, _, _, _),
LoanState.Overdue(var dueOn, var days)))) {
// ...
}
更好的做法是先把中间层提取成方法:
static boolean isOverdueShelf(Object value) {
if (!(value instanceof Shelf(Book book, Loan loan))) {
return false;
}
return loan.state(LocalDate.now()) instanceof LoanState.Overdue;
}
第二,一个模式里绑定的变量不超过四个。超过四个,读者很难记住每个变量的含义。用 _ 丢掉不关心的位置,只绑定后续真正使用的字段。Shelf(Book(var isbn, var title, _, _), Loan(var id, _, _, _, var dueOn, _, _)) 绑定了四个变量,已经是上限;如果还要 memberId,考虑拆成两次判断。
第三,守卫里的条件不超过两个。when days > 30 && dueOn.getMonth() == DECEMBER 是两个条件,读者能一眼看出「十二月且逾期超过三十天」。再加一个 && !returned,就需要停下来想「returned 不是已经由类型排除了吗」。守卫应该表达一个可命名的业务规则,而不是堆砌条件。
还有一个常被忽略的边界:switch 的模式匹配适合「按类型分流」,不适合「按值分流」。如果所有分支都是同一个类型、只是字段值不同,用 if 或查表更清楚。例如按 ErrorCode 映射状态码,switch (code) 的枚举常量分支比模式匹配更直接;模式匹配的收益在「不同类型携带不同字段」时才明显。
核心机制五:primitive patterns 在 JDK 25 仍是预览
JDK 25 的 JEP 507 继续预览「primitive types in patterns, instanceof, and switch」。它允许这样写:
// 预览特性:JDK 25 需要 --enable-preview
static String classify(Object value) {
return switch (value) {
case int i when i < 0 -> "negative";
case int i -> "int " + i;
case double d -> "double " + d;
default -> "other";
};
}
这段代码在 JDK 25 上不加 --enable-preview 时,本机编译输出是:
PrimitivePreview.java:4: error: primitive patterns are a preview feature and are disabled by default.
case int i when i < 0 -> "negative";
^
(use --enable-preview to enable primitive patterns)
1 error
加上 --release 25 --enable-preview 后可以编译运行,本机输出 negative、int 7、double 2.5。但这不意味着可以在生产代码里使用:
- 预览特性可能在下一个 JDK 版本改变语法或语义,甚至在正式化前被移除。
- 启用预览后编译出的 class 文件带有预览标记,只能由相同 JDK 版本的 JVM 运行,升级 JDK 必须重新编译。
libraryd的构建统一使用--release 25,没有加--enable-preview。引入预览特性会迫使整个构建链和运行环境一起加开关。
因此,本节的所有 libraryd 代码都不使用 primitive patterns。需要处理 int、long 时,用装箱类型模式加显式转换,或者用普通 if:
if (value instanceof Integer i) {
return i < 0 ? "negative" : "int " + i;
}
装箱模式在 JDK 25 是正式特性,没有预览风险。代价是 boxing 一次,但在 libraryd 的请求路径上,这点分配远小于一次数据库往返。等 JEP 507 正式化后,再把热点的装箱判断替换成 primitive pattern,才是合理的节奏。
在 libraryd 里落地:逾期策略与状态归一化
OverduePolicy 用守卫把逾期分成三档,返回不同的提醒文案。它是纯函数,输入 LoanState,不读时钟:
package com.plume.library.domain;
public final class OverduePolicy {
private OverduePolicy() {
}
public static String reminder(LoanState state) {
return switch (state) {
case null -> "状态缺失,请人工核对";
case LoanState.Active active -> "请于 " + active.dueOn() + " 前归还";
case LoanState.Overdue overdue when overdue.days() >= 30 ->
"已逾期 " + overdue.days() + " 天,请尽快到馆处理";
case LoanState.Overdue overdue when overdue.days() >= 7 ->
"已逾期 " + overdue.days() + " 天,将产生罚金";
case LoanState.Overdue overdue ->
"已逾期 " + overdue.days() + " 天";
case LoanState.Returned returned ->
"已归还,罚金 " + returned.fineCents() + " 分";
};
}
}
这个 switch 有三个 Overdue 分支,靠守卫从严格到宽松排列。顺序不能乱:days >= 30 必须放在 days >= 7 前面,否则 30 天以上的逾期会命中 7 天档。最后一个无守卫的 Overdue 接住 1 到 6 天,保证穷尽性。
第二个落点是边界归一化。仓储层从数据库读出的状态可能是 null,LoanStateMapper 把它转成显式的 LoanState 或抛出领域异常:
package com.plume.library.persistence;
import com.plume.library.domain.LoanState;
import com.plume.library.domain.LibraryException;
import com.plume.library.domain.ErrorCode;
import java.time.LocalDate;
public final class LoanStateMapper {
private LoanStateMapper() {}
public static LoanState fromRow(String rawState, LocalDate dueOn, long days) {
if (rawState == null) {
throw new LibraryException(ErrorCode.INTERNAL,
"loan state column is null");
}
return switch (rawState) {
case "ACTIVE" -> new LoanState.Active(dueOn);
case "OVERDUE" -> new LoanState.Overdue(dueOn, days);
case "RETURNED" -> new LoanState.Returned(dueOn, days * 50);
default -> throw new LibraryException(ErrorCode.INTERNAL,
"unknown loan state: " + rawState);
};
}
}
这个 switch 的 selector 是 String,不是 sealed 类型,所以必须有 default。default 在这里不是偷懒,而是「数据库里出现了代码不认识的枚举值」这一真实故障的显式处理:抛 LibraryException,让上层返回 500 并记录原始值,而不是静默把它当成 ACTIVE。
OverduePolicy 和 LoanStateMapper 合起来说明了两种边界:领域内部的 sealed 状态用穷尽 switch,外部输入(数据库字符串、HTTP 字段)用带 default 的 switch 加显式失败。不要把外部输入直接转成 sealed 类型再假设它一定合法。
常见错误与边界
错误一:以为 case null 可以省略。 只要 selector 可能是 null,就必须有 case null 或在外层归一化。省略不会得到「不匹配任何分支」的安静结果,而是 NullPointerException。本机输出只有一行 java.lang.NullPointerException: null,堆栈指向 switch 那一行,排查时容易误以为是某个分支里的代码抛的。
错误二:把守卫写成「兜底」。 case LoanState.Overdue overdue when overdue.days() > 0 不是兜底,days == 0 的逾期不会命中。守卫为 false 时,匹配继续往后走;如果后面没有无守卫的 Overdue 分支,编译器的穷尽性检查会发现漏了情况。不要用守卫表达「其他情况」,用无守卫分支或 default。
错误三:把 instanceof 链和 switch 混着写。 同一个方法里先用 if (state instanceof Active),再用 switch (state),两套分流逻辑会让读者很难确认覆盖关系。选一种:需要穷尽性就用 switch,需要提前返回就用 if,不要在同一个值上反复切换风格。
错误四:在 JDK 25 生产代码里使用 primitive patterns。 不加 --enable-preview 编译失败;加了以后 class 文件绑定 JDK 版本,升级 JDK 必须全量重编译。预览特性适合实验和写文章,不适合进入 libraryd 的 --release 25 构建。
边界一:守卫的异常会传播。 when 里的方法如果抛 NullPointerException,异常会从整个 switch 抛出,不会尝试下一个分支。守卫里调用可能返回 null 的方法时,先做空值判断,或者把守卫提取成返回 boolean 的谓词并在谓词内部处理异常。
边界二:模式匹配不改变 equals 语义。 case LoanState.Active active 匹配的是运行时类型,不是 equals。两个 Active 即使 dueOn 不同,也都会匹配同一个分支。需要按值区分时,在分支内部比较字段,或者用守卫表达值条件。
小结
switch的模式分支不匹配 null;selector 可能为 null 时必须写case null,否则抛NullPointerException。case null, default把 null 和未知值合并处理,但会放弃 sealed 的穷尽性检查。- 守卫
when在类型匹配后求值,不参与穷尽性;分支顺序是语义的一部分,宽泛的无守卫分支必须放在最后。 - 被前面 case 完全覆盖的分支会触发「case label is dominated」编译错误,重构时先检查宽泛模式的位置。
- 模式嵌套控制在两层、绑定变量不超过四个、守卫条件不超过两个,是可读性的实用上限。
- primitive patterns 在 JDK 25 仍是预览(JEP 507),不加
--enable-preview无法编译;libraryd不在生产代码中使用。 - 领域内部的 sealed 状态用穷尽
switch,数据库、HTTP 等外部输入用带default的switch加显式失败。
类型系统这一章到这里结束。下一章离开语言表面,进入平台层:先把 libraryd 做成真正的 JPMS 模块,用 jdeps 分析依赖、用 jlink 裁运行时,再通过 FFM 调用本机的 ISBN 校验库。
阅读导航:上一节:7.2 sealed 层次与穷尽 switch:把错误码变成类型 · 下一节:8.1 JPMS 模块化:module-info、requires 与 jlink 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。