《Java 编程入门》9.3 sealed 与模式匹配

把 library 的 CLI 从字符串命令和 if/else 改成 sealed Command 接口加记录子类型,再用 switch 模式匹配一次性解构、守卫和分发;讲清 permits 的限制、穷尽性检查、模式顺序与 null 分支,并给出非穷尽 switch 与未授权实现的真实编译错误。

本节目标:能用 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:三种容器的选择 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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