跳转到内容

JEP 441:switch的模式匹配

原文:https://openjdk.org/jeps/441
翻译:张欢

使用switch表达式和语句的模式匹配增强Java编程语言。将模式匹配扩展到switch中,允许根据多种模式测试表达式,每种模式都有特定的操作,这样就可以简洁而安全地表达复杂的面向数据的查询。

历史

此特性最初由JEP 406(JDK 17)提出,随后由JEP 420(JDK 18)、427(JDK 19)和433(JDK 20)改进。它与记录模式特性(JEP 440)共同发展,两者有相当大的互动。本JEP提议根据持续的的经验和反馈,通过进一步的小幅改进来完成此特性。

除了各种编辑性的更改外,基于上一个JEP的主要变化是:

  • 删除带括号的模式,因为它们没有足够的价值,并且
  • 允许在switch表达式和语句中,使用合格的枚举常量作为case常量。

目标

  • 通过允许在case标签中出现模式,扩展switch表达式和语句的表现力和适用性。
  • 允许在需要时放宽switch的历史性null值限制。
  • 通过要求模式switch语句涵盖所有可能的输入值来提高switch语句的安全性。
  • 确保所有现有的switch表达式和语句继续编译,而不发生任何变化,并以相同的语义执行。

动机

在Java 16中,JEP 394扩展了instanceof运算符,使其能够采用类型模式并执行模式匹配。这种适度的扩展简化了熟悉的“instanceof-and-cast”用法,使其更加简洁且不易出错:

// Java 16之前
if (obj instanceof String) {
String s = (String) obj;
... use s ...
}
// 从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非常受限。我们只能对几种类型的值进行switch操作——整数基本类型(不包括long)、它们对应的包装类、枚举类型和String——并且我们只能测试与常量的完全相等性。我们可能希望使用模式针对多种可能性测试同一个变量,并对每种可能性采取特定的操作,但由于现有的switch不支持这一点,我们最终会得到一系列if...else测试,例如:

// 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,则我们有一个错误。(将formatted声明为空白的局部变量至少会在此工作中引入编译器的明确赋值分析,但开发人员并不总是编写此类声明。)此外,上述代码不可优化;如果没有编译器优化,它将具有O(n)O(n) 的时间复杂度,即使底层问题通常是O(1)O(1)

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

// 从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语句;switch块(包括case标签)将保持不变。)

由于我们使用了正确的控制结构,此代码的意图更加清晰:我们说,“参数obj最多匹配以下条件之一,找出并执行相应的分支。”另外,它更易于优化;在这种情况下,我们更有可能在O(1)O(1) 时间内执行调度。

switch与null

传统上,如果选择器表达式的计算结果为null,则switch语句和表达式将抛出NullPointerException,因此必须在switch之外进行null测试:

// 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测试感觉就像是一种刻意的区分,它会带来不必要的样板和出错机会。最好通过允许新的case null标签将null测试集成到switch中:

// 从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标签相比,模式case标签可以应用于许多值。这通常会导致switch规则右侧出现条件代码。例如,考虑以下代码:

// 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?");
}
}
}

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

通过这种方法,我们可以使用守护重写上述代码:

// 从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规则的右侧。

我们可以使用针对其他已知常量字符串的额外规则进一步增强此示例:

// 从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规则的右侧。

switch与枚举常量

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

// 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");
}
}

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

// 从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常量使用枚举常量的限定名称。这允许将上述代码重写为:

// 从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");
}
}
}

现在,我们每个枚举常量都有一个直接的实例,而无需使用守护的类型模式,这些模式以前只是为了解决类型系统的当前约束。

描述

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

  • 改进枚举常量case标签,
  • 扩展case标签以包含常量之外的模式和null
  • 扩大switch语句和switch表达式的选择器表达式允许的类型范围(以及对switch块详尽性进行必要的、更丰富的分析),以及
  • 允许可选的when子句跟在case标签之后。

改进的枚举常量与case标签

