Java 基础体系 · 第 42/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 25 sealed 类型与模式匹配:封闭层次、switch 穷尽和建模

sealed 类型和模式匹配解决的是同一个建模问题的两面:

  • sealed 描述“一个抽象类型允许有哪些直接变体”;
  • 模式匹配描述“面对这些变体时,如何安全地分派逻辑”;
  • 穷尽性检查则验证“代码是否覆盖了模型允许的所有情况”。

三者结合后,可以把一组固定的领域变体表达为编译器可检查的代数数据类型风格模型,而不是依赖注释、约定或运行时类型判断。

本文以 Java 25 LTS 的标准语言特性为范围,使用引用类型模式、record pattern 和模式匹配 switch。Java 25 中某些其他模式相关能力仍可能属于预览特性,本文不把它们作为标准语法使用。


一、先建立基础:类型、子类型和模式

1. 类型模式不仅判断类型,还绑定变量

传统写法通常把类型判断和强制转换分成两步:

if (value instanceof String) {
    String text = (String) value;
    System.out.println(text.length());
}

类型模式把这两步合并:

if (value instanceof String text) {
    System.out.println(text.length());
}

String text 是一个类型模式

  1. 判断运行时对象是否属于 String
  2. 如果判断成功,在模式变量的作用域内把对象视为 String
  3. 绑定变量 text

模式变量不是无条件存在的。例如下面的 text 不在 if 后可用:

if (!(value instanceof String text)) {
    return;
}

System.out.println(text.length());

这里之所以成立,是因为如果模式匹配失败,return 已经结束了后续路径。编译器会进行这种流敏感分析。

模式匹配 switch 使用相同的核心机制,只是把一系列模式组织成多个分支:

static String describe(Object value) {
    return switch (value) {
        case String text -> "字符串,长度为 " + text.length();
        case Integer number -> "整数:" + number;
        default -> "其他类型";
    };
}

case String text 先判断类型,再绑定 text;箭头右侧表达式只在该模式匹配成功时执行。


二、sealed 类型:显式声明封闭的直接子类型

1. sealed 的语义

sealed 类或接口限制其直接子类型。例如:

sealed interface PaymentResult
        permits PaymentSucceeded, PaymentRejected {
}

这段声明表达的是:

PaymentResult 的直接实现类型只能是 PaymentSucceededPaymentRejected

一个被许可的直接子类型必须明确选择自己的继承策略:

  • final:继承层次在这里结束;
  • sealed:继续封闭,并声明自己的直接子类型;
  • non-sealed:重新开放继承,后续可以出现未列出的子类型。

完整示例:

sealed interface PaymentResult
        permits PaymentSucceeded, PaymentRejected, PaymentPending {
}

record PaymentSucceeded(String transactionId)
        implements PaymentResult {
}

final class PaymentRejected implements PaymentResult {
    private final String reason;

    PaymentRejected(String reason) {
        this.reason = reason;
    }

    String reason() {
        return reason;
    }
}

sealed class PaymentPending
        implements PaymentResult
        permits AwaitingBank, AwaitingRiskCheck {
}

final class AwaitingBank extends PaymentPending {
}

final class AwaitingRiskCheck extends PaymentPending {
}

这里的封闭层次是:

PaymentResult
├── PaymentSucceeded
├── PaymentRejected
└── PaymentPending
    ├── AwaitingBank
    └── AwaitingRiskCheck

PaymentResult 只允许三个直接子类型;PaymentPending 又只允许两个直接子类型。PaymentSucceeded 是 record,record 隐式为 finalPaymentRejectedAwaitingBankAwaitingRiskCheck 显式为 final

2. “直接子类型”与“所有后代”不是一回事

permits 列出的是直接子类或直接实现类,而不是整个后代集合:

sealed interface Command
        permits UserCommand, SystemCommand {
}

sealed interface UserCommand
        extends Command
        permits CreateUser, DeleteUser {
}

record CreateUser(String name) implements UserCommand {
}

record DeleteUser(long id) implements UserCommand {
}

final class SystemCommand implements Command {
}

