跳到主要内容

JEP 441:switch 模式匹配

QWen Max 中英对照 JEP 441: Pattern Matching for switch

总结

增强 Java 编程语言,为 switch 表达式和语句添加模式匹配功能。将模式匹配扩展到 switch 可以使一个表达式与多个模式进行匹配,每个模式都有特定的操作,从而能够简洁且安全地表达以数据为导向的复杂查询。

历史

该特性最初由 JEP 406(JDK 17)提出,随后由 JEP 420(JDK 18)、427(JDK 19)和 433(JDK 20)进行了改进。它与 Record Patterns 特性(JEP 440)共同演进,并且两者之间有相当多的交互。本 JEP 提议在持续的经验和反馈基础上,通过进一步的小幅改进来最终确定该特性。

除了各种编辑性修改之外,与之前的 JEP 相比,主要的变化在于:

  • 移除带括号的模式,因为它们的价值不够,并且

  • 允许在 switch 表达式和语句中将限定的枚举常量作为 case 常量。

目标

  • 通过允许在 case 标签中出现模式,扩展 switch 表达式和语句的表现力与适用性。

  • 在需要时,允许放宽 switch 历史上对空值的严格限制。

  • 通过要求模式 switch 语句覆盖所有可能的输入值,提高 switch 语句的安全性。

  • 确保所有现有的 switch 表达式和语句无需更改即可继续编译,并以相同的语义执行。

动机

在 Java 16 中,JEP 394instanceof 运算符进行了扩展,使其能够接受一个 类型模式 并执行 模式匹配。这一适度的扩展简化了常见的 instanceof-然后-强制转换的习惯用法,使其更加简洁且不易出错:

// Prior to Java 16
if (obj instanceof String) {
String s = (String)obj;
... use s ...
}

// As of Java 16
if (obj instanceof String s) {
... use s ...
}

在新代码中,如果在运行时 obj 的值是 String 的一个实例,则 obj 匹配类型模式 String s。如果该模式匹配,则 instanceof 表达式为 true,并且模式变量 s 会被初始化为 obj 转换为 String 后的值,然后可以在包含的代码块中使用。

我们经常需要将一个变量(例如 obj)与多个选项进行比较。Java 通过 switch 语句支持多路比较,并且从 Java 14 开始支持 switch 表达式(JEP 361),但遗憾的是,switch 的功能非常有限。我们只能对少数类型的值进行切换 —— 整型基本类型(不包括 long)、它们对应的包装类形式、enum 类型以及 String 类型,并且只能测试与常量的完全相等性。我们可能希望使用模式来针对多种可能性测试同一个变量,并在每种情况下采取特定的操作,但由于现有的 switch 不支持这一点,最终我们只能编写一连串的 if...else 测试,例如:

// Prior to Java 21
static String formatter(Object obj) {
String formatted = "unknown";
if (obj instanceof Integer i) {
formatted = String.format("int %d", i);
} else if (obj instanceof Long l) {
formatted = String.format("long %d", l);
} else if (obj instanceof Double d) {
formatted = String.format("double %f", d);
} else if (obj instanceof String s) {
formatted = String.format("String %s", s);
}
return formatted;
}

这段代码受益于使用了模式 instanceof 表达式,但它远非完美。首先,也是最重要的一点,这种方法允许编码错误隐藏起来,因为我们使用了一个过于通用的控制结构。我们的意图是在 if...else 链的每个分支中为 formatted 赋值,但没有任何机制能够让编译器识别并强制执行这一不变量。如果某个“then”块(可能是很少执行的那个)没有对 formatted 进行赋值,就会产生一个 bug。(将 formatted 声明为一个空白局部变量至少可以让编译器的确定性赋值分析发挥作用,但开发者并不总是这样写声明。)此外,上述代码无法优化;即使底层问题通常是 O(1) 的复杂度,但如果没有编译器的特殊优化,它的时间复杂度将是 O(n)。

但是 switch 非常适合用于模式匹配!如果我们将 switch 语句和表达式扩展到适用于任何类型,并允许 case 标签使用模式而不仅仅是常量,那么我们就可以更清晰、更可靠地重写上面的代码:

// As of Java 21
static String formatterPatternSwitch(Object obj) {
return switch (obj) {
case Integer i -> String.format("int %d", i);
case Long l -> String.format("long %d", l);
case Double d -> String.format("double %f", d);
case String s -> String.format("String %s", s);
default -> obj.toString();
};
}

这个 switch 的语义很明确:如果选择器表达式 obj 的值与模式匹配,那么带有该模式的 case 标签就适用。(为了简洁起见,我们展示了一个 switch 表达式,但也可以展示一个 switch 语句;包括 case 标签在内的 switch 块保持不变。)

这段代码的意图更清晰,因为我们使用了正确的控制结构:我们说的是,“参数 obj 最多匹配以下条件之一,找出它并评估相应的分支。” 作为额外的好处,它更容易优化;在这种情况下,我们更有可能以 O(1) 的时间复杂度执行分派。

开关与空值