长期以来的一项要求是,在switch处理枚举类型时,唯一有效的case常量是枚举常量。但这是严格的要求,随着新的、更丰富的switch形式的出现,它变得繁重起来。

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

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

其次,当枚举常量之一的名称用作case常量时,我​​们不再要求选择器表达式为枚举类型。在这种情况下,我们要求名称符合条件,并且其值与选择器表达式的类型兼容。(这使枚举case常量与数值case常量的处理保持一致。)

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

// 从Java 21开始
sealed interface Currency permits Coin {}
enum Coin implements Currency { HEADS, TAILS }
static void goodEnumSwitch1(Currency c) {
switch (c) {
case Coin.HEADS -> { // 枚举常量的限定名称作为标签
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 -> { // 不必要的限定,但是允许
System.out.println("Tails");
}
}
}

以下示例是不允许的:

// 从Java 21开始
static void badEnumSwitch(Currency c) {
switch (c) {
case Coin.HEADS -> {
System.out.println("Heads");
}
case TAILS -> { // 错误 - TAILS必须是限定的
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 p标签,其中p是一个模式。switch的本质保持不变:将选择器表达式的值与switch标签进行比较,选择其中一个标签,然后执行或运算与该标签关联的代码。现在的区别在于,对于带有模式的case标签,所选标签由模式匹配的结果而不是相等性测试决定。例如,在下面的代码中,obj的值与模式Long l匹配,并运算与标签case Long l关联的表达式:

// 从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();
};
}

在模式匹配成功后,我们经常会进一步测试匹配的结果。这可能会导致繁琐的代码,例如:

// 从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标签中:

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

如果objString并且长度为1,则第一个子句匹配。如果obj是任意长度的String,则第二个子句匹配。

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

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

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

增强的类型检查

选择器表达式的类型

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

例如,在下面的switch模式中,选择器表达式obj与涉及classenumrecord和数组的类型模式以及case null标签和default进行匹配:

// 从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标签case String scase CharSequence cs都将适用。

要解决的第一个问题是,确定在这种情况下应该应用哪个标签。我们不会尝试复杂的最佳匹配方法,而是采用更简单的语义:选择switch块中出现的第一个适用于值的case标签。

// 从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类型,那么将应用第二个模式标签。

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

// 从Java 21开始
static void error(Object obj) {
switch (obj) {
case CharSequence cs ->
System.out.println("A sequence of length " + cs.length());
case String s -> // 错误 - 该模式被前一个模式所涵盖
System.out.println("A string: " + s);
default -> {
break;
}
}
}

现在,如果obj的值是String类型,则case CharSequence标签适用,因为它首先出现在switch块中。case String标签是无法访问的,因为没有选择器表达式的值会导致它被选中。与无法访问的代码类似,这被视为编程错误并导致编译时报错。

更确切地,我们说第一个标签case CharSequence cs涵盖了第二个标签case String s,因为与模式String s匹配的每个值也与模式CharSequence cs匹配,但反之则不然。这是因为第二个模式的类型String是第一个模式的类型CharSequence的子类型。

在相同的模式中,非守护模式的标签涵盖守护模式的标签。例如,(非守护)模式的标签case String s涵盖了守护模式的标签case String s when s.length > 0,因为与标签case String s when s.length > 0匹配的每个值都一定与标签case String s匹配。

对于一个守护模式标签和另一个(守护或非守护)模式标签,当且仅当前者的模式涵盖后者的模式,并且前者的守护是一个取值为true的常量表达式时,前者才会涵盖后者。例如,守护模式标签case String s when true涵盖了模式标签case String s。我们不再进一步分析守护表达式,以便更准确地确定哪些值与模式标签匹配——这个问题通常是无法判定的。

模式标签可以涵盖常量标签。例如,当A是枚举类型E的成员时,模式标签case Integer i涵盖了常量标签case 42,而当A是枚举类型E的成员时,模式标签case E e涵盖了常量标签case A。如果非守护的相同模式标签涵盖常量标签,那么守护的模式标签也将涵盖常量标签。换句话说,我们不检查守护与否,因为这通常是不可判定的。例如,模式标签case String s when s.length() > 1涵盖了常量标签case "hello",正如预期的那样;但是case Integer i when i != 0涵盖了标签case 0

所有这些都表明了标签的简单、可预测和可读的顺序,其中常量标签应该出现在守护模式标签之前,而守护模式标签应该出现在非守护模式标签之前:

// 从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标签被该switch块中的任何前一个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无条件匹配任何引用类型的选择器表达式:

// 从Java 21开始
static void matchAll(String s) {
switch(s) {
case String t:
System.out.println(t);
break;
default:
System.out.println("Something else"); // 错误 - 被涵盖!
}
}
static void matchAll2(String s) {
switch(s) {
case Object o:
System.out.println("An Object");
break;
default:
System.out.println("Something else"); // 错误 - 被涵盖!
}
}

switch表达式和语句的详尽性

类型覆盖

switch表达式要求在switch块中处理选择器表达式的所有可能值;换句话说,它必须是详尽的。这保持了switch表达式只要成功,就始终会产生结果值的属性。

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

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

考虑这个(错误的)模式切换表达式:

// 从Java 21开始
static int coverage(Object obj) {
return switch (obj) { // 错误 - 没有详尽
case String s -> s.length();
};
}

switch块只有一个switch标签,case String s。它匹配类型是String子类型的obj的任何值。因此,我们说这个switch标签的类型覆盖是String的每个子类型。这个模式switch表达式并不详尽,因为其switch块的类型覆盖(String的所有子类型)不包括选择器表达式的类型(Object)。

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

// 从Java 21开始
static int coverage(Object obj) {
return switch (obj) { // 错误 - 仍然没有详尽
case String s -> s.length();
case Integer i -> i;
};
}

switch块的类型覆盖是其两个switch标签的覆盖率的并集。换句话说,类型覆盖是String的所有子类型的集合,以及Integer的所有子类型的集合。但是,类型覆盖仍然不包括选择器表达式的类型,因此该模式switch表达式也不详尽,并会导致编译时错误。

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

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

实践中的详尽性

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

// 从Java 20开始
enum Color { RED, YELLOW, GREEN }
int numLetters = switch (color) { // 错误 - 没有详尽!
case RED -> 3;
case GREEN -> 5;
}

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

// 从Java 20开始
int numLetters = switch (color) { // 详尽!
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子句的需求,如上所述,这是一种很好的做法。考虑以下密封接口S的示例,该接口具有三个允许的子类ABC

// 从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 {} // 隐式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标签。

当允许的直接子类仅实现(泛型)密封超类的特定参数化时,需要格外小心。例如:

// 从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) {
// 详尽,因为不可能是A!
case B<Integer> bi -> 42;
};
}

I仅有的允许子类是AB,但是编译器可以检测到switch块只需要覆盖类B就可以详尽,因为选择器表达式的类型是I<Integer>,并且A的参数化都不可能是I<Integer>的子类型。(译者注:A的参数化只能是I<String>的子类型。)

再次强调,详尽性的概念只是一种近似。由于是分开编译的,接口I的新实现可能会在运行时出现,因此编译器将在这种情况下插入一个会抛出异常的合成default子句。

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

详尽性和兼容性

详尽性要求适用于模式switch表达式和模式switch语句。为确保向后兼容性,所有现有switch语句都将按原样编译。但如果switch语句使用本JEP中描述的任何switch增强特性,则编译器将检查它是否详尽。(Java语言的未来编译器可能会对不详尽的旧版switch语句发出警告。)

更确切地说,任何使用模式或null标签或其选择器表达式不是传统类型(charbyteshortintCharacterByteShortIntegerString或枚举类型)的switch语句都需要做到详尽。例如:

// 从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 {} // 隐式final
static void switchStatementExhaustive(S s) {
switch (s) { // 错误 - 不详尽
// 丢失了允许的class B的子句
case A a :
System.out.println("A");
break;
case C c :
System.out.println("C");
break;
};
}

模式变量声明的作用域

模式变量JEP 394)是由模式声明的局部变量。模式变量声明的不同寻常之处在于其作用域是流程敏感的。回顾以下示例,其中类型模式String s声明了模式变量s