Command 的直接子类型只有 UserCommandSystemCommandCreateUser 并不是 Command 的直接子类型,但它是该封闭层次中的最终变体之一。

要判断整个层次是否穷尽,编译器需要递归展开:

  1. 如果直接子类型是 final,该分支结束;
  2. 如果直接子类型是 sealed,继续展开其 permits
  3. 如果直接子类型是 non-sealed,该分支不能再被静态枚举。

3. 被许可的直接子类型必须满足结构约束

permits 列出的类型必须确实直接扩展或实现封闭类型:

sealed interface Result
        permits Success, Failure {
}

final class Success implements Result {
}

final class Failure implements Result {
}

下面的声明非法,因为 Other 并没有实现 Result

sealed interface Result
        permits Success, Other { // 编译错误
}

final class Success implements Result {
}

final class Other {
}

直接子类型还必须声明 finalsealednon-sealed。例如:

sealed interface Result permits Success {
}

// 编译错误:Success 必须声明 final、sealed 或 non-sealed
class Success implements Result {
}

正确写法:

final class Success implements Result {
}

这些约束保证了封闭层次不是一个只写在父类型上的名单,而是一个由编译器验证过的真实继承结构。

4. permits 的省略规则

如果封闭类型和其所有直接子类型在同一个编译单元中声明,可以省略 permits,由编译器根据同一编译单元中的直接子类型推断:

sealed interface Shape {
}

final class Circle implements Shape {
}

final class Square implements Shape {
}

在多文件项目中,通常显式写出 permits 更容易阅读,也更能表达公共模型的边界。无论是否省略,编译器最终都必须能确定直接子类型集合。

5. 包和模块的边界

为了防止封闭类型的作者无法控制子类型,封闭类型与其直接子类型必须满足语言规定的包或模块约束:

  • 位于命名模块中的类型通常必须位于同一个模块;
  • 位于未命名模块中的类型必须位于同一个包。

这不是访问控制的替代品。sealed 解决的是“谁能成为子类型”,而 public、包可见性和模块导出解决的是“谁能访问类型”。

6. non-sealed 会截断静态穷尽

sealed interface Message
        permits TextMessage, ExternalMessage {
}

record TextMessage(String text) implements Message {
}

non-sealed class ExternalMessage implements Message {
}

编译器知道 MessageTextMessageExternalMessage 两个直接子类型,但不知道 ExternalMessage 未来会有哪些后代。因此,下面的 switch 不能只依赖封闭层次自动穷尽:

static String describe(Message message) {
    return switch (message) {
        case TextMessage text -> text.text();
        // 不能仅凭 sealed 层次覆盖所有 ExternalMessage 后代
    };
}

必须补充能够覆盖开放分支的模式,例如:

static String describe(Message message) {
    return switch (message) {
        case TextMessage text -> "文本:" + text.text();
        case ExternalMessage external -> "外部消息";
    };
}

case ExternalMessage 会匹配 ExternalMessage 本身及其所有后代,因此覆盖了这个重新开放的分支。


三、模式匹配 switch:从值分派到结构分解

1. 常量标签与类型模式标签

传统 switch 主要根据常量匹配:

static String nameOf(int code) {
    return switch (code) {
        case 200 -> "OK";
        case 404 -> "Not Found";
        default -> "Other";
    };
}

模式匹配 switch 可以根据类型分派:

static String describe(Object value) {
    return switch (value) {
        case Integer number -> "整数:" + number;
        case String text -> "字符串:" + text;
        default -> "其他对象";
    };
}

每个 case 标签可以理解为一个匹配规则:

  • case Integer number:运行时值是 Integer 时成功,并绑定 number
  • case String text:运行时值是 String 时成功,并绑定 text
  • default:前面的规则都失败时成功。

2. switch 表达式与 switch 语句

switch 表达式必须产生一个值:

String result = switch (value) {
    case String text -> text.toUpperCase();
    default -> "UNKNOWN";
};

箭头规则的右侧可以是表达式,也可以是代码块:

String result = switch (value) {
    case String text -> {
        String normalized = text.trim();
        yield normalized.toUpperCase();
    }
    default -> "UNKNOWN";
};

代码块中使用 yield 返回该分支的值。传统冒号语法仍然存在,但更容易出现贯穿执行和变量作用域问题;模式匹配场景通常使用箭头规则更清晰。

switch 语句可以不产生值:

static void print(Object value) {
    switch (value) {
        case String text -> System.out.println(text);
        case Integer number -> System.out.println(number);
        default -> System.out.println("其他");
    }
}

对于需要穷尽检查的建模逻辑,switch 表达式通常更有约束力,因为每一条执行路径都必须产生结果。


四、模式的穷尽性:编译器如何证明没有遗漏

1. 穷尽性的形式化条件

设选择器表达式的静态类型为 Sswitch 的 case 模式集合为:

P={p1,p2,,pn}P = \{p_1, p_2, \ldots, p_n\}

对于一个运行时值 v,如果:

vSpiP, match(pi,v)v \in S \land \exists p_i \in P,\ match(p_i, v)

则称 v 被该 switch 覆盖。

穷尽性要求的是:

vValues(S), piP, match(pi,v)\forall v \in Values(S),\ \exists p_i \in P,\ match(p_i, v)

也就是:选择器类型 S 的每个允许值,都至少匹配一个 case。

对于普通开放类层次,编译器无法枚举所有可能的子类,因此通常需要 default

static String describe(Object value) {
    return switch (value) {
        case String text -> "string";
        case Integer number -> "integer";
        default -> "other";
    };
}

Object 未来可以有任意新子类,StringInteger 不足以证明穷尽。

对于 sealed 层次,编译器可以沿着 permits 递归分析,进而省略 default

2. 完整算例:表达式树

下面定义一个只能由整数、加法和取负组成的表达式模型:

public class Main {
    sealed interface Expr
            permits IntLiteral, Add, Neg {
    }

    record IntLiteral(int value) implements Expr {
    }

    record Add(Expr left, Expr right) implements Expr {
    }

    record Neg(Expr expression) implements Expr {
    }

    static int evaluate(Expr expression) {
        return switch (expression) {
            case IntLiteral(var value) -> value;
            case Add(var left, var right) ->
                    evaluate(left) + evaluate(right);
            case Neg(var inner) ->
                    -evaluate(inner);
        };
    }

    static String format(Expr expression) {
        return switch (expression) {
            case IntLiteral(var value) -> Integer.toString(value);
            case Add(var left, var right) ->
                    "(" + format(left) + " + " + format(right) + ")";
            case Neg(var inner) ->
                    "(-" + format(inner) + ")";
        };
    }

    public static void main(String[] args) {
        Expr expression = new Add(
                new IntLiteral(2),
                new Neg(new IntLiteral(3))
        );

        System.out.println(format(expression));
        System.out.println(evaluate(expression));
    }
}

使用 Java 25 编译运行:

javac --release 25 Main.java
java Main

预期输出:

(2 + (-3))
-1

编译器为什么认为 evaluate 穷尽?

第一步,选择器类型是 Expr

第二步,读取 Expr 的直接许可类型:

IntLiteral
Add
Neg

第三步,判断每个直接类型是否还能产生未知后代:

  • IntLiteral 是 record,隐式 final
  • Add 是 record,隐式 final
  • Neg 是 record,隐式 final

第四步,检查三个 case:

case IntLiteral(...)
case Add(...)
case Neg(...)

它们分别覆盖三个最终变体,因此所有合法的 Expr 值都有匹配分支。

3. record pattern 为什么适合 sealed 建模

case Add(var left, var right) 不只是判断对象类型,还会解构 record 的组件:

case Add(var left, var right) ->
        evaluate(left) + evaluate(right);

这等价于概念上的两步:

if (expression instanceof Add add) {
    Expr left = add.left();
    Expr right = add.right();
    return evaluate(left) + evaluate(right);
}

record pattern 的前提是被匹配类型必须是 record,并且模式中的组件数量和结构符合 record 声明。嵌套模式也可以继续分解:

static boolean isZeroAdd(Expr expression) {
    return switch (expression) {
        case Add(IntLiteral(int left), IntLiteral(int right)) ->
                left + right == 0;
        case Add(var left, var right) ->
                isZeroAdd(left) || isZeroAdd(right);
        case IntLiteral(int value) -> value == 0;
        case Neg(var inner) -> isZeroAdd(inner);
    };
}

第一条 case 只匹配“两边都是整数字面量”的 Add。如果内部结构不符合,匹配会继续尝试后续 case。


五、null 是穷尽性中的特殊边界

1. 类型模式默认不匹配 null

类型模式:

case String text -> ...

不会匹配 null。因此,即使一个 switch 已覆盖所有非空类型,传入 null 仍可能导致 NullPointerException

例如:

static String describe(Object value) {
    return switch (value) {
        case String text -> "string";
        default -> "other";
    };
}

调用:

describe(null);

会在进入匹配过程前因空选择器失败而抛出异常,而不是进入 defaultdefault 不是“包括 null 的所有情况”的绝对兜底。

2. 显式使用 case null

如果 null 是业务状态的一部分,应明确建模:

static String describe(Object value) {
    return switch (value) {
        case null -> "没有值";
        case String text -> "字符串:" + text;
        default -> "其他对象";
    };
}

这里的匹配关系分成两部分:

null                  -> case null
非空 String           -> case String text
其他非空对象           -> default

对于 sealed 层次也一样:

static int evaluateOrDefault(Expr expression) {
    return switch (expression) {
        case null -> 0;
        case IntLiteral(var value) -> value;
        case Add(var left, var right) ->
                evaluateOrDefault(left) + evaluateOrDefault(right);
        case Neg(var inner) ->
                -evaluateOrDefault(inner);
    };
}

case null 不属于某个 sealed 子类型;它是对空引用状态的单独覆盖。


六、case 的顺序、支配关系和常见失败

1. 更具体的模式必须放在更一般的模式之前

下面的代码非法:

static String describe(Object value) {
    return switch (value) {
        case Object object -> "对象";
        case String text -> "字符串";
    };
}

因为所有 String 都是 Object。第一条模式已经匹配了所有非空对象,后面的 String 永远不可达。

应改为:

static String describe(Object value) {
    return switch (value) {
        case String text -> "字符串";
        case Object object -> "对象";
    };
}

同理:

case CharSequence sequence -> ...
case String text -> ... // 不可达

应把 String 放在 CharSequence 前面。

这种规则称为支配:如果模式 p1 匹配 p2 能匹配的全部值,那么 p1 支配 p2;被支配的 case 没有执行机会,编译器会拒绝代码。

2. when 保护条件不能随意帮助穷尽证明

可以给模式增加 when 条件:

static String classify(Integer value) {
    return switch (value) {
        case Integer number when number > 0 -> "正数";
        case Integer number when number < 0 -> "负数";
        case Integer number -> "零";
    };
}

第三条无保护的 Integer 覆盖所有剩余整数。

如果只写前两条:

static String classify(Integer value) {
    return switch (value) {
        case Integer number when number > 0 -> "正数";
        case Integer number when number < 0 -> "负数";
    };
}

编译器不能一般性地证明所有整数不是正数就是负数,因为:

  • 条件可能涉及方法调用;
  • 条件可能依赖可变状态;
  • Java 编译器不会把任意业务谓词当作完备的数学证明。

因此必须补充无保护的 case Integerdefault

3. default 与无默认分支的取舍

对开放类型使用 default 是必要的:

static String describe(Object value) {
    return switch (value) {
        case String text -> text;
        default -> "未知类型";
    };
}

对 sealed 类型,通常可以不写 default,让编译器在新增变体时帮助发现遗漏:

sealed interface Event permits UserCreated, UserDeleted {
}

record UserCreated(long id) implements Event {
}

record UserDeleted(long id) implements Event {
}

static String describe(Event event) {
    return switch (event) {
        case UserCreated created -> "创建用户 " + created.id();
        case UserDeleted deleted -> "删除用户 " + deleted.id();
    };
}