传统上,如果选择器表达式计算结果为 nullswitch 语句和表达式会抛出 NullPointerException,因此必须在 switch 外部测试 null

// Prior to Java 21
static void testFooBarOld(String s) {
if (s == null) {
System.out.println("Oops!");
return;
}
switch (s) {
case "Foo", "Bar" -> System.out.println("Great");
default -> System.out.println("Ok");
}
}

switch 仅支持少数引用类型时,这是合理的。然而,如果 switch 允许选择器表达式为任何引用类型,并且 case 标签可以具有类型模式,那么单独的 null 检查就会感觉像是一个武断的区别,容易引发不必要的样板代码和出错机会。通过允许新的 null case 标签,将 null 检查集成到 switch 中会更好:

// As of Java 21
static void testFooBarNew(String s) {
switch (s) {
case null -> System.out.println("Oops");
case "Foo", "Bar" -> System.out.println("Great");
default -> System.out.println("Ok");
}
}

当选择器表达式的值为 null 时,switch 的行为始终由其 case 标签决定。如果有 case nullswitch 将执行与该标签关联的代码;如果没有 case nullswitch 将抛出 NullPointerException,与之前相同。(为了与当前 switch 语义保持向后兼容,default 标签不会匹配 null 选择器。)

案例提炼

与带有常量的 case 标签相反,模式 case 标签可以应用于许多值。这常常会导致在 switch 规则右侧出现条件代码。例如,考虑以下代码:

// As of Java 21
static void testStringOld(String response) {
switch (response) {
case null -> { }
case String s -> {
if (s.equalsIgnoreCase("YES"))
System.out.println("You got it");
else if (s.equalsIgnoreCase("NO"))
System.out.println("Shame");
else
System.out.println("Sorry?");
}
}
}

这里的问题在于,使用单一模式来区分不同情况无法扩展到单一条件之外。我们更倾向于编写多个模式,但这就需要某种方式来表达对模式的细化。因此,我们在 switch 块中允许使用 when 子句,为模式 case 标签指定保护条件,例如:case String s when s.equalsIgnoreCase("YES")。我们将这样的 case 标签称为受保护的 case 标签,并将布尔表达式称为保护条件

通过这种方法,我们可以使用 guards 重写上面的代码:

// As of Java 21
static void testStringNew(String response) {
switch (response) {
case null -> { }
case String s
when s.equalsIgnoreCase("YES") -> {
System.out.println("You got it");
}
case String s
when s.equalsIgnoreCase("NO") -> {
System.out.println("Shame");
}
case String s -> {
System.out.println("Sorry?");
}
}
}

这将导致一种更易读的 switch 编程风格,其中测试的复杂性出现在 switch 规则的左侧,而如果满足该测试则适用的逻辑位于 switch 规则的右侧。

我们可以通过为其他已知常量字符串添加额外的规则来进一步增强此示例:

// As of Java 21
static void testStringEnhanced(String response) {
switch (response) {
case null -> { }
case "y", "Y" -> {
System.out.println("You got it");
}
case "n", "N" -> {
System.out.println("Shame");
}
case String s
when s.equalsIgnoreCase("YES") -> {
System.out.println("You got it");
}
case String s
when s.equalsIgnoreCase("NO") -> {
System.out.println("Shame");
}
case String s -> {
System.out.println("Sorry?");
}
}
}

这些示例展示了 case 常量、case 模式和 null 标签如何结合在一起,展示出 switch 编程的新能力:我们可以将以前与业务逻辑混杂在一起的复杂条件逻辑简化为可读的、顺序排列的 switch 标签列表,并将业务逻辑放在 switch 规则的右侧。

开关与枚举常量

目前,case 标签中使用枚举常量的限制非常严格:switch 的选择器表达式必须为枚举类型,并且标签必须是该枚举常量的简单名称。例如:

// Prior to Java 21
public enum Suit { CLUBS, DIAMONDS, HEARTS, SPADES }

static void testforHearts(Suit s) {
switch (s) {
case HEARTS -> System.out.println("It's a heart!");
default -> System.out.println("Some other suit");
}
}

即使添加了模式标签,此约束也会导致代码不必要地冗长。例如:

// As of Java 21
sealed interface CardClassification permits Suit, Tarot {}
public enum Suit implements CardClassification { CLUBS, DIAMONDS, HEARTS, SPADES }
final class Tarot implements CardClassification {}

static void exhaustiveSwitchWithoutEnumSupport(CardClassification c) {
switch (c) {
case Suit s when s == Suit.CLUBS -> {
System.out.println("It's clubs");
}
case Suit s when s == Suit.DIAMONDS -> {
System.out.println("It's diamonds");
}
case Suit s when s == Suit.HEARTS -> {
System.out.println("It's hearts");
}
case Suit s -> {
System.out.println("It's spades");
}
case Tarot t -> {
System.out.println("It's a tarot");
}
}
}

如果能为每个枚举常量单独设置一个 case,而不是使用大量受保护的模式,那么这段代码的可读性会更强。因此,我们放宽了选择器表达式必须为枚举类型的要求,并允许 case 常量使用枚举常量的限定名称。这使得上面的代码可以重写为:

// As of Java 21
static void exhaustiveSwitchWithBetterEnumSupport(CardClassification c) {
switch (c) {
case Suit.CLUBS -> {
System.out.println("It's clubs");
}
case Suit.DIAMONDS -> {
System.out.println("It's diamonds");
}
case Suit.HEARTS -> {
System.out.println("It's hearts");
}
case Suit.SPADES -> {
System.out.println("It's spades");
}
case Tarot t -> {
System.out.println("It's a tarot");
}
}
}

现在,我们为每个枚举常量都提供了一个直接的 case,而无需使用受保护的类型模式,这些模式之前仅仅是为了绕过类型系统的当前限制而使用的。

描述

我们通过以下四种方式增强了 switch 语句和表达式:

  • 改进枚举常量 case 标签,

  • 扩展 case 标签以包含模式和 null,而不仅仅是常量,

  • 拓宽允许用于 switch 语句和 switch 表达式的选择器表达式的类型范围(同时需要对 switch 块的完备性进行更丰富的分析),以及

  • 允许在 case 标签后添加可选的 when 子句。

改进的枚举常量 case 标签

长期以来,有这样一个要求:当切换枚举类型时,唯一有效的 case 常量就是枚举常量。但是,对于新的、更丰富的 switch 形式来说,这是一个负担沉重的严格要求。

为了保持与现有 Java 代码的兼容性,在枚举类型上进行切换时,case 常量仍然可以使用被切换的枚举类型的常量的简单名称。

对于新代码,我们扩展了枚举的处理方式。首先,我们允许枚举常量的限定名称作为 case 常量出现。这些限定名称可以在对枚举类型进行切换时使用。

其次,当使用某个枚举常量的名称作为 case 常量时,我们取消了选择器表达式必须为枚举类型的要求。在这种情况下,我们要求该名称是限定的,并且其值能够与选择器表达式的类型赋值兼容。(这一调整使得枚举 case 常量的处理方式与数值型 case 常量保持一致。)

例如,允许以下两种方法:

// As of Java 21
sealed interface Currency permits Coin {}
enum Coin implements Currency { HEADS, TAILS }

static void goodEnumSwitch1(Currency c) {
switch (c) {
case Coin.HEADS -> { // Qualified name of enum constant as a label
System.out.println("Heads");
}
case Coin.TAILS -> {
System.out.println("Tails");
}
}
}

static void goodEnumSwitch2(Coin c) {
switch (c) {
case HEADS -> {
System.out.println("Heads");
}
case Coin.TAILS -> { // Unnecessary qualification but allowed
System.out.println("Tails");
}
}
}

下面的示例是不允许的:

// As of Java 21
static void badEnumSwitch(Currency c) {
switch (c) {
case Coin.HEADS -> {
System.out.println("Heads");
}
case TAILS -> { // Error - TAILS must be qualified
System.out.println("Tails");
}
default -> {
System.out.println("Some currency");
}
}
}

switch 标签中的模式

我们修改了 switch 块中 switch 标签的语法,具体如下(请参阅 JLS §14.11.1):

SwitchLabel:
case CaseConstant { , CaseConstant }
case null [, default]
case Pattern [ Guard ]
default

主要的增强功能是引入了一种新的 case 标签,即 case p,其中 p 是一个模式。switch 的本质保持不变:选择器表达式的值会与 switch 标签进行比较,选择其中一个标签,并执行或评估与该标签关联的代码。现在的区别在于,对于带有模式的 case 标签,所选标签是由模式匹配的结果决定的,而不是通过相等性测试。例如,在以下代码中,obj 的值与模式 Long l 匹配,并且与标签 case Long l 关联的表达式会被评估:

// As of Java 21
static void patternSwitchTest(Object obj) {
String formatted = switch (obj) {
case Integer i -> String.format("int %d", i);
case Long l -> String.format("long %d", l);
case Double d -> String.format("double %f", d);
case String s -> String.format("String %s", s);
default -> obj.toString();
};
}

成功匹配模式后,我们通常会进一步测试匹配结果。这可能会导致代码变得冗长,例如:

// As of Java 21
static void testOld(Object obj) {
switch (obj) {
case String s:
if (s.length() == 1) { ... }
else { ... }
break;
...
}
}

期望的测试 —— 即 obj 是长度为 1 的 String —— 不幸地被分在了模式 case 标签和接下来的 if 语句之间。

为了解决这个问题,我们引入了受保护的模式 case 标签,允许在模式标签后跟随一个可选的保护条件,即布尔表达式。这使得上面的代码可以重写,从而将所有条件逻辑提升到 switch 标签中:

// As of Java 21
static void testNew(Object obj) {
switch (obj) {
case String s when s.length() == 1 -> ...
case String s -> ...
...
}
}

如果 obj 既是 String 而且长度为 1,那么第一个子句就会匹配。如果 obj 是任意长度的 String,那么第二种情况就会匹配。

只有模式标签可以具有守卫。例如,编写带有 case 常量和守卫的标签是无效的;例如,case "Hello" when callRandomBooleanExpression()

