JEP 441:switch 模式匹配
总结
增强 Java 编程语言,为 switch
表达式和语句添加模式匹配功能。将模式匹配扩展到 switch
可以使一个表达式与多个模式进行匹配,每个模式都有特定的操作,从而能够简洁且安全地表达以数据为导向的复杂查询。
历史
除了各种编辑性修改之外,与之前的 JEP 相比,主要的变化在于:
-
移除带括号的模式,因为它们的价值不够,并且
-
允许在
switch
表达式和语句中将限定的枚举常量作为case
常量。
目标
-
通过允许在
case
标签中出现模式,扩展switch
表达式和语句的表现力与适用性。 -
在需要时,允许放宽
switch
历史上对空值的严格限制。 -
通过要求模式
switch
语句覆盖所有可能的输入值,提高switch
语句的安全性。 -
确保所有现有的
switch
表达式和语句无需更改即可继续编译,并以相同的语义执行。
动机
在 Java 16 中,JEP 394 对 instanceof
运算符进行了扩展,使其能够接受一个 类型模式 并执行 模式匹配。这一适度的扩展简化了常见的 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) 的时间复杂度执行分派。
开关与空值
传统上,如果选择器表达式计算结果为 null
,switch
语句和表达式会抛出 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 null
,switch
将执行与该标签关联的代码;如果没有 case null
,switch
将抛出 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
)、对应的基本类型的包装类(即 Character
、Byte
、Short
或 Integer
)、String
或枚举类型 (enum
)。我们对此进行了扩展,现在要求选择器表达式的类型可以是基本整数类型(不包括 long
)或任何引用类型。
例如,在下面的模式 switch
中,选择器表达式 obj
会与涉及类类型、enum
类型、记录类型和数组类型的类型模式进行匹配,同时还有一个 null
的 case
标签和一个 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 s
和 case 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
类型,那么 CharSequence
的 case
标签将适用,因为它在 switch
块中首先出现。从某种意义上说,String
的 case
标签是无法到达的,因为没有任何选择器表达式的值会导致它被选中。类比于不可达代码,这被视为程序员的错误,并导致编译时错误。
更准确地说,我们说第一个 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
子句的条件要求,即如果一个捕获异常类 E
的 catch
子句前面存在另一个能够捕获 E
或 E
的超类的 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
子句的情况下可以更好地检查穷尽性。(对于任何其他匹配所有情况的子句也是如此,例如 default
、case null, default
或无条件类型模式。)如果我们省略 default
子句,那么如果忘记了一个 case
标签,我们将在编译时发现,而不是在运行时才发现——甚至可能到那时都发现不了。
更重要的是,如果有人后来在 Color
枚举中添加了另一个常量会发生什么?如果我们有一个显式的全匹配子句,那么只有在运行时出现新的常量值时我们才会发现它。但是,如果我们编写 switch
语句来覆盖编译时已知的所有常量,并省略全匹配子句,那么当下次我们重新编译包含该 switch
的类时,我们就会发现这一变化。全匹配子句可能会掩盖穷尽性错误。
总之:在可能的情况下,没有匹配全部子句的详尽 switch
语句比有匹配全部子句的详尽 switch
语句更好。
在考虑运行时的情况下,如果添加了一个新的 Color
常量,并且包含 switch
的类没有重新编译,会发生什么?存在这样一种风险:新的常量可能会暴露给我们的 switch
。因为这种风险在枚举类型中始终存在,如果一个详尽的枚举 switch
没有匹配全部的子句,那么编译器将合成一个抛出异常的 default
子句。这保证了 switch
无法在不选择其中一个子句的情况下正常完成。
穷尽性的概念旨在在覆盖所有合理情况之间取得平衡,同时又不会迫使你编写可能许多罕见的极端情况,这些情况对实际价值贡献很小,但却会污染甚至主导你的代码。换句话说:穷尽性是真实运行时穷尽性的一种编译时近似。
完备性与密封类
如果选择器表达式的类型是一个密封类(JEP 409),那么类型覆盖检查可以考虑密封类的 permits
子句,以确定 switch
块是否详尽无遗。这有时可以消除对 default
子句的需求,正如上文所述,这是一个良好的实践。请看以下示例,其中有一个 sealed
接口 S
,它允许三个子类 A
、B
和 C
:
// 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
块的类型覆盖范围为类型 A
、B
和 C
。由于选择器表达式的类型 S
是一个密封接口,其允许的子类正好是 A
、B
和 C
,因此这个 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
唯一允许的子类是 A
和 B
,但编译器可以检测到,为了覆盖所有情况,switch 块只需要处理类 B
,因为选择器表达式的类型是 I<Integer>
,而 A
的任何参数化都不是 I<Integer>
的子类型。
同样,详尽性的概念是一种近似。由于独立编译的原因,接口 I
的新实现有可能在运行时出现,因此在这种情况下,编译器会插入一个合成的 default
子句来抛出异常。
由于记录模式(JEP 440)可以嵌套,这使得穷尽性的概念变得更加复杂。因此,穷尽性的概念必须反映这种潜在的递归结构。
完备性与兼容性
穷尽性要求适用于模式 switch
表达式和模式 switch
语句。为了确保向后兼容,所有现有的 switch
语句都将保持不变地编译。但如果某个 switch
语句使用了本 JEP 中描述的任何 switch
增强功能,那么编译器将检查其是否具有穷尽性。(Java 语言的未来编译器可能会对非穷尽性的旧版 switch
语句发出警告。)
更准确地说,任何使用模式或 null
标签的 switch
语句,或者其选择表达式不是旧类型(char
、byte
、short
、int
、Character
、Byte
、Short
、Integer
、String
或 enum
类型)之一时,都要求具备完备性。例如:
// 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
标签中出现的模式声明,并通过三条新规则来实现:
-
在受保护的
case
标签模式中出现的模式变量声明的作用域包括保护条件,即when
表达式。 -
在
switch
规则的case
标签中出现的模式变量声明的作用域包括出现在箭头右侧的表达式、代码块或throw
语句。 -
在
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
标签,那么在冒号或箭头之后,c
和 i
都会处于作用域中,但根据 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
传统上,如果选择器表达式的结果为 null
,switch
会抛出 NullPointerException
。这种行为是广为人知的,我们并不建议对任何现有的 switch
代码进行更改。然而,对于模式匹配和 null
值,存在合理且不会引发异常的语义,因此在模式匹配的 switch
块中,我们可以在保持与现有 switch
语义兼容的同时,以更常规的方式处理 null
。
首先,我们引入了一个新的 null
情况标签。然后,我们取消了这样一条通用规则:即如果选择器表达式的值为 null
,switch
语句会立即抛出 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 块会被视为包含一个抛出 NullPointerException
的 case 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
块同时包含带有 default
的 null
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
不支持原始类型boolean
、long
、float
和double
。允许这些原始类型也就意味着要在instanceof
表达式中允许它们,并且要将原始类型模式与引用类型模式对齐,这需要相当多的额外工作。这个问题留待未来可能的 JEP(Java 改进提案)来解决。 -
我们预计,在未来,普通类将能够声明解构模式以指定它们如何进行匹配。这样的解构模式可以与模式
switch
一起使用,从而生成非常简洁的代码。例如,如果我们有一个Expr
的层次结构,其中包含IntExpr
(包含单个int
)、AddExpr
和MulExpr
(各包含两个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 e
、p if e
,甚至是p &&& e
。 -
带保护模式标签的另一种选择是直接将带保护的模式作为一种特殊模式形式来支持,例如
p && e
。在早期预览中进行实验后,由于与布尔表达式产生的歧义,我们更倾向于使用带保护的case
标签而不是带保护的模式。