如果后来增加:

record UserLocked(long id) implements Event {
}

并把它加入:

sealed interface Event
        permits UserCreated, UserDeleted, UserLocked {
}

原来的 describe 在重新编译时会报错,因为缺少 case UserLocked

这正是“不写无意义的 default”的价值:它把模型演进暴露为编译错误,而不是让新变体静默落入旧逻辑。

不过,穷尽性是基于编译时类型信息建立的。若运行时加载的类层次与编译时假设发生不兼容变化,编译器生成的穷尽检查仍可能在运行时失败,通常表现为 IncompatibleClassChangeError。因此,不能把无 default 的穷尽 switch 当作跨版本二进制兼容机制。


七、sealed 层次与泛型:穷尽的是“可适用的类型变体”

泛型会让穷尽性分析更精细。考虑:

sealed interface Result<T>
        permits Success, Failure {
}

final class Success<T> implements Result<T> {
    T value() {
        return null;
    }
}

final class Failure<T> implements Result<T> {
    String message() {
        return "failure";
    }
}

从声明层面看,Result<T> 有两个直接实现类:

Success<T>
Failure<T>

但 Java 的类型模式不能直接使用大多数不可具体化的参数化类型。例如下面通常不能作为类型模式:

case Success<String> success -> ... // 参数化类型模式不可具体化

因为运行时通常无法检查对象是否精确携带 String 这个泛型参数。可以使用可检查的原始类型或通配符形式:

static String describe(Result<String> result) {
    return switch (result) {
        case Success<?> success -> "成功";
        case Failure<?> failure -> "失败:" + failure.message();
    };
}

这里 Success<?>Failure<?> 是可用于运行时类型判断的形式。由于 Result<String> 的实际运行时对象只能落在这两个最终类分支中,两个模式覆盖了该选择器的变体。

泛型 sealed 类型的一个重要边界是:穷尽性依赖类型参数的适用性,而不仅是简单地把所有 permits 名称机械列出。复杂的交叉类型、泛型继承关系和不可具体化类型模式会使编译器无法接受看似完整的 case 集合。遇到这类情况,应先检查:

  1. 选择器的静态类型;
  2. 每个 permitted 类型是否能实现或扩展该参数化类型;
  3. case 类型是否是可检查的运行时类型;
  4. 是否需要使用 <?>、无保护的父类型模式或 default

八、用 sealed 类型表达业务状态,而不是用无关字段组合

1. 开放对象容易产生非法状态

假设用一个普通类表示订单状态:

final class Order {
    String status;
    String paymentId;
    String failureReason;
    Instant retryAt;
}

这允许产生大量不一致组合:

status = "PAID",    paymentId = null
status = "FAILED",  failureReason = null
status = "PENDING", retryAt = null
status = "PAID",    failureReason = "银行卡拒绝"

代码需要到处检查:

if ("PAID".equals(order.status) && order.paymentId == null) {
    // 非法状态
}

字段之间的约束没有进入类型系统。

2. 用封闭变体让数据跟随状态

可以把每种状态建模成一个独立变体:

sealed interface OrderState
        permits Unpaid, Paid, Failed, Refunding {
}

record Unpaid() implements OrderState {
}

record Paid(String paymentId) implements OrderState {
}

record Failed(String reason) implements OrderState {
}

record Refunding(String refundId) implements OrderState {
}

现在:

  • Paid 必然具有 paymentId
  • Failed 必然具有 reason
  • Refunding 必然具有 refundId
  • Unpaid 不会伪造一个不适用的支付字段。

状态相关逻辑可以穷尽分派:

static String nextAction(OrderState state) {
    return switch (state) {
        case Unpaid() ->
                "发起支付";
        case Paid(String paymentId) ->
                "支付成功,记录交易 " + paymentId;
        case Failed(String reason) ->
                "展示失败原因:" + reason;
        case Refunding(String refundId) ->
                "查询退款 " + refundId;
    };
}