switch 中支持模式时,需要考虑五个主要的语言设计领域:

  • 增强类型检查
  • switch 表达式和语句的完备性
  • 模式变量声明的作用域
  • 处理 null
  • 错误

增强的类型检查

选择器表达式类型

switch 中支持模式意味着我们可以放宽对选择器表达式的类型限制。当前,普通 switch 的选择器表达式类型必须是以下之一:基本整数类型(不包括 long)、对应的基本类型的包装类(即 CharacterByteShortInteger)、String 或枚举类型 (enum)。我们对此进行了扩展,现在要求选择器表达式的类型可以是基本整数类型(不包括 long)或任何引用类型。

例如,在下面的模式 switch 中,选择器表达式 obj 会与涉及类类型、enum 类型、记录类型和数组类型的类型模式进行匹配,同时还有一个 nullcase 标签和一个 default

// As of Java 21
record Point(int i, int j) {}
enum Color { RED, GREEN, BLUE; }

static void typeTester(Object obj) {
switch (obj) {
case null -> System.out.println("null");
case String s -> System.out.println("String");
case Color c -> System.out.println("Color: " + c.toString());
case Point p -> System.out.println("Record class: " + p.toString());
case int[] ia -> System.out.println("Array of ints of length" + ia.length);
default -> System.out.println("Something else");
}
}

switch 块中的每个 case 标签必须与选择器表达式兼容。对于带有模式的 case 标签(称为 模式标签),我们使用现有的 表达式与模式的兼容性 概念 (JLS §14.30.1)。

case 标签的主导性

支持模式 case 标签意味着,对于选择器表达式的给定值,现在可能会有多个 case 标签适用,而以前最多只有一个 case 标签适用。例如,如果选择器表达式的计算结果为 String 类型,那么 case String scase CharSequence cs 这两个 case 标签都将适用。

首先要解决的问题是,确定在这种情况下应该应用哪个标签。我们并没有尝试复杂“最佳匹配”方法,而是采用了更简单的语义:选择开关块中适用于某个值的第一个 case 标签。

// As of Java 21
static void first(Object obj) {
switch (obj) {
case String s ->
System.out.println("A string: " + s);
case CharSequence cs ->
System.out.println("A sequence of length " + cs.length());
default -> {
break;
}
}
}

在此示例中,如果 obj 的值属于 String 类型,则第一个 case 标签将适用;如果它属于 CharSequence 类型但不属于 String 类型,则第二个模式标签将适用。

但是,如果交换这两个 case 标签的顺序,会发生什么呢?

// As of Java 21
static void error(Object obj) {
switch (obj) {
case CharSequence cs ->
System.out.println("A sequence of length " + cs.length());
case String s -> // Error - pattern is dominated by previous pattern
System.out.println("A string: " + s);
default -> {
break;
}
}
}

现在,如果 obj 的值属于 String 类型,那么 CharSequencecase 标签将适用,因为它在 switch 块中首先出现。从某种意义上说,Stringcase 标签是无法到达的,因为没有任何选择器表达式的值会导致它被选中。类比于不可达代码,这被视为程序员的错误,并导致编译时错误。

更准确地说,我们说第一个 case 标签 case CharSequence cs 支配 了第二个 case 标签 case String s,因为每个匹配模式 String s 的值同样也匹配模式 CharSequence cs,但反之则不成立。这是由于第二个模式的类型 String 是第一个模式的类型 CharSequence 的子类型。

一个无保护模式的 case 标签会支配具有相同模式的受保护模式 case 标签。例如,(无保护的)模式 case 标签 case String s 支配受保护模式的 case 标签 case String s when s.length() > 0,因为每个匹配 case 标签 case String s when s.length() > 0 的值必定也匹配 case 标签 case String s

一个带有保护条件的 case 标签仅在其模式支配另一个模式 其保护条件为值为 true 的常量表达式时,才会支配另一个(带或不带保护条件的)模式 case 标签。例如,带有保护条件的 case 标签 case String s when true 支配模式 case 标签 case String s。为了更精确地确定哪些值与模式标签匹配,我们不会进一步分析保护表达式 —— 这是一个在一般情况下无法判定的问题。

模式 case 标签可以支配常量 case 标签。例如,模式 case 标签 case Integer i 支配常量 case 标签 case 42,而模式 case 标签 case E e 支配常量 case 标签 case A,当 A 是枚举类类型 E 的成员时。如果相同的模式 case 标签没有保护条件时支配常量 case 标签,则带保护条件的模式 case 标签也支配该常量 case 标签。换句话说,我们不检查保护条件,因为这在一般情况下是不可判定的。例如,模式 case 标签 case String s when s.length() > 1 支配常量 case 标签 case "hello",正如预期;但是 case Integer i when i != 0 支配 case 标签 case 0

所有这些都表明 case 标签应该有一个简单、可预测且可读的顺序,其中常量 case 标签应出现在受保护模式 case 标签之前,而后者又应出现在未受保护模式 case 标签之前:

// As of Java 21
Integer i = ...
switch (i) {
case -1, 1 -> ... // Special cases
case Integer j when j > 0 -> ... // Positive integer cases
case Integer j -> ... // All the remaining integers
}

编译器会检查所有的 case 标签。如果在同一个 switch 块中,某个 case 标签被其前面的任意 case 标签所支配(即覆盖),则会导致编译时错误。这一支配要求确保了:如果一个 switch 块仅包含类型模式的 case 标签,则它们将按照子类型顺序出现。

(支配的概念类似于 try 语句中对 catch 子句的条件要求,即如果一个捕获异常类 Ecatch 子句前面存在另一个能够捕获 EE 的超类的 catch 子句,则会产生错误(参见 JLS §11.2.3)。从逻辑上讲,前面的 catch 子句支配了后面的 catch 子句。)

如果 switch 表达式或 switch 语句的 switch 块中存在多个匹配所有情况的 switch 标签,这同样会导致编译时错误。匹配所有情况的标签包括 default 和模式 case 标签,其中模式无条件地匹配选择器表达式。例如,类型模式 String s 会无条件地匹配类型为 String 的选择器表达式,而类型模式 Object o 则会无条件地匹配任何引用类型的选择器表达式:

// As of Java 21
static void matchAll(String s) {
switch(s) {
case String t:
System.out.println(t);
break;
default:
System.out.println("Something else"); // Error - dominated!
}
}

static void matchAll2(String s) {
switch(s) {
case Object o:
System.out.println("An Object");
break;
default:
System.out.println("Something else"); // Error - dominated!
}
}

switch 表达式和语句的穷尽性

类型覆盖率

switch 表达式要求在 switch 块中处理选择器表达式的所有可能值;换句话说,它必须是穷尽的。这保证了 switch 表达式成功求值后总是会生成一个值。

对于普通的 switch 表达式,此属性由对 switch 块的一组简单额外条件强制执行。

对于模式 switch 表达式和语句,我们通过定义一个开关块中开关标签的 类型覆盖 概念来实现这一点。然后,将开关块中所有开关标签的类型覆盖结合起来,以确定开关块是否穷尽了选择器表达式的所有可能性。

考虑以下(错误的)模式 switch 表达式:

// As of Java 21
static int coverage(Object obj) {
return switch (obj) { // Error - not exhaustive
case String s -> s.length();
};
}

switch 块只有一个 switch 标签,即 case String s。这将匹配任何类型为 String 子类型的 obj 值。因此,我们说这个 switch 标签的类型覆盖范围是 String 的所有子类型。此模式 switch 表达式并非详尽无遗,因为其 switch 块的类型覆盖范围(String 的所有子类型)并未包含选择器表达式的类型(Object)。

考虑这个(仍然有错误的)例子:

// As of Java 21
static int coverage(Object obj) {
return switch (obj) { // Error - still not exhaustive
case String s -> s.length();
case Integer i -> i;
};
}

switch 块的类型覆盖范围是其两个 switch 标签覆盖范围的并集。换句话说,类型覆盖范围是 String 的所有子类型和 Integer 的所有子类型的集合。但是,再次强调,类型覆盖范围仍然不包括选择器表达式的类型,因此此模式 switch 表达式也不详尽无遗,并导致编译时错误。

default 标签的类型覆盖范围是所有类型,因此这个例子(终于!)是合法的:

// As of Java 21
static int coverage(Object obj) {
return switch (obj) {
case String s -> s.length();
case Integer i -> i;
default -> 0;
};
}

实践中的详尽性

类型覆盖的概念在非模式 switch 表达式中已经存在。例如:

// As of Java 20
enum Color { RED, YELLOW, GREEN }

int numLetters = switch (color) { // Error - not exhaustive!
case RED -> 3;
case GREEN -> 5;
}

此枚举类上的 switch 表达式并不全面,因为预期的输入 YELLOW 未被覆盖。正如预期的那样,添加一个处理 YELLOW 枚举常量的 case 标签就足以使 switch 全面:

// As of Java 20
int numLetters = switch (color) { // Exhaustive!
case RED -> 3;
case GREEN -> 5;
case YELLOW -> 6;
}

以这种方式编写的 switch 是详尽无遗的,这有两个重要的好处。

首先,必须编写一个 default 子句会很麻烦,因为它可能只是抛出一个异常,而我们已经处理了所有情况:

int numLetters = switch (color) {
case RED -> 3;
case GREEN -> 5;
case YELLOW -> 6;
default -> throw new ArghThisIsIrritatingException(color.toString());
}

在这种情况下,手动编写 default 子句不仅令人烦恼,而且实际上是有害的,因为编译器在没有 default 子句的情况下可以更好地检查穷尽性。(对于任何其他匹配所有情况的子句也是如此,例如 defaultcase null, default 或无条件类型模式。)如果我们省略 default 子句,那么如果忘记了一个 case 标签,我们将在编译时发现,而不是在运行时才发现——甚至可能到那时都发现不了。

