Blog

Java 的密封类型与模式匹配

密封类型给 Java 一份封闭的子类型清单,模式匹配让 switch 逐个检查并拆开每一种子类型。两者结合,漏掉的 case 会变成编译错误,而不是悄无声息的 bug。

密封类型会列出所有允许继承或实现它的类。模式匹配让 switch 在同一步里检查值的类型并取出它的字段。把两者放在一起,编译器就知道你的代码必须处理哪些情况。以后有人新增一种,构建会指出每一处漏掉它的地方。

本文先讲密封类型要防止的那个 bug。然后介绍 sealed 和 permits、类型模式、record 模式、when 守卫、穷尽性、支配关系、null,以及未命名模式 _。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,把它保存为 Main.java,再执行 java Main.java。

问题:漏掉一种情况的类型检查

一串以 else 结尾的 instanceof 检查,在出现新的子类型时照样能顺利编译,而新的子类型会悄悄走进 else。下面是一个小小的支付系统,写法和多年来的 Java 代码一样:

interface Payment {}

class Card implements Payment {
    final int amount;

    Card(int amount) {
        this.amount = amount;
    }
}

class BankTransfer implements Payment {
    final int amount;

    BankTransfer(int amount) {
        this.amount = amount;
    }
}

class Crypto implements Payment {
    final int amount;

    Crypto(int amount) {
        this.amount = amount;
    }
}

int fee(Payment p) {
    if (p instanceof Card) {
        Card c = (Card) p;
        return c.amount * 2 / 100;
    } else if (p instanceof BankTransfer) {
        return 1;
    } else {
        return 0;
    }
}

void main() {
    IO.println("card fee:   " + fee(new Card(250)));
    IO.println("bank fee:   " + fee(new BankTransfer(250)));
    IO.println("crypto fee: " + fee(new Crypto(250)));
}

输出:

card fee:   5
bank fee:   1
crypto fee: 0

Crypto 是在 fee 写好之后才加进来的。没人更新 fee,于是现在每笔加密货币支付都不收手续费。程序没有崩溃,也没有任何警告。

编译器在这里帮不上忙,因为 Payment 是开放的。任何地方的任何类都能实现它,所以编译器没有一份清单可以拿来核对你的 if 链。else 是对尚不存在的类型的猜测,而这次猜错了。

再注意那个强制类型转换。p instanceof Card 检查了类型,(Card) p 又说了一遍。这两个问题在现代 Java 里都有解决办法。

密封接口:封闭的子类型清单

sealed 接口在 permits 子句里写明唯一允许实现它的类型。密封类和密封接口在 Java 17 成为正式特性。

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

void main() {
    Payment p = new Card(250, "EUR");
    IO.println(p);
    IO.println(Payment.class.isSealed());
}

输出:

Card[amount=250, currency=EUR]
true

Card 和 BankTransfer 是 record(记录类)。record 天然适合做密封层次结构的叶子类型:每一个都是一小包不可变的具名值。讲 record 的那一部分会详细介绍它们。

这份清单是强制执行的。试着加第三种支付类型,却不把它加进 permits:

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

record Crypto(int amount, String coin) implements Payment {}

void main() {
    IO.println(new Crypto(250, "BTC"));
}

构建失败,报错:

Main.java:7: error: class is not allowed to extend sealed class: Payment (as it is not listed in its 'permits' clause)

尽管 Payment 是接口,错误信息说的却是 “sealed class”;尽管 Crypto 是实现它,错误信息说的却是 “extend”。javac 对两种情况用的是同一套措辞。关键在后半句:Crypto 不在清单里。

每个被允许的子类型都要说明下一层怎么办

permits 里的每个类型都必须标记为 final、sealed 或 non-sealed,这样层次结构才能一路封闭到底。普通类是不允许的:

sealed interface Payment permits Card, BankTransfer, GiftCard {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

class GiftCard implements Payment {
    int balance = 50;
}

void main() {
    IO.println(new GiftCard().balance);
}

构建失败,报错:

Main.java:7: error: sealed, non-sealed or final modifiers expected

这三种选择的含义是:

