《Java 高级编程》7.3 模式匹配的边界:守卫、null 与可读性

穷尽 switch 只解决了「分支是否齐全」,没有解决 null、守卫顺序和模式嵌套过深。本节用 LoanState 演示 case null 的三种写法、when 守卫的求值规则、模式支配的编译错误,并说明 JDK 25 中 primitive patterns 仍是预览特性,不能带进生产代码。

本节目标:把模式匹配从「能编译」推进到「在 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发