更重要的是,如果有人后来在 Color 枚举中添加了另一个常量会发生什么?如果我们有一个显式的全匹配子句,那么只有在运行时出现新的常量值时我们才会发现它。但是,如果我们编写 switch 语句来覆盖编译时已知的所有常量,并省略全匹配子句,那么当下次我们重新编译包含该 switch 的类时,我们就会发现这一变化。全匹配子句可能会掩盖穷尽性错误。

总之:在可能的情况下,没有匹配全部子句的详尽 switch 语句比有匹配全部子句的详尽 switch 语句更好。

在考虑运行时的情况下,如果添加了一个新的 Color 常量,并且包含 switch 的类没有重新编译,会发生什么?存在这样一种风险:新的常量可能会暴露给我们的 switch。因为这种风险在枚举类型中始终存在,如果一个详尽的枚举 switch 没有匹配全部的子句,那么编译器将合成一个抛出异常的 default 子句。这保证了 switch 无法在不选择其中一个子句的情况下正常完成。

穷尽性的概念旨在在覆盖所有合理情况之间取得平衡,同时又不会迫使你编写可能许多罕见的极端情况,这些情况对实际价值贡献很小,但却会污染甚至主导你的代码。换句话说:穷尽性是真实运行时穷尽性的一种编译时近似。

完备性与密封类

如果选择器表达式的类型是一个密封类(JEP 409),那么类型覆盖检查可以考虑密封类的 permits 子句,以确定 switch 块是否详尽无遗。这有时可以消除对 default 子句的需求,正如上文所述,这是一个良好的实践。请看以下示例,其中有一个 sealed 接口 S,它允许三个子类 ABC

// As of Java 21
sealed interface S permits A, B, C {}
final class A implements S {}
final class B implements S {}
record C(int i) implements S {} // Implicitly final

static int testSealedExhaustive(S s) {
return switch (s) {
case A a -> 1;
case B b -> 2;
case C c -> 3;
};
}

编译器可以确定 switch 块的类型覆盖范围为类型 ABC。由于选择器表达式的类型 S 是一个密封接口,其允许的子类正好是 ABC,因此这个 switch 块是穷尽的。结果就是,不需要 default 标签。

当允许的直接子类仅实现(泛型)sealed 超类的特定参数化时,需要一些额外的注意。例如:

// As of Java 21
sealed interface I<T> permits A, B {}
final class A<X> implements I<String> {}
final class B<Y> implements I<Y> {}

static int testGenericSealedExhaustive(I<Integer> i) {
return switch (i) {
// Exhaustive as no A case possible!
case B<Integer> bi -> 42;
};
}

I 唯一允许的子类是 AB,但编译器可以检测到,为了覆盖所有情况,switch 块只需要处理类 B,因为选择器表达式的类型是 I<Integer>,而 A 的任何参数化都不是 I<Integer> 的子类型。

同样,详尽性的概念是一种近似。由于独立编译的原因,接口 I 的新实现有可能在运行时出现,因此在这种情况下,编译器会插入一个合成的 default 子句来抛出异常。

由于记录模式(JEP 440)可以嵌套,这使得穷尽性的概念变得更加复杂。因此,穷尽性的概念必须反映这种潜在的递归结构。

完备性与兼容性

穷尽性要求适用于模式 switch 表达式和模式 switch 语句。为了确保向后兼容,所有现有的 switch 语句都将保持不变地编译。但如果某个 switch 语句使用了本 JEP 中描述的任何 switch 增强功能,那么编译器将检查其是否具有穷尽性。(Java 语言的未来编译器可能会对非穷尽性的旧版 switch 语句发出警告。)

更准确地说,任何使用模式或 null 标签的 switch 语句,或者其选择表达式不是旧类型(charbyteshortintCharacterByteShortIntegerStringenum 类型)之一时,都要求具备完备性。例如:

// As of Java 21
sealed interface S permits A, B, C {}
final class A implements S {}
final class B implements S {}
record C(int i) implements S {} // Implicitly final

static void switchStatementExhaustive(S s) {
switch (s) { // Error - not exhaustive;
// missing clause for permitted class B!
case A a :
System.out.println("A");
break;
case C c :
System.out.println("C");
break;
};
}

模式变量声明的作用域

模式变量JEP 394)是由模式声明的局部变量。模式变量声明的特殊之处在于其作用域是流敏感型的。以下面的例子回顾一下,其中类型模式 String s 声明了模式变量 s

// As of Java 21
static void testFlowScoping(Object obj) {
if ((obj instanceof String s) && s.length() > 3) {
System.out.println(s);
} else {
System.out.println("Not a string");
}
}

s 的声明在代码的某些部分中是有效的,这些部分中模式变量 s 将已被初始化。在此示例中,是在 && 表达式的右侧操作数和 "then" 块中。然而,s 在 "else" 块中是无效的:为了使控制转移到 "else" 块,模式匹配必须失败,在这种情况下,模式变量将未被初始化。

我们将这种对模式变量声明的流敏感作用域概念扩展到包含 case 标签中出现的模式声明,并通过三条新规则来实现:

  1. 在受保护的 case 标签模式中出现的模式变量声明的作用域包括保护条件,即 when 表达式。

  2. switch 规则的 case 标签中出现的模式变量声明的作用域包括出现在箭头右侧的表达式、代码块或 throw 语句。

  3. switch 带标签的语句组的 case 标签中出现的模式变量声明的作用域包括该语句组的代码块语句。禁止贯穿声明了模式变量的 case 标签。