当新增状态 Cancelled 时,所有不再穷尽的状态分派都能在编译阶段暴露出来。

3. sealed 类型表达“允许的变体”,不自动表达状态迁移

sealed 只限制类型层次,不会自动验证状态转换是否合法。例如:

static OrderState cancel(OrderState state) {
    return new Unpaid();
}

这段代码在类型上合法,但业务上可能错误:已支付订单不应直接变为未支付。

合法迁移仍需要由方法逻辑、状态对象方法或独立的领域服务约束:

static OrderState cancel(OrderState state) {
    return switch (state) {
        case Unpaid() ->
                new Unpaid();
        case Paid(String paymentId) ->
                new Refunding("refund-for-" + paymentId);
        case Failed(String reason) ->
                new Unpaid();
        case Refunding(String refundId) ->
                new Refunding(refundId);
    };
}

这里的 switch 保证所有状态都有定义,但“每个状态应该迁移到什么状态”仍是业务规则,不是 sealed 关键字自动推导出的结论。


九、不要把所有模型都做成 sealed

1. 适合封闭的场景

当变体集合由模型拥有者控制,并且调用方需要针对每种变体做完整分派时,sealed 类型很合适:

  • 编译器前端产生的语法树;
  • 协议解析后的有限消息类型;
  • 明确固定的业务结果;
  • 领域状态机的有限状态;
  • 受控的策略结果或校验结果。

例如:

sealed interface ValidationResult
        permits Valid, Invalid {
}

record Valid() implements ValidationResult {
}

record Invalid(List<String> errors) implements ValidationResult {
}

调用方可以清楚处理成功和失败两种结果,而不需要约定字符串或空值。

2. 不适合封闭的场景

如果类型本意是扩展点,就不应强行使用 sealed:

interface Formatter {
    String format(Object value);
}

第三方库可能需要实现 Formatter。如果把它改为:

sealed interface Formatter permits JsonFormatter, XmlFormatter {
}

就会破坏扩展模型。此时普通接口加上文档约定、SPI 或注册机制更符合目标。

non-sealed 可以在层次中局部开放,但这会牺牲该分支的自动穷尽能力:

sealed interface PluginResult
        permits BuiltInResult, ThirdPartyResult {
}

record BuiltInResult(String value) implements PluginResult {
}

non-sealed interface ThirdPartyResult
        extends PluginResult {
}

这表达的是“内置结果集合受控,但第三方结果允许扩展”。针对 PluginResult 的分派必须把 ThirdPartyResult 作为一个开放分支处理。


十、常见误解与诊断路径

误解一:sealed 会阻止所有继承

不准确。它只限制直接子类型;直接子类型可以是 non-sealed,从而重新开放后续继承。

诊断时先查看继承链:

根 sealed 类型
  └── direct subtype
        ├── final:结束
        ├── sealed:继续查看 permits
        └── non-sealed:无法静态穷尽

误解二:default 一定会处理 null

不准确。没有显式 case null 时,空选择器通常在匹配前导致 NullPointerException

出现空值异常时,应检查:

  1. 选择器表达式是否可能为 null
  2. 是否需要 case null
  3. null 是否应该被视为编程错误,而不是业务状态。

误解三:写了所有类名就一定穷尽

不一定。还要考虑:

  • case 类型是否覆盖选择器的静态类型;
  • sealed 类型是否存在 non-sealed 分支;
  • 泛型类型是否可用于运行时模式匹配;
  • when 条件是否让模式只覆盖部分值;
  • 是否漏掉 null 的业务处理。

误解四:模式顺序只是代码风格

不是。模式顺序影响可达性:

case Object object -> ...
case String text -> ... // 编译错误,不可达

编译器会拒绝被前面模式完全支配的 case。修改顺序时,应该按“具体类型在前、一般类型在后”的关系检查,而不是按字段或业务优先级随意排列。

误解五:sealed 类型保证运行时永远没有未知实现

它保证的是 Java 语言层面的继承约束和编译时穷尽分析,不等于:

  • 外部数据一定符合业务模型;
  • 反射、序列化框架一定正确构造对象;
  • 不同版本二进制一定兼容;
  • 一个变体内部的数据一定满足业务不变量。