  • final:没有任何类能继承它。record 隐式就是 final 的,所以 Card 和 BankTransfer 不需要修饰符。
  • sealed:它有自己的 permits 清单,往下再管一层。
  • non-sealed:谁都可以继承它。你有意打开一个分支,也就放弃了这个分支上的封闭清单。

下面用 non-sealed 让一个不在任何清单里的类通过 GiftCard 加入进来:

sealed interface Payment permits Card, BankTransfer, GiftCard {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

non-sealed class GiftCard implements Payment {
    int balance() {
        return 50;
    }
}

class StoreCredit extends GiftCard {
    @Override
    int balance() {
        return 20;
    }
}

void main() {
    Payment p = new StoreCredit();
    IO.println(p instanceof GiftCard);
    IO.println(((GiftCard) p).balance());
}

输出:

true
20

Payment 里没有任何地方提到 StoreCredit,但它仍然是 Payment,因为它是 GiftCard。编译器依然知道顶层恰好有三个分支,只是列不出 GiftCard 下面有什么。

什么时候可以省略 permits

如果被允许的子类型和密封类型声明在同一个文件里,就可以去掉 permits,由编译器替你收集:

sealed interface Payment {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

void main() {
    for (var type : Payment.class.getPermittedSubclasses()) {
        IO.println(type.getSimpleName());
    }
}

输出:

Card
BankTransfer

本文的每个程序都只有一个文件,所以 permits 在所有程序里都可以省略。后面的例子还是保留了它,好让你看到这份清单。在真实项目里,子类型通常各自放在自己的文件中,这时 permits 就是必需的。

类型模式:一步完成类型检查和命名

类型模式,比如 p instanceof Card c,会检查类型,匹配时再给你一个该类型的变量。不用写强制类型转换。instanceof 的模式匹配在 Java 16 成为正式特性。

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

String describe(Payment p) {
    if (p instanceof Card c && c.amount() >= 100) {
        return "large card payment in " + c.currency();
    }
    if (!(p instanceof Card c)) {
        return "not a card";
    }
    return "small card payment of " + c.amount();
}

void main() {
    IO.println(describe(new Card(250, "EUR")));
    IO.println(describe(new Card(40, "EUR")));
    IO.println(describe(new BankTransfer(900, "DE89 3704")));
}

输出:

large card payment in EUR
small card payment of 40
not a card

c 叫作绑定变量。它只在匹配确定成立的地方存在:

  • 在 && 之后:只有左边匹配了,右边才会执行,所以在那里用 c.amount() 是安全的。
  • 在取反并返回的检查之后:if (!(p instanceof Card c)) return ... 意味着它下面的每一行只会对卡支付执行,所以 c 在方法剩下的部分里一直可用。

把 && 换成 ||,构建就会失败,报 cannot find symbol,因为 || 的右边恰好在匹配失败时才执行。

对密封类型做 switch 不需要 default

switch 可以用类型模式作为 case。对密封类型做 switch 时,它可以列出每个被允许的子类型,完全不写 default。switch 中的模式在 Java 21 成为正式特性。

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

int fee(Payment p) {
    return switch (p) {
        case Card c -> c.amount() * 2 / 100;
        case BankTransfer t -> 1;
    };
}

void main() {
    IO.println(fee(new Card(250, "EUR")));
    IO.println(fee(new BankTransfer(250, "DE89 3704")));
}

输出:

5
1

拿它和开头的 if 链比一比。这里没有强制类型转换,也没有 else。编译器接受了这个没有 default 的 switch,因为它读了 permits,看到两个类型,并为每个类型都找到了一个 case。覆盖了所有可能值的 switch 叫作穷尽的 switch。

新增子类型会让构建失败,这是有意的

穷尽性的好处,在有人新增一个被允许的类型那天就体现出来了。下面给 Payment 加上 Crypto,fee 保持不动:

sealed interface Payment permits Card, BankTransfer, Crypto {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

record Crypto(int amount, String coin) implements Payment {}

int fee(Payment p) {
    return switch (p) {
        case Card c -> c.amount() * 2 / 100;
        case BankTransfer t -> 1;
    };
}

void main() {
    IO.println(fee(new Crypto(250, "BTC")));
}

构建失败,报错:

Main.java:10: error: the switch expression does not cover all possible input values
    return switch (p) {
           ^

这和开头 if 链犯的是同一个错误。那一次,免手续费的加密货币支付被带上了线。这一次,代码根本构建不出来。在大型代码库里,每个针对 Payment 的 switch 会同时报错,错误列表就是你的待办清单。

switch 语句一旦用了模式,也会接受同样的检查。javac 会报 the switch statement does not cover all possible input values。对枚举使用、不带模式的旧式 switch 语句,仍然可以漏掉某些值。

default 分支会丢掉这层保护

给同一个 switch 加上 default,它又能编译了,旧 bug 也跟着回来了:

sealed interface Payment permits Card, BankTransfer, Crypto {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

record Crypto(int amount, String coin) implements Payment {}

int fee(Payment p) {
    return switch (p) {
        case Card c -> c.amount() * 2 / 100;
        case BankTransfer t -> 1;
        default -> 0;
    };
}

void main() {
    IO.println("crypto fee: " + fee(new Crypto(250, "BTC")));
}

输出:

crypto fee: 0

default 会匹配其他 case 没匹配上的一切,包括你写它时还不存在的类型。编译器挑不出毛病,也就不会报错。对密封类型做 switch 时,别写 default,把 case 一个个列出来。这样编译器就会替你记住该处理什么。

用十岁孩子能懂的话说

想象一个形状分类玩具。盒子上写着,里面正好有三种形状:星形、正方形和圆形。印在盒子上的这份清单,就是密封类型。

你的盖子就是 switch。因为你知道只有三种形状,玩之前就可以检查盖子:一个星形孔、一个正方形孔、一个圆形孔。每种形状都有对应的孔,所以什么都不会卡住。

第二年,厂商加了三角形,并在盒子上印了新清单。你拿旧盖子对照新清单的那一刻,就会发现没有三角形孔。三角形还没出现,你就已经知道了。

default 分支就像在盖子中间挖了一个大洞。三角形直接从洞里掉下去,你根本不会注意到自己漏掉了它。

准确的说法

密封类型的 permits 清单是它编译后类文件的一部分。javac 编译针对密封类型的 switch 时,会检查这些 case 是否覆盖了每个被允许的子类型,遇到 sealed 子类型还会顺着往下查它自己的清单。如果没有覆盖,又没有 default,这个 switch 就无法编译。

有两个细节让这项检查比看上去更严格。带守卫的 case(when,下文会讲)不计入覆盖范围,因为编译器无法知道守卫一定为真。另外,这项检查需要一份封闭的清单:同样的 switch 如果针对一个普通的、非密封的接口,也会报同样的错误,直到你加上 default。

这个比喻的局限:检查发生在编译时,而不是形状到来时。如果 Payment 加上了 Crypto 并重新编译,但包含你那个 switch 的类没有重新编译,就没人再去检查你的盖子。不过 Java 依然不会让新类型溜过去。编译器会给每个穷尽的 switch 加一个隐藏分支,Crypto 走到那里时会抛出 java.lang.MatchException。我们用两个分别编译的文件试过,结果正是如此。把所有代码都重新编译,你得到的就会是编译错误。

record 模式:在 case 里把 record 拆开

record 模式,比如 Card(int amount, String currency),会匹配类型,并在同一个 case 里把 record 的各个组件取出来放进变量。record 模式在 Java 21 成为正式特性。

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

String describe(Payment p) {
    return switch (p) {
        case Card(int amount, String currency) -> amount + " " + currency + " by card";
        case BankTransfer(var amount, var iban) -> amount + " from account " + iban;
    };
}

void main() {
    IO.println(describe(new Card(250, "EUR")));
    IO.println(describe(new BankTransfer(900, "DE89 3704")));

    Object thing = new Card(40, "USD");
    if (thing instanceof Card(int amount, String currency)) {
        IO.println("unpacked " + amount + " and " + currency);
    }
}

输出:

250 EUR by card
900 from account DE89 3704
unpacked 40 and USD

组件按 record 声明的顺序取出。模式里的名字由你决定,不必和 record 的组件名一致,不过用相同的名字代码更好读。var 让编译器自动推断每个类型,就像 BankTransfer 那个 case 一样。

record 模式在 instanceof 里也能用,最后几行就是例子。

用 when 加守卫

守卫给 case 加上一个条件:case Card c when c.amount() < 100 只匹配金额小于 100 的卡支付。如果模式匹配了但守卫为假,switch 会继续尝试下一个 case。

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

String fee(Payment p) {
    return switch (p) {
        case BankTransfer(int amount, String iban) -> "flat fee 1 from " + iban;
        case Card(int amount, String currency) when amount < 100 -> "no fee";
        case Card(int amount, String currency) -> "fee " + amount * 2 / 100 + " " + currency;
    };
}

void main() {
    IO.println(fee(new Card(250, "EUR")));
    IO.println(fee(new Card(40, "EUR")));
    IO.println(fee(new BankTransfer(900, "DE89 3704")));
}

输出:

fee 5 EUR
no fee
flat fee 1 from DE89 3704

小额卡支付免手续费,大额的收 2%,转账统一收 1。守卫可以使用它自己的模式刚刚绑定的变量,amount < 100 就是这样。

第三个 case 没有守卫,正是它让这个 switch 保持穷尽。删掉它,构建就会失败,报 the switch expression does not cover all possible input values,尽管还剩一个卡支付的 case。带守卫的 case 永远不计入覆盖范围。

看 switch 如何挑选 case

模式 switch 里的 case 从上往下逐个尝试。下面的动画跟随 new Card(250, "EUR") 走过上面那个 switch:

p new Card(250, "EUR") switch (p) 从上往下逐个尝试 case case BankTransfer(int amount, String iban) -> case Card(int amount, String currency) when amount < 100 -> case Card(int amount, String currency) -> 不是 BankTransfer:跳过 守卫为假:跳过 匹配:执行这个 case amount = 250, currency = "EUR" 结果:"fee 5 EUR" switch 收到 p = new Card(250, "EUR"),从上往下逐个尝试 case case 1:p 不是 BankTransfer,所以 record 模式不匹配 case 2:Card 模式匹配并绑定了 amount,但守卫为假 case 3:Card 模式匹配且没有守卫,所以这个 case 胜出 模式从 record 中取出 amount 和 currency -> 后面的表达式执行,它的值就是 switch 的结果

new Card(250, “EUR”) 按顺序与三个 case 比对。BankTransfer 模式在类型上就不匹配。第一个 Card 模式匹配了,但它的守卫 amount < 100 为假,所以被跳过。不带守卫的 Card 模式匹配成功,绑定 amount 和 currency,它的表达式成为结果。

如果动画没有播放,下面用文字说明这几个步骤:

  1. switch 收到 new Card(250, "EUR"),从第一个 case 开始。
  2. case BankTransfer(int amount, String iban) 先检查类型。这个值是 Card,所以模式不匹配,什么也没有绑定。
  3. case Card(int amount, String currency) when amount < 100 匹配了类型,并把 amount 绑定为 250。接着执行守卫。250 < 100 为假,所以跳过这个 case。
  4. case Card(int amount, String currency) 匹配,而且没有守卫,于是选中这个 case。
  5. 模式把 amount 绑定为 250,把 currency 绑定为 "EUR"。
  6. -> 后面的表达式执行,得到 "fee 5 EUR",它就是整个 switch 的值。后面的 case 不会再尝试。

嵌套模式,以及用 _ 表示用不到的部分

record 模式可以嵌套,所以一个 case 能深入到一个包含其他 record 的 record 内部。未命名模式 _ 可以代替任何你用不到的组件。它在 Java 22 成为正式特性。

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

record Customer(String name, boolean vip) {}

record Order(Customer customer, Payment payment) {}

int fee(Order order) {
    return switch (order) {
        case Order(Customer(_, boolean vip), _) when vip -> 0;
        case Order(_, Card(int amount, String currency)) when currency.equals("EUR") ->
            amount * 2 / 100;
        case Order(_, Card(int amount, _)) -> amount * 3 / 100;
        case Order(_, BankTransfer _) -> 1;
    };
}

void main() {
    var ana = new Customer("Ana", true);
    var ben = new Customer("Ben", false);
    IO.println(fee(new Order(ana, new Card(250, "EUR"))));
    IO.println(fee(new Order(ben, new Card(250, "EUR"))));
    IO.println(fee(new Order(ben, new Card(250, "USD"))));
    IO.println(fee(new Order(ben, new BankTransfer(250, "DE89 3704"))));
}

输出:

0
5
7
1

从上往下读这些 case:

  • VIP 客户免手续费。模式深入到客户内部,绑定 vip,用 _ 忽略名字和整个支付。
  • 欧元卡支付收 2%。模式跳过客户,深入到支付内部。
  • 其他卡支付收 3%。Card(int amount, _) 需要金额,不需要币种。250 的 3% 是 7.5,整数除法得到 7。
  • 转账收 1。BankTransfer _ 匹配类型,什么也不绑定。

_ 的意思是“这里有东西,但我不用它”,这句话既说给编译器听,也说给读代码的人听。之后不能读取 _,而且同一个模式里可以多次使用它。

还有一个相关特性仍处于预览阶段。模式中的基本类型,比如 case int i when i < 10,在 Java 25 里是预览特性,除非传入 --enable-preview,否则 javac 会拒绝它。本系列不使用它。

顺序很重要:宽泛的 case 会挡住具体的 case

永远到达不了的 case 是编译错误,而最常见的写法就是把宽泛的 case 放在更具体的 case 上面。下面带守卫的卡支付 case 排在了普通的那个后面:

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

String fee(Payment p) {
    return switch (p) {
        case Card c -> "fee " + c.amount() * 2 / 100;
        case Card c when c.amount() < 100 -> "no fee";
        case BankTransfer t -> "flat fee 1";
    };
}

void main() {
    IO.println(fee(new Card(40, "EUR")));
}

构建失败,报错:

Main.java:10: error: this case label is dominated by a preceding case label
        case Card c when c.amount() < 100 -> "no fee";
             ^

case Card c 匹配所有卡支付,所以金额小于 100 的卡支付永远到不了下面那一行。编译器把这叫作支配:第一个 case 支配了第二个。如果这段代码能编译,小额支付就会被悄悄收取手续费。

解决办法是按从具体到宽泛的顺序排列 case,就像守卫那个例子一样。如果把 case Payment q 放在 case BankTransfer t 上面,也会出现同样的错误,因为每笔转账都是一笔支付。

null 与 switch

值为 null 时,模式 switch 会抛出 NullPointerException,除非其中有一个 case 是 case null。穷尽性不包括 null,所以一个覆盖了所有类型的 switch 在运行时仍然会失败:

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

int fee(Payment p) {
    return switch (p) {
        case Card c -> c.amount() * 2 / 100;
        case BankTransfer t -> 1;
    };
}

void main() {
    IO.println(fee(new Card(250, "EUR")));
    IO.println(fee(null));
}

输出后停止:

5
Exception in thread "main" java.lang.NullPointerException

这个异常没有错误信息。Java 的“有帮助的 NullPointerException 信息”通常会指出哪个变量是 null,这次却没有。栈跟踪的第一帧是 java.util.Objects.requireNonNull,是编译器在 switch 之前插入的。如果你在一行带 switch 的代码上看到一个光秃秃的 NPE,就去检查你拿来做 switch 的那个值。

如果 null 是你预料之中的值,就用 case null 明确写出来:

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

String fee(Payment p) {
    return switch (p) {
        case null -> "no payment yet";
        case Card c -> "fee " + c.amount() * 2 / 100;
        case BankTransfer t -> "flat fee 1";
    };
}

void main() {
    IO.println(fee(new Card(250, "EUR")));
    IO.println(fee(null));

    Payment missing = null;
    IO.println(missing instanceof Card c ? "card " + c.amount() : "not a card");
}

输出:

fee 5
no payment yet
not a card

case null 只是又一个 case,加上它不会影响穷尽性。instanceof 从来不需要它:null instanceof Card 就是 false,所以最后一行走了 “not a card” 分支。对非密封类型做 switch 时,case null, default -> 能在一行里同时处理这两种剩余情况。

要点

  • sealed 接口或类会列出它允许的子类型。每个子类型都必须是 final、sealed 或 non-sealed,而 record 本来就是 final 的。
  • x instanceof Card c 检查类型并绑定 c,不用强制类型转换。像 Card(int amount, String currency) 这样的 record 模式还会取出各个组件。
  • 覆盖了每个子类型的密封类型 switch 不需要 default。新增一个子类型,每个漏掉它的 switch 都会编译失败。
  • default 分支会把新的子类型藏起来,那个悄无声息的 bug 也就回来了。带守卫的 case 不算覆盖了某个类型。
  • case 从上往下逐个尝试。把具体的 case 放在宽泛的 case 上面,否则 javac 会报某个 case 被支配。
  • 模式 switch 遇到 null 会抛出 NullPointerException,除非它有 case null。
  • 用不到的组件就用 _。

密封类型把完整的清单交给编译器,所以编译器能告诉你漏掉了哪个 case。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。