此示例显示了第一个规则的实际应用:

// As of Java 21
static void testScope1(Object obj) {
switch (obj) {
case Character c
when c.charValue() == 7:
System.out.println("Ding!");
break;
default:
break;
}
}

模式变量 c 的声明范围包括保护条件,即表达式 c.charValue() == 7

此变体展示了第二条规则的实际应用:

// As of Java 21
static void testScope2(Object obj) {
switch (obj) {
case Character c -> {
if (c.charValue() == 7) {
System.out.println("Ding!");
}
System.out.println("Character");
}
case Integer i ->
throw new IllegalStateException("Invalid Integer argument: "
+ i.intValue());
default -> {
break;
}
}
}

这里,模式变量 c 的声明范围是第一个箭头右侧的块。模式变量 i 的声明范围是第二个箭头右侧的 throw 语句。

第三条规则更为复杂。让我们首先考虑一个例子,其中对于一个 switch 标签语句组只有一个 case 标签:

// As of Java 21
static void testScope3(Object obj) {
switch (obj) {
case Character c:
if (c.charValue() == 7) {
System.out.print("Ding ");
}
if (c.charValue() == 9) {
System.out.print("Tab ");
}
System.out.println("Character");
default:
System.out.println();
}
}

模式变量 c 的声明范围包括语句组中的所有语句,即两个 if 语句和 println 语句。该范围不包括 default 语句组中的语句,即使第一个语句组的执行可以贯穿 default 开关标签并执行这些语句。

我们禁止通过声明模式变量的 case 标签的可能性。考虑这个错误的例子:

// As of Java 21
static void testScopeError(Object obj) {
switch (obj) {
case Character c:
if (c.charValue() == 7) {
System.out.print("Ding ");
}
if (c.charValue() == 9) {
System.out.print("Tab ");
}
System.out.println("character");
case Integer i: // Compile-time error
System.out.println("An integer " + i);
default:
break;
}
}

如果允许这样做,并且 obj 的值为 Character,那么在执行 switch 块时,可能会跳过第二个语句组(即 case Integer i: 之后的部分),此时模式变量 i 将未被初始化。因此,允许执行跳过声明了模式变量的 case 标签属于编译时错误。

这就是为什么不允许由多个模式标签组成的 switch 标签,例如 case Character c: case Integer i: ...。类似的推理也适用于禁止在单个 case 标签中使用多个模式:无论是 case Character c, Integer i: ... 还是 case Character c, Integer i -> ... 都不被允许。如果允许这样的 case 标签,那么在冒号或箭头之后,ci 都会处于作用域中,但根据 obj 的值是 Character 还是 Integer,它们中只有一个会被初始化。

另一方面,如本例所示,跳过未声明模式变量的标签是安全的:

// As of Java 21
void testScope4(Object obj) {
switch (obj) {
case String s:
System.out.println("A string: " + s); // s in scope here!
default:
System.out.println("Done"); // s not in scope here
}
}

处理 null

传统上,如果选择器表达式的结果为 nullswitch 会抛出 NullPointerException。这种行为是广为人知的,我们并不建议对任何现有的 switch 代码进行更改。然而,对于模式匹配和 null 值,存在合理且不会引发异常的语义,因此在模式匹配的 switch 块中,我们可以在保持与现有 switch 语义兼容的同时,以更常规的方式处理 null

首先,我们引入了一个新的 null 情况标签。然后,我们取消了这样一条通用规则:即如果选择器表达式的值为 nullswitch 语句会立即抛出 NullPointerException。相反,我们会检查 case 标签以确定 switch 的行为:

  • 如果选择器表达式计算结果为 null,则任何 null 的 case 标签被认为匹配。如果 switch 块中没有与此类标签关联,则 switch 会像以前一样抛出 NullPointerException

  • 如果选择器表达式计算结果为非 null 值,那么我们像往常一样选择一个匹配的 case 标签。如果没有 case 标签匹配,则任何 default 标签都被认为是匹配的。

例如,对于下面的声明,求值 nullMatch(null) 会打印 null! 而不是抛出 NullPointerException 异常:

// As of Java 21
static void nullMatch(Object obj) {
switch (obj) {
case null -> System.out.println("null!");
case String s -> System.out.println("String");
default -> System.out.println("Something else");
}
}

一个没有 case null 标签的 switch 块会被视为包含一个抛出 NullPointerExceptioncase null 规则。换句话说,以下代码:

// As of Java 21
static void nullMatch2(Object obj) {
switch (obj) {
case String s -> System.out.println("String: " + s);
case Integer i -> System.out.println("Integer");
default -> System.out.println("default");
}
}

等价于:

// As of Java 21
static void nullMatch2(Object obj) {
switch (obj) {
case null -> throw new NullPointerException();
case String s -> System.out.println("String: " + s);
case Integer i -> System.out.println("Integer");
default -> System.out.println("default");
}
}

