Blog

Java 的密封类型与模式匹配

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

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

本文先讲密封类型要防止的那个 bug。然后介绍 sealedpermits、类型模式、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

CardBankTransfer 是 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 里的每个类型都必须标记为 finalsealednon-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 的,所以 CardBankTransfer 不需要修饰符。
  • 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 时,它可以列出每个被允许的子类型,完全不写 defaultswitch 中的模式在 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 加上 Cryptofee 保持不动:

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 上面,也会出现同样的错误,因为每笔转账都是一笔支付。

nullswitch

值为 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 接口或类会列出它允许的子类型。每个子类型都必须是 finalsealednon-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

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