// 从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 标签。

该示例显示了第1条规则的实际应用:

// 从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

这个变体展示了第2条规则的实际作用:

// 从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语句。

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

// 从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语句组的语句,尽管第一个语句组的执行可以“贯穿”(fall-through)到default标签并执行这些语句。

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

// 从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: // 编译错误
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

另一方面,贯穿未声明模式变量的标签是安全的,如下例所示:

// 从Java 21开始
void testScope4(Object obj) {
switch (obj) {
case String s:
System.out.println("A string: " + s); // s在作用域中
default:
System.out.println("Done"); // s不在作用域中
}
}

处理null

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

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

  • 如果选择器表达式的计算结果为null,则任何case null标签都被视为匹配。如果switch块没有关联的标签,则switch会像以前一样抛出NullPointerException
  • 如果选择器表达式的计算结果为非null值,则我们像往常一样选择匹配的case标签。如果没有case标签匹配,则任何default标签都被视为匹配。

例如,给定下面的声明,执行nullMatch(null)将打印null!而不是抛出NullPointerException

// 从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块将被视为具有case null规则,该规则的主体会抛出NullPointerException。换句话说,此代码:

// 从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");
}
}

等效于:

// 从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块中看到case null标签,则该标签将与null值匹配。如果你在switch块中没有看到case null标签,则处理null值将像以前一样抛出NullPointerException。因此,switch块中对null值的处理得到了规范化。