在这两个示例中,评估 nullMatch(null) 都将导致抛出 NullPointerException

我们保留了现有 switch 语句的直观理解,即对 null 进行切换是一种异常操作。在模式匹配的 switch 中的区别在于,你可以直接在 switch 内处理这种情况。如果你在 switch 块中看到一个 null 标签,那么该标签将匹配 null 值。如果你在 switch 块中没有看到 null 标签,那么对 null 值进行切换时将像以前一样抛出 NullPointerException。因此,switch 块中对 null 值的处理变得更加规范化。

想要将 null 情况与 default 结合起来是有意义的,而且这种情况并不少见。为此,我们允许 null 情况标签包含一个可选的 default;例如:

// As of Java 21
Object obj = ...
switch (obj) {
...
case null, default ->
System.out.println("The rest (including null)");
}

如果 obj 的值为 null 引用值,或者其它 case 标签都不匹配,那么 obj 的值与该标签匹配。

如果一个 switch 块同时包含带有 defaultnull case 标签和 default 标签,则会产生编译时错误。

错误

模式匹配可能会突然完成。例如,当根据记录模式匹配值时,记录的访问器方法可能会突然完成。在这种情况下,模式匹配被定义为通过抛出 MatchException 来突然完成。如果这样的模式作为 switch 中的标签出现,那么 switch 也会通过抛出 MatchException 而突然完成。

如果某个 case 模式包含一个保护条件(guard),并且在评估该保护条件时突然中止,则 switch 语句会因相同原因突然中止。

如果模式 switch 中没有任何标签与选择器表达式的值匹配,那么该 switch 会通过抛出一个 MatchException 异常而突然终止,因为模式匹配的 switch 必须是穷尽的。

例如:

// As of Java 21
record R(int i) {
public int i() { // bad (but legal) accessor method for i
return i / 0;
}
}

static void exampleAnR(R r) {
switch(r) {
case R(var i): System.out.println(i);
}
}

调用 exampleAnR(new R(42)) 会引发 MatchException 异常。(一个始终抛出异常的记录访问器方法是非常不规则的,而一个抛出 MatchException 的穷尽模式 switch 也是非常不寻常的。)

相比之下:

// As of Java 21
static void example(Object obj) {
switch (obj) {
case R r when (r.i / 0 == 1): System.out.println("It's an R!");
default: break;
}
}

调用 example(new R(42)) 会引发 ArithmeticException 异常。

为了与模式 switch 语义保持一致,当在运行时没有任何 switch 标签适用时,基于 enum 类的 switch 表达式现在会抛出 MatchException 而不是 IncompatibleClassChangeError。这是对语言的一处细微的不兼容更改。(只有在 switch 编译完成后修改了 enum 类的情况下,针对 enum 的穷尽 switch 才会匹配失败,这种情况非常少见。)

未来工作

  • 目前,模式 switch 不支持原始类型 booleanlongfloatdouble。允许这些原始类型也就意味着要在 instanceof 表达式中允许它们,并且要将原始类型模式与引用类型模式对齐,这需要相当多的额外工作。这个问题留待未来可能的 JEP(Java 改进提案)来解决。

  • 我们预计,在未来,普通类将能够声明解构模式以指定它们如何进行匹配。这样的解构模式可以与模式 switch 一起使用,从而生成非常简洁的代码。例如,如果我们有一个 Expr 的层次结构,其中包含 IntExpr(包含单个 int)、AddExprMulExpr(各包含两个 Expr),以及 NegExpr(包含单个 Expr)的子类型,那么我们可以一步匹配 Expr 并针对特定子类型进行操作:

    // 某个未来的 Java
    int eval(Expr n) {
    return switch (n) {
    case IntExpr(int i) -> i;
    case NegExpr(Expr n) -> -eval(n);
    case AddExpr(Expr left, Expr right) -> eval(left) + eval(right);
    case MulExpr(Expr left, Expr right) -> eval(left) * eval(right);
    default -> throw new IllegalStateException();
    };
    }

    如果没有这种模式匹配,表达像这样的临时多态计算就需要使用繁琐的访问者模式。模式匹配通常更加透明和直接。

  • 添加 AND 和 OR 模式也可能很有用,以便为带有模式的 case 标签提供更多表达能力。

替代方案

  • 与其支持模式 switch,我们也可以定义一个类型 switch,它仅支持根据选择器表达式的类型进行切换。此功能在规范和实现上更为简单,但表达能力却差得多。

  • 对于带保护的模式标签,还有许多其他的语法选项,例如 p where ep if e,甚至是 p &&& e

  • 带保护模式标签的另一种选择是直接将带保护的模式作为一种特殊模式形式来支持,例如 p && e。在早期预览中进行实验后,由于与布尔表达式产生的歧义,我们更倾向于使用带保护的 case 标签而不是带保护的模式。

依赖

本 JEP 基于 JDK 16 中提供的 instanceof 的模式匹配JEP 394),以及 Switch 表达式JEP 361)带来的增强功能。它与 记录模式JEP 440)共同演进。