跳转到内容

JEP 361:Switch 表达式(标准)

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

扩展switch以使其既可以用作语句,也可以用作表达式,这样可以使用两种形式:传统的case ... :标签(带有fall-through“贯穿”)或者新的case ... ->标签(不带有贯穿),带有另一个用于从switch表达式产生值的新语句。这些变动会简化日常编码,并为switch中的模式匹配铺平道路。这在JDK 12JDK 13中是一个预览语言特性

历史

JEP 3252017年12月提出了switch表达式。JEP 325在2018年8月作为JDK 12预览特性。JEP 325的一个方面是重载break语句以从switch表达式中返回结果。JDK 12的反馈表明,这样使用break会造成混淆。作为对反馈的回应,JEP 354作为JEP 325的演进而被创建。JEP 354提出了一个新的语句,yield,并恢复了break的原始含义。JEP 354在2019年6月作为JDK 13的进一步预览特性。JDK 13的反馈表明,该特性现在可以在JDK 14中最终确定并永久化。

动机

当我们准备增强Java编程语言以支持模式匹配(JEP 305)时,现有switch语句的一些不规则性(长期以来一直困扰着用户)成为了障碍。这些包括switch标签之间的默认控制流行为(贯穿),switch块中的默认作用域(将整个块视为一个作用域),以及switch仅作为语句工作的事实,即使它通常更自然地表示为多路条件表达式。

Java中switch语句的当前设计紧密遵循C和C++等语言,默认支持贯穿语义。尽管这种传统的控制流对于编写低级代码(如用于二进制编码的解析器)很有用,但由于switch被用于更高级的上下文,其易错性超过了灵活性。例如,在下面的代码中,许多break语句使其不必要地冗长,且这种视觉噪声通常掩盖了难以调试的错误,遗漏的break语句将意味着意外的贯穿。

switch (day) {
case MONDAY:
case FRIDAY:
case SUNDAY:
System.out.println(6);
break;
case TUESDAY:
System.out.println(7);
break;
case THURSDAY:
case SATURDAY:
System.out.println(8);
break;
case WEDNESDAY:
System.out.println(9);
break;
}

我们建议引入一种新形式的switch标签,“case L ->”,以表示如果匹配标签,则只执行标签右边的代码。我们还建议在每种情况下使用逗号分隔多个常量。之前的代码现在可以写成:

switch (day) {
case MONDAY, FRIDAY, SUNDAY -> System.out.println(6);
case TUESDAY -> System.out.println(7);
case THURSDAY, SATURDAY -> System.out.println(8);
case WEDNESDAY -> System.out.println(9);
}

在switch标签”case L ->”右侧的代码被限制为表达式、代码块或(为方便起见)throw语句。这有令人愉快的结果,如果一个分支引入局部变量,那它必须包含在一个代码块中,这样它就不属于switch其他分支的代码块。这消除了传统switch代码块的另一个麻烦,其中局部变量的作用域是整个代码块:

switch (day) {
case MONDAY:
case TUESDAY:
int temp = ... // 'temp' 的作用域直到 }
break;
case WEDNESDAY:
case THURSDAY:
int temp2 = ... // 不能称该变量为 'temp'
break;
default:
int temp3 = ... // 不能称该变量为 'temp'
}

许多现有的switch语句其实是对switch表达式的模拟,其中每个分支要么给一个共同的目标变量赋值,要么返回一个值:

int numLetters;
switch (day) {
case MONDAY:
case FRIDAY:
case SUNDAY:
numLetters = 6;
break;
case TUESDAY:
numLetters = 7;
break;
case THURSDAY:
case SATURDAY:
numLetters = 8;
break;
case WEDNESDAY:
numLetters = 9;
break;
default:
throw new IllegalStateException("Wat: " + day);
}

用语句来表达这些是迂回、重复且易错的。作者本意是我们应该为每一天计算一个numLetters的值。应该直接使用switch表达式说出来,这样既更清晰又更安全:

int numLetters = switch (day) {
case MONDAY, FRIDAY, SUNDAY -> 6;
case TUESDAY -> 7;
case THURSDAY, SATURDAY -> 8;
case WEDNESDAY -> 9;
};

反过来,扩展switch以支持表达式会带来一些额外需求,例如扩展流程分析(表达式必须始终计算一个值或意外中断),并允许某些switch表达式的case分支抛出一个异常而不是产生一个值。

描述

箭头标签

除了switch代码块中传统的”case L :”标签外,我们还定义了一种新的简化形式,“case L ->”标签。如果标签匹配,则仅执行箭头右侧的表达式或语句;没有贯穿。例如,给定以下使用新标签形式的switch语句:

static void howMany(int k) {
switch (k) {
case 1 -> System.out.println("one");
case 2 -> System.out.println("two");
default -> System.out.println("many");
}
}

这些代码:

howMany(1);
howMany(2);
howMany(3);

会输出以下结果:

one
two
many

switch表达式

我们扩展switch语句以使其可以用作表达式。例如,之前的howMany方法可以用switch表达式重写,只用一个println

static void howMany(int k) {
System.out.println(
switch (k) {
case 1 -> "one";
case 2 -> "two";
default -> "many";
}
);
}

通常情况下,switch表达式如下所示:

T result = switch (arg) {
case L1 -> e1;
case L2 -> e2;
default -> e3;
};

switch表达式是聚合表达式;如果已知目标类型,则该类型会压入每个分支。switch表达式的类型即其自身目标类型(如果已知);如果未知,则通过组合每个case分支的类型来计算一个独立类型。

产生值

大多数switch表达式在”case L ->”标签的右侧都有一个表达式。以防需要一个完整代码块,我们引入一个新的yield语句来产生一个值,该值成为闭包switch表达式的值。

int j = switch (day) {
case MONDAY -> 0;
case TUESDAY -> 1;
default -> {
int k = day.toString().length();
int result = f(k);
yield result;
}
};

switch语句一样,switch表达式也可以使用带有”case L:”标签的传统switch代码块(暗示着贯穿语义)。在这种情况下,使用新的yield语句产生值:

int result = switch (s) {
case "Foo":
yield 1;
case "Bar":
yield 2;
default:
System.out.println("Neither Foo nor Bar, hmmm...");
yield 0;
};

两种语句,break(带有或不带有标签)和yield,有助于在switch语句和switch表达式之间轻松消除歧义:break语句的目标只能是switch语句,而不能是switch表达式;而yield语句的目标只能是switch表达式,而不能是switch语句。

yield不是一个关键字,而是一个受限的标识符(就像var),这意味着命名为yield的类是非法的。如果作用域内有一元方法yield,那么表达式yield(x)会有歧义(既可以是一个方法调用,又可以是一个操作数为括号表达式的yield语句),解决这种歧义将有利于yield语句。如果首选方法调用,则应该使用this限定实例方法,或使用类名称限定静态方法。

详尽性

switch表达式的case必须详尽;对于所有可能的值,必须有一个匹配的switch标签。(显然switch语句并不要求详尽。)

在实践中,这通常意味着需要一个default子句;然而,对于涵盖所有已知常量的enum switch表达式,default子句会由编译器插入,用来表明enum定义已在编译器和运行期之间改变。依靠插入这种隐式的default子句可以让代码更健壮。现在,当代码重新编译时,编译器会检查所有的case是否得到显式处理。如果开发人员插入了显式的default子句(如今天的情况),则可能的错误将被隐藏。

此外,switch必须以一个值正常完成,或以抛出异常意外中断。这有许多后果。首先,编译器会为每个switch标签进行检查,如果匹配,则会产生一个值。

int i = switch (day) {
case MONDAY -> {
System.out.println("Monday");
// 错误!代码块未包含 yield 语句
}
default -> 1;
};
i = switch (day) {
case MONDAY, TUESDAY, WEDNESDAY:
yield 0;
default:
System.out.println("Second half of the week");
// 错误!组未包含 yield 语句
};

进一步的后果是,控制语句(breakyieldreturncontinue)不能跳转switch表达式,如下所示:

z:
for (int i = 0; i < MAX_VALUE; ++i) {
int k = switch (e) {
case 0:
yield 1;
case 1:
yield 2;
default:
continue z;
// 错误!非法跳转 switch 表达式
};
...
}

依赖

这个JEP演进自JEP 325JEP 354。然而,这个JEP是独立的,并不依赖那两个JEP。

未来对模式匹配的支持,将以JEP 305作为开始,基于这个JEP建立。

风险与假设

有时不清楚是否需要使用带有case L ->标签的switch语句。以下几点考虑支持将其包含在内:

  • 有的switch语句用到了副作用,但通常仍然是“每个标签一个动作”。通过使用新型标签将这些内容折叠起来,可以让语句更直接、更不易出错。
  • 在Java的早期,不幸的选择是switch语句块中的默认控制流是贯穿,而不是跳出,这对开发者来说仍是一个巨大的忧虑。为通常的switch构造解决该问题(而不是只为switch表达式)可以减少该选择的影响。
  • 将预期的收益(表达式、更好的控制流、清晰的作用域)发挥到正交特性中,switch表达式和switch语句可以有更多共同点。switch表达式和switch语句之间的差异越大,语言学习就越复杂,开发者利用它们获得的优势就越大。