边界数据仍需解析和校验;状态迁移仍需业务规则;跨版本部署仍需兼容性策略。


十一、从建模到分派的完整推导

一个稳定的 sealed + pattern matching 设计通常可以按以下逻辑建立,而不是先写 switch 再补类型:

第一步:确定变体是否封闭

如果调用方需要知道全部变体,并且新增变体应触发编译错误,就选择 sealed

sealed interface SearchResult
        permits Found, NotFound, SearchFailed {
}

如果第三方必须扩展,改用普通接口,或只把内部受控部分设为 sealed。

第二步:让数据进入正确的变体

record Found(List<String> items) implements SearchResult {
}

record NotFound(String query) implements SearchResult {
}

record SearchFailed(String message, Throwable cause)
        implements SearchResult {
}

不要让所有可能字段都存在于一个“大对象”中,再用 status 字段解释哪些字段有效。

第三步:让最终变体停止扩展

record 默认是 final;普通类需要显式声明:

final class SearchFailed
        implements SearchResult {
}

如果某个中间状态仍可能细分,就把它声明为 sealed 并继续列出 permits

第四步:使用不带 default 的穷尽分派

static String render(SearchResult result) {
    return switch (result) {
        case Found(List<String> items) ->
                "找到 " + items.size() + " 项";
        case NotFound(String query) ->
                "没有找到:" + query;
        case SearchFailed(String message, Throwable cause) ->
                "搜索失败:" + message;
    };
}

如果编译器报告不穷尽,按以下顺序检查:

  1. 是否漏写 permitted 类型;
  2. 某个 permitted 类型是否是 sealed,需要继续展开;
  3. 是否存在 non-sealed 分支;
  4. record pattern 是否写成了无法匹配的结构;
  5. 选择器是否可能为 null

第五步:让新增变体成为显式变更

当新增:

record RateLimited(Duration retryAfter)
        implements SearchResult {
}

并加入:

sealed interface SearchResult
        permits Found, NotFound, SearchFailed, RateLimited {
}

所有依赖 SearchResult 的无默认穷尽 switch 都需要重新审查。编译错误在这里不是噪声,而是模型变化的传播信号。


十二、规范保证、实现行为与工程取舍

规范保证

Java 语言规范保证:

  • sealed 类型的直接子类型必须符合 permits 和继承修饰符约束;
  • 模式变量只在模式匹配成功且满足流作用域规则的路径上可用;
  • 不可达或被支配的 case 会被编译器拒绝;
  • switch 表达式必须穷尽;
  • sealed 层次可参与穷尽性分析;
  • null 可以使用显式 case null 处理。

常见实现行为

主流 Java 编译器会根据 sealed 层次生成必要的运行时检查。当编译时假设的封闭层次与运行时实际加载的类不一致时,通常会看到:

java.lang.IncompatibleClassChangeError

具体堆栈和触发位置取决于编译器生成的字节码及类加载环境。生产环境不应通过捕获这个错误来实现正常业务兼容,而应保证相关模块按一致版本编译和部署。

工程取舍

default 的 sealed switch 更能发现新增变体,但也意味着模型演进会要求调用方重新编译和修改。带 default 可以提供旧代码对未知变体的兜底,却可能掩盖新状态尚未处理的问题。

因此,选择方式应取决于分派位置:

  • 内部领域逻辑、编译器 AST、固定协议模型:通常优先穷尽 case,不写宽泛 default
  • 面向未来版本或外部扩展的边界适配层:可以使用明确的未知分支或 default
  • null 若是合法业务状态:显式写 case null
  • null 若表示编程错误:可以不处理,让异常尽早暴露,但应在 API 契约中明确这一点。

sealed、record pattern 和穷尽 switch 的核心价值,不是减少几行强制转换,而是把“模型有哪些变体”和“每个变体如何被处理”连接起来。类型层次负责限定世界,模式匹配负责观察这个世界,穷尽性检查则确保代码没有遗漏已知的部分。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。