case nulldefault相结合是有意义的,而且并不罕见。为此,我们允许case null标签具有可选的default;例如:

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

如果obj的值是null引用值,或者其他case标签均不匹配,则obj的值与此标签匹配。

如果switch块同时具有case null, default标签和另一个default标签,那么会导致编译时错误。

错误

模式匹配可能会中断完成。例如,当将值与记录模式进行匹配时,记录的访问器方法可能会中断完成。在这种情况下,模式匹配被定义为通过抛出MatchException来中断完成。如果此类模式作为标签出现在switch中,则switch也会通过抛出MatchException而中断完成。

如果case模式有一个守护,并且对守护的评估中断完成,则switch也会因相同的原因中断完成。

如果模式switch中没有标签与选择器表达式的值匹配,则switch会通过抛出MatchException中断完成,因为模式switch必须是详尽的。

例如:

// 从Java 21开始
record R(int i) {
public int i() { // 不好的访问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也是极不寻常的。)

相比之下:

// 从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标签时,枚举类上的switch表达式现在会抛出MatchException而不是IncompatibleClassChangeError。这是对语言的一个小的不兼容更改。(只有在编译switch后更改枚举类时,枚举上的详尽switch才会匹配失败,这种情况非常罕见。)

未来的工作

  • 目前,模式切换不支持基本类型booleanlongfloatdouble。允许这些基本类型也意味着允许它们出现在instanceof表达式中,并将基本类型模式与引用类型模式对齐,这将需要大量额外工作。这留给未来可能的JEP来完成。
  • 我们期望,将来,泛型​​类将能够声明解构模式来指定如何匹配它们。此类解构模式可以与模式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,而不是支持模式switch。此特性更易于指定和实现,但表达能力却差得多。
  • 守护模式标签有许多其他语法选项,例如p where ep if e,甚至p &&& e
  • 守护模式标签的替代方法是直接将守护模式作为特殊模式形式来支持,例如p && e。在早期预览版中尝试过这种方法后,布尔表达式产生的歧义导致我们更喜欢守护case标签而不是守护模式。

依赖

本JEP以JDK 16中提供的*instanceof模式匹配*(JEP 394) 以及Switch表达式JEP 361)提供的增强特性为基础。它与记录模式JEP 440)共同发展。