密封类型给 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:
new Card(250, “EUR”) 按顺序与三个 case 比对。BankTransfer 模式在类型上就不匹配。第一个 Card 模式匹配了,但它的守卫 amount < 100 为假,所以被跳过。不带守卫的 Card 模式匹配成功,绑定 amount 和 currency,它的表达式成为结果。
如果动画没有播放,下面用文字说明这几个步骤:
- switch 收到
new Card(250, "EUR"),从第一个 case 开始。 case BankTransfer(int amount, String iban)先检查类型。这个值是Card,所以模式不匹配,什么也没有绑定。case Card(int amount, String currency) when amount < 100匹配了类型,并把amount绑定为 250。接着执行守卫。250 < 100为假,所以跳过这个 case。case Card(int amount, String currency)匹配,而且没有守卫,于是选中这个 case。- 模式把
amount绑定为 250,把currency绑定为"EUR"。 ->后面的表达式执行,得到"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。