Java switch 结构演进:从语句到模式匹配
来源: InfoQ - 后端
在2025年9月发布的TIOBE榜单上,Java位居第四,排在Python、C++和C之后。作为一门步入而立之年的语言,Java一直没有停下现代化改造的脚步,switch结构的演进就是一例。
在Java 8之前,switch结构只有switch语句,只能使用冒号语法(:),相对来说并不复杂。但是从Java 14正式引入switch表达式和箭头语法(->)、Java 21正式引入模式匹配之后,switch结构的复杂性飙升,可能令人感到无所适从。
如果细分的话,那么目前存在8种形式的switch结构:
switch语句(无模式匹配+冒号语法)switch语句(无模式匹配+箭头语法)switch语句(模式匹配+冒号语法)switch语句(模式匹配+箭头语法)switch表达式(无模式匹配+冒号语法)switch表达式(无模式匹配+箭头语法)switch表达式(模式匹配+冒号语法)switch表达式(模式匹配+箭头语法)
本文以Java 21(LTS版本)为基础,主要从语法层面系统梳理一下switch结构,为开发者做个参考。
注意,文中提到的“switch结构”是switch语句和switch表达式的统称,讨论过程中会视情况区分二者。
前奏:代码不够简洁,意图不够清晰
对于传统的switch结构(指不采用模式匹配、使用冒号语法的switch语句),想必开发者不会感到陌生:
switch (day) {
case MONDAY :
case FRIDAY :
case SUNDAY :
System.out.println("We have a class today");
break;
case TUESDAY :
System.out.println("Office hours");
break;
default :
System.out.println("No schedule");
}
这样的switch结构显然称不上完美:
由于没有返回值,因此不能作为表达式使用,也不能赋给变量;由于冒号语法默认具有穿透性,因此忘记使用break语句跳出可能导致逻辑错误,从而影响安全性;由于选择器表达式支持的类型有限,因此case标签只能匹配简单的常量值;由于不支持空值检查,因此判断选择器表达式是否为null的操作只能放在switch结构外部进行。
此外,传统的switch结构无法实现类型检查和转换,只能借助烦琐的instanceof和强制类型转换:
public void demo(Object obj) {
if (obj == null) {
System.out.println("null");
} else if (obj instanceof Integer) {
Integer i = (Integer) obj;
System.out.println(i * 2);
} else if (obj instanceof Double) {
Double d = (Double) obj;
System.out.println(d * d);
} else if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
} else {
System.out.println(obj);
}
}
正是因为代码不够简洁,意图也不够清晰,所以Java社区一直存在改进switch结构的呼声。
插曲:怎样区分switch语句和switch表达式
许多人经常会问:对于一个switch结构,如何判断它是switch语句还是switch表达式?
先说结论:观察是否有返回值。
这是因为switch语句属于控制流程结构,本身不会产生值;而switch表达式属于求值结构,始终会返回值。
有返回值意味着switch表达式不仅可以赋给变量,而且可以用作方法返回值或方法参数。相反,由于switch语句没有返回值,因此无法赋给变量,也不能作为方法的返回值或方法参数。
当然,switch语句内部可以使用return语句,只不过它的作用是结束方法并返回结果,而不是指switch语句本身会返回值。
如下所示,这段代码包括一个switch语句和三个switch表达式。
public class Demo {
public static void main(String[] args) {
int value = 2;
// switch语句(箭头语法)
switch (value) {
case 1 -> System.out.println("One");
case 2 -> System.out.println("Two");
}
// switch表达式:赋给变量(箭头语法)
String result = switch (value) {
case 1 -> "One";
case 2 -> "Two";
default -> "Other";
};
// switch表达式:作为方法参数(冒号语法)
System.out.println(switch (value) {
case 1 : yield "One";
case 2 : yield "Two";
default : yield "Other";
});
}
// switch表达式:作为方法返回值(箭头语法)
public String getValue(int value) {
return switch (value) {
case 1 -> "One";
case 2 -> "Two";
default -> "Other";
};
}
}
可以看到,switch语句没有使用default分支,而三个switch表达式都使用了default分支。这是因为switch语句不必强制满足穷尽性要求,switch表达式则必须满足穷尽性要求,后面会讨论穷尽性。
此外,switch语句、赋给变量的switch表达式、作为方法返回值的switch表达式使用了箭头语法,而作为方法参数的switch表达式使用了冒号语法,后面会讨论这两种语法。
一言以蔽之:是否有返回值是区分switch语句和switch表达式的根本标准。
选择器表达式
选择器表达式决定了switch结构的匹配依据,它支持的类型在引入模式匹配前后大相径庭。
引入模式匹配之前
在Java诞生之初,选择器表达式只支持char、byte、short、int这4种基本数据类型及其包装类Character、Byte、Short、Integer。随着Java的发展,Java 5引入枚举类型,Java 7开始支持String。但是很长一段时间以来,无法使用long、float、double、boolean及其包装类Long、Float、Double、Boolean作为选择器表达式的类型。
为了区分,我们把char/Character、byte/Byte、short/Short、int/Integer、枚举类型、String称为传统类型。
引入模式匹配之后
在Java 21正式引入模式匹配后,选择器表达式支持的类型大幅扩展,将所有引用类型纳入其中。例如,Object类、自定义类、数组、接口都属于引用类型,目前都能用于选择器表达式。
由于包装类也属于引用类型,因此现在也可以使用之前无法使用的Long、Float、Double、Boolean作为选择器表达式的类型,不过仍然不能使用long、float、double、boolean。
举例来说,在以下switch语句中,选择器表达式的类型是自定义类Employee。
Employee emp = // Some value
switch (emp) {
case Manager m -> // Some code
case Engineer eng -> // Some code
default -> // Some code
}
当然,如果switch结构不采用模式匹配,那么选择器表达式的类型依然只限于传统类型。
虽然使用单一变量作为选择器表达式的情况最常见,但是也可以使用任何合法的表达式,例如以下选择器表达式使用的就是b + 1:
byte b = 10;
switch (b + 1) {
case 1000 :
System.out.println("1000");
}
因此,在检查case标签的合法性时,需要考虑数值提升、类型转换、运算符优先级等各种因素。
选择器表达式不支持long、float、double、boolean有历史和技术实现方面的考虑,但是Java的设计者一直在探索放宽限制的可能性。Java 23、Java 24、Java 25建议将选择器表达式的类型扩大到全部8种基本数据类型,并作为预览特性发布。如果今后能成为正式特性,那么选择器表达式的类型限制将完全放开。
case标签
根据所匹配的内容,可以把case标签分为常量case标签和模式case标签:常量case标签匹配的是常量值,而模式case标签匹配的是模式。
常量case标签
首先要记住,常量case标签必须使用编译时常量表达式。也就是说,常量case标签只能匹配字面量、枚举常量、常量变量(声明为final而且值在编译阶段就能确定的变量),或是由它们构成的表达式。
下面几个case标签都属于常量case标签:
case 1000(匹配字面量1000)enum Color { RED, GREEN, BLUE }、case BLUE(匹配枚举常量BLUE)final String GREETING = "Hello";、case GREETING(匹配final变量GREETING)
我们来看以下代码。为什么case getCookies()和case cookies无法编译呢?
final int getCookies() { return 4; }
void feedAnimals() {
final int bananas = 1;
final int apples = 2;
int numberOfAnimals = 3;
final int cookies = getCookies();
switch (numberOfAnimals) {
case bananas:
case apples:
case getCookies(): // 无法编译
case cookies: // 无法编译
case 6 * 9:
}
}
这是因为getCookies()方法的结果在运行阶段才能确定,因此不是编译时常量表达式,不能用于常量case标签;cookies变量虽然声明为final,但是它的值取决于getCookies()方法的结果,因此同样不是编译时常量表达式,同样不能用于常量case标签。
又如,case args[1]和case "abc".toUpperCase()标签都无法编译,原因在于args[1]
和"abc".toUpperCase()都不是编译时常量表达式。
public static void main(String[] args) {
String result = switch (args[0]) {
case "1", "2" -> "1 or 2";
case "1" + "2" -> "1 and 2";
case args[1] -> "Some args"; // 无法编译
case "abc".toUpperCase() -> "ABC"; // 无法编译
default -> "Others";
};
}
虽然常量case标签可以匹配枚举常量,但是长期以来只能使用枚举常量名。Java 21放宽了限制,除枚举常量名之外,还可以使用类型限定的枚举常量名。
举例来说,在Java 21之前,只能使用case JUNE这样的形式;而从Java 21开始,case JUNE和case Month.JUNE都是合法的语法。
这种变化有助于提高清晰度。如下所示,goodEnumSwitch()方法传入枚举类型Coin,选择器表达式c是枚举类型,因此可以直接使用枚举常量名TAILS。而badEnumSwitch()方法传入接口类型Currency,选择器表达式c是接口类型,因此不能直接使用枚举常量名TAILS,必须加上枚举类型Coin.TAILS才能通过编译。
sealed interface Currency permits Coin {}
enum Coin implements Currency { HEADS, TAILS }
static void goodEnumSwitch(Coin c) {
switch (c) {
case Coin.HEADS ->
System.out.println("Heads");
case TAILS -> // 可以编译
System.out.println("Tails");
}
}
static void badEnumSwitch(Currency c) {
switch (c) {
case Coin.HEADS ->
System.out.println("Heads");
case TAILS -> // 无法编译
System.out.println("Tails");
}
}
其次,常量case标签的类型必须兼容选择器表达式的类型,而且不能超出后者的取值范围。所谓“兼容”,是指常量case标签的类型要么与选择器表达式的类型相同,要么可以隐式转换。
举例来说,final变量select的类型char可以隐式转换为int,所以case select标签与选择器表达式month的类型相互兼容。
int month = 2;
final char select = '5';
switch (month) {
case 5 : System.out.println("Winter"); break;
case select: System.out.println("Spring"); break;
default : System.out.println("Fall"); // 输出"Fall"
}
如下所示,在第一个switch语句中,b的类型为byte,而1000的类型为int,超出了byte的取值范围,因此无法编译;在第二个switch语句中,b + 1的类型会自动提升为int,1000没有超出int的取值范围,因此可以编译。
byte b = 10;
switch (b) {
case 1000 : // 无法编译
System.out.println("test");
}
switch (b + 1) {
case 1000 : // 可以编译
System.out.println("test");
}
还要注意的是,两个常量case标签的值不能重复:
String str = "Hello world";
switch (str) {
case "Hello world" :
System.out.println("This is case 1"); break;
case "Hello " + "world" : // 无法编译
System.out.println("This is case 2"); break;
}
模式case标签
引入模式匹配后,case标签发生了翻天覆地的变化。所有case标签匹配的都是模式,包括常量模式、类型模式、记录模式、null模式。
在模式匹配的体系中,常量case标签“升级”为常量模式case标签,同样需要满足以下条件:必须使用编译时常量表达式,必须兼容选择器表达式的类型,而且不能超出后者的取值范围。
为了实现更加精细的控制,类型模式和记录模式还支持加入守卫条件。守卫条件以when子句开头,相当于在类型模式和记录模式的基础上添加了额外的条件检查。
因此,Java 21的模式case标签可以细分为以下几类:
常量模式case标签(例如case "abc")类型模式case标签:无守卫条件(例如case String s)类型模式case标签:有守卫条件(例如case String s when s.length() > 5)记录模式case标签:无守卫条件(例如case Point(intx, inty))记录模式case标签:有守卫条件(例如case Point(int x, int y) when x > y)null模式case标签(也就是case null)
以下switch语句使用了各种模式case标签来匹配不同的情况。
import java.util.Arrays;
enum Color { RED, GREEN, BLUE; }
record Point(int i, int j) {}
public class Demo {
static void typeTester(Object obj) {
switch (obj) {
case null -> // null模式case标签:匹配null
System.out.println("null");
case StringBuilder sb -> // 类型模式case标签:匹配StringBuilder类型
System.out.println("Length of StringBuilder: " + sb.length());
case Integer i when i < 0 -> // 类型模式case标签+守卫条件:匹配Integer类型
System.out.println("Negative value: " + i);
case double[] da -> // 类型模式case标签:匹配double[]数组类型
System.out.println("Array: " + Arrays.toString(da));
case Color.RED -> // 常量模式case标签:匹配Color.RED枚举常量
System.out.println("Enum constant: RED");
case Color c -> // 类型模式case标签:匹配Color枚举类型
System.out.println("Enum: " + c.toString());
case Point p -> // 记录模式case标签:匹配Point记录类型
System.out.println("Record: " + p.toString());
default -> // 匹配其他所有情况
System.out.println("Others");
}
}
public static void main(String[] args) {
typeTester(null); // null
typeTester(new StringBuilder("Java")); // Length of StringBuilder: 4
typeTester(100); // Others
typeTester(Color.RED); // Enum constant: RED
typeTester(Color.BLUE); // Enum: BLUE
typeTester(new Point(5, 10)); // Record: Point[i=5, j=10]
typeTester(new double[] { 3.14, 2, 5.9 }); // Array: [3.14, 2.0, 5.9]
typeTester(new float[] { 3.14f, 2, 5.9f }); // Others
}
}
目前,模式case标签只能匹配引用类型,不能匹配基本数据类型。换句话说,Java 21支持使用case String s这样的形式,但是case int i无法通过编译。
Java 23、Java 24、Java 25建议将模式case标签的匹配范围扩展到基本数据类型,并作为预览特性发布。一旦成为正式特性,无疑会极大增强switch结构的灵活性。
模式变量
在模式case标签中,模式变量是模式匹配成功时自动声明、初始化并绑定到匹配值的局部变量。以case String s为例,s是模式变量,它的类型是String。
注意,模式变量的类型要么与选择器表达式的类型相同,要么是其子类型。如下所示,选择器表达式fish的类型是Number,而模式变量s的类型是String,因此编译器会报错。
Number fish = 10;
String name = switch (fish) {
case Integer freshWater -> "Bass fish";
case Number saltWater -> "Clown fish";
case String s -> "Shark"; // 无法编译
};
模式变量属于局部变量,所以其作用域仅限于匹配成功的case分支内部。例如,以下三个模式case标签定义的模式变量都是value,但它们的作用域互不重叠,因此变量名不会相互冲突。
switch (obj) {
case Integer value : {
System.out.println("Integer: " + value); break;
}
case Double value : {
System.out.println("Double: " + value); break;
}
case Boolean value : {
System.out.println("Boolean: " + value); break;
}
default : System.out.println("Object: " + obj);
}
case null
在引入模式匹配之前,如果选择器表达式的值为null,那么运行时会直接抛出NullPointerException(NPE),这使得判断选择器表达式是否为null的操作只能放在switch结构外部进行:
/ 引入模式匹配之前
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");
}
}
Java的设计者认为,这样处理不仅会产生不必要的样板代码,也会增加出错的概率,更好的方案是将空值检查集成到switch结构中,因此在引入模式匹配时专门设计了用于匹配null的case标签:case null。
也就是说,当采用模式匹配时,如果选择器表达式的值为null,那么switch结构是否抛出NPE取决于是否存在case null标签:存在则不会抛出NPE,不存在则仍然会抛出NPE。
因此,之前的示例可以改写为:
/ 引入模式匹配之后
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");
}
}
为了保持向后兼容性,default标签不会匹配null。换句话说,如果只有default标签而没有case null标签,那么当选择器表达式的值为null时仍然会抛出NPE。
语法方面有几点需要注意。
1. 如果switch结构中既有case null标签又有default标签,那么case null标签必须位于default标签之前,否则会导致支配性问题。
2. case null标签可以与default标签合并,但是不能与其他case标签合并。例如,case null, default是合法的语法,而case null, String s无法通过编译。
冒号语法和箭头语法
冒号语法从Java 1.0起就已存在,而箭头语法是Java 14正式引入的特性,属于Java现代化改造的一部分。
编写switch语句和switch表达式时既可以使用冒号语法,也可以使用箭头语法,只是在同一个switch结构中不能混用这两种语法。
如下所示,case 9, 10, 11分支使用了冒号语法,而其他case分支和default分支使用了箭头语法,因此编译器会报错。
int month = 5;
switch (month) {
case 3, 4, 5 -> System.out.println("Spring");
case 6, 7, 8 -> { System.out.println("Summer"); }
case 9, 10, 11 : System.out.println("Fall"); // 无法编译
case 12, 1, 2 -> System.out.println("Winter");
default -> System.out.println("Unknown");
}
冒号语法和箭头语法的主要区别有两个,一是穿透性,二是返回值的方式。
穿透性
我们知道,Java在设计之初大量借鉴了C和C++的语法,因此switch语句的冒号语法也继承了C语言的穿透性。如果希望避免穿透,就要使用break、return或throw语句来显式跳出分支。
而消除穿透性是箭头语法的设计目的之一。使用箭头语法的分支在执行完毕后,控制流会自动跳出switch结构。
注意,当分支的语句组/规则体是单条语句时,不能使用break语句,因为箭头语法本身就已阻止穿透;而当分支的语句组/规则体是代码块时,块内可以使用break语句,只不过它属于冗余,并不会影响switch结构的控制流。
上面提到了“语句组/规则体”,其中语句组指冒号语法后面的内容,规则体指箭头语法后面的内容,稍后会详细讨论。
如下所示,case 1分支的规则体是代码块,因此是否使用break语句均可;其他两个case分支和default分支的规则体是单条语句,因此不能使用break语句。
void main(String[] args) {
switch(args.length) {
case 0 -> System.out.println("No argument");
case 1 -> {
System.out.println("Only one argument");
break; // 可以使用,但属于冗余
}
case 2, 3 -> System.out.println("Two or three arguments");
default -> System.out.println("Too many arguments");
}
System.out.println("Done");
}
在引入模式匹配之后,无穿透性成为硬性规定,它也是模式匹配的核心原则之一。
返回值的方式
冒号语法本身没有返回值的功能,箭头语法则自带返回值的功能,记住这一点很重要。
对于使用冒号语法的switch表达式来说,无论分支的语句组是单条语句还是代码块,都要通过yield语句来返回值。
而对于使用箭头语法的switch表达式来说,当分支的规则体是单条语句时,可以直接返回值,不需要也不能使用yield语句;当规则体是代码块时,必须通过yield语句来返回值。
以下两个switch表达式分别使用了冒号语法和箭头语法,不难看出为什么第二个switch表达式的case 1和case 2分支无法编译。
double computeTax1(double income, int taxBracket) {
return income * switch (taxBracket) {
case 0 : yield 0.1;
case 1 : { yield 0.2; }
default : yield 0.3;
};
}
double computeTax2(double income, int taxBracket) {
return income * switch (taxBracket) {
case 0 -> 0.1;
case 1 -> yield 0.2; // 无法编译
case 2 -> { 0.3; } // 无法编译
case 3 -> { yield 0.4; }
default -> 0.5;
};
}
传统的switch结构支持多个case标签共享相同的代码,标签之间用冒号隔开,例如 case 1: case 2: System.out.print("1 or 2");。我们来看下面这段代码:
import java.time.*;
public class Demo {
public static void main(String[] args) {
DayOfWeek dow = LocalDate.now().getDayOfWeek();
switch (dow) {
case MONDAY : TUESDAY :
System.out.println("MON/TUE"); break;
case WEDNESDAY : THURSDAY: FRIDAY :
System.out.println("WED - FRI"); break;
default :
System.out.println("SAT/SUN");
}
}
}
这段代码的本意是根据当前日期是星期几来输出对应的消息:周一或周二则输出"MON/TUE",周三、周四、周五则输出"WED - FRI",周六或周日则输出“SAT/SUN”。
假设当前日期是周二(即选择器表达式dow的值为TUESDAY),那么根据代码逻辑应该输出“MON/TUE”,然而实际的输出结果的却是"SAT/SUN"。这是因为TUESDAY只是个普通的标签,并不是case标签(case TUESDAY才是)。由于没有匹配到任何case标签,因此程序将执行default分支。
使用箭头语法改写上述switch语句后,就能正确输出当前日期对应的星期几:
import java.time.*;
public class Demo {
public static void main(String[] args) {
DayOfWeek dow = LocalDate.now().getDayOfWeek();
switch (dow) {
case MONDAY, TUESDAY ->
System.out.println("MON/TUE");
case WEDNESDAY, THURSDAY, FRIDAY ->
System.out.println("WED - FRI");
default ->
System.out.println("SAT/SUN");
}
}
}
注意,改写之前的代码没有编译错误,但是逻辑存在问题。而改用箭头语法后代码变得更简洁,意图也更清晰,还能彻底避免因忘写break语句而造成的穿透性错误,所以在现代Java开发中建议首选箭头语法。
语句组/规则体
如前所述,switch结构既能使用冒号语法,也能使用箭头语法。对于这两种语法后面的内容,Java语言规范给出了标准称谓:当使用冒号语法时,冒号后面的内容称为语句组;当使用箭头语法时,箭头后面的内容称为规则体。
换言之,“语句组”是冒号语法的专用术语,而“规则体”是箭头语法的专用术语。更简单地说,语句组对应冒号语法,规则体对应箭头语法。
使用冒号语法时,语句组可以包含任意数量的语句或代码块,必要时可以抛出异常,也可以为空;而使用箭头语法时,规则体可以包含单个表达式或单个代码块,必要时可以抛出异常,但不能为空。
注意,因为switch表达式必须有返回值,所以必须保证任何一条执行路径都能产生结果。
以下switch语句和switch表达式分别使用了箭头语法和冒号语法,相应的规则体和语句组可以包含各种内容。
int x = 3;
// 使用箭头语法的switch语句
switch (x) {
case 1 -> // 规则体包含一条表达式语句
System.out.println("1");
case 2 -> { // 规则体包含一个代码块,块内有两条语句
int y = x * 2;
System.out.println(y);
}
case 3 -> // 规则体包含一个空代码块
{}
default -> // 规则体抛出异常
throw new IllegalArgumentException("Invalid value");
}
// 使用冒号语法的switch表达式
int result = switch (x) {
case 1 : // 语句组为空
case 2 : // 语句组包含两条语句
System.out.println("2");
yield 20;
case 3 : { // 语句组包含一个代码块,块内有两条语句
int y = x * 2;
yield y;
}
case 4 : // 语句组包含一个空代码块
{}
case 5 : // 语句组包含一条空语句
;
default : // 语句组抛出异常
throw new IllegalArgumentException("Invalid value");
};
插曲:怎样判断switch结构有没有采用模式匹配
另一个经常被问到的问题是:对于一个switch结构,如何判断它是否采用了模式匹配?
方法很简单:先观察选择器表达式,如有必要再观察case标签。
第1步 观察选择器表达式。
如果选择器表达式的类型是除传统类型之外的类型(例如Object类、自定义类、数组、接口),就说明switch结构一定采用了模式匹配,不需要再观察case标签。如果选择器表达式的类型是传统类型(byte/Byte、short/Short、char/Character、int/Integer、枚举类型、String),那么还不能确定switch结构有没有采用模式匹配,需要继续观察case标签。
第2步 观察case标签。
如果所有case标签都是常量case标签(例如case "abc"),就说明switch结构没有采用模式匹配。如果至少有一个case标签是模式case标签(例如case String s),就说明switch结构采用了模式匹配。
我们以下面这个switch语句为例来解释判断过程。
Integer value = 5;
switch (value) {
case 1 -> System.out.println("1");
case Integer i -> System.out.println(i);
}
1. 选择器表达式value的类型是Integer,它是传统类型,还不足以判断上述switch语句是否采用了模式匹配,需要继续观察case标签。
2. case 1既可能是常量case标签,也可能是常量模式case标签,所以仅凭这个case标签依然无法判断,需要继续观察其他case标签。
3. case Integer i是类型模式case标签,这是类型匹配独有的特征,由此可知switch语句一定采用了模式匹配。
4. 由于采用了模式匹配,因此可以断定case 1属于常量模式case标签。
模式匹配三原则
模式匹配是极其强大的特性,不过为了享受模式匹配给开发带来的便利性,必须遵循三条核心规则:穷尽性、支配性、穿透性。
穷尽性
所谓“穷尽性”,是指编译器会检查case标签和default标签是否涵盖了选择器表达式所有可能的取值。
对于不采用模式匹配的switch语句来说,穷尽性不是硬性规定;而对于采用模式匹配的switch语句来说,必须要满足穷尽性要求。
switch表达式的标准则严格得多:无论是否采用模式匹配,switch表达式都要满足穷尽性要求,以保证在任何情况下都能返回值。
怎样做到这一点呢?最常见的方案是使用default标签来兜底。default标签可以匹配所有没有被case标签涵盖的取值,相当于实现穷尽性的万能钥匙。
当然,只要穷尽性要求得到满足,编译器就不会强制要求使用default标签。
举例来说,如果选择器表达式的类型是枚举类型,并且case标签已经涵盖了所有枚举常量,那么是否使用default标签均可。
类似地,如果选择器表达式的类型是密封类或密封接口,并且case标签已经涵盖了密封类的所有子类或密封接口的所有子类型,那么也不需要再使用default标签。
如下所示,选择器表达式的类型是Shape,这个密封接口有且仅有两个子类型Circle和Rectangle,而case Circle c和case Rectangle r标签已经涵盖了这两个子类型,因此不需要再使用default标签。
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
public class Demo {
public static void main(String[] args) {
Shape shape = new Circle(5.0);
switch (shape) {
case Circle c-> System.out.println("Circle");
case Rectangle r -> System.out.println("Rectangle");
}
}
}
再来看一个示例。以下switch语句能否编译呢?
Number visitor = Integer.valueOf(1000);
switch (visitor) {
case Integer count -> System.out.println("Welcome: " + visitor);
}
答案是否定的。
选择器表达式visitor的类型为Number,它包括Integer、Double、Long、BigDecimal等多个子类;而case Integer count标签只能匹配Integer的取值,并没有处理非Integer的情况,因此不满足穷尽性要求。
有几种修复方案。一是将选择器表达式和模式变量改为相同的类型,也就是将visitor的类型从Number改为Integer,或是将count的类型从Integer改为Number。
二是在末尾增加匹配Number的case标签,例如:
Number visitor = Integer.valueOf(1000);
switch (visitor) {
case Integer count -> System.out.println("Welcome: " + visitor);
case Number count -> System.out.println("Too many visitors");
}
三是在末尾增加default标签作为兜底,例如:
Number visitor = Integer.valueOf(1000);
switch (visitor) {
case Integer count -> System.out.println("Welcome: " + visitor);
default -> System.out.println("Too many visitors");
}
当穷尽性要求已经得到满足时,是否还使用default标签是个见仁见智的问题。建议不要仅仅为了兜底而添加default标签,因为明确列出所有已知情况,往往比一个笼统的default标签更能体现代码意图。
支配性
所谓“支配性”,是指更具体(匹配范围更窄)的模式case标签必须位于更通用(匹配范围更宽)的模式case标签之前,否则会被后者“遮蔽”而导致不可达错误。
之所以设计支配性规则,是为了防止出现永远匹配不到的模式case标签。
我们梳理一下模式匹配的支配性规则。
规则1 子类优先于父类。
换句话说,匹配子类的模式case标签必须位于匹配父类的模式case标签之前。
以case Object o和case Number n为例。Object可以匹配任何引用类型,而Number只能匹配Number及其子类(例如Integer和Double)。如果case Object o出现在case Number n之前,那么所有Number及其子类的取值在匹配过程中都会先被Object o捕获,从而导致case Number n不可达。
规则2 常量模式优先于类型模式,有守卫条件优先于无守卫条件。
换句话说,常量模式case标签必须位于有守卫条件的模式case标签之前,有守卫条件的模式case标签必须位于无守卫条件的模式case标签之前。
如下所示,由于违反了这条规则,因此case -1, 1无法编译。
Integer i = -5;
switch (i) {
case Integer j when j > 0 ->
System.out.println("Positive integer");
case Integer j ->
System.out.println("Other integer");
case -1, 1 -> // 无法编译
System.out.println("-1 or 1");
}
注意,只有当守卫条件使用编译时常量表达式时,编译器才会进行支配性检查。我们来看下面这段代码:
Object obj = 1;
switch (obj) {
case Integer i when i > 0 ->
System.out.println("Positive value");
case Integer i when i == 1 ->
System.out.println("One");
default ->
System.out.println("Object " + obj);
}
两个case标签都使用了守卫条件,第一个守卫条件i > 0看似会支配第二个守卫条件i == 1,但这段代码其实可以编译通过,程序将执行第一个case分支并输出“Positive value”。
原因在于,i > 0和i == 1都不是编译时常量表达式,因此不涉及支配性检查。虽然第二个case标签确实不会执行,但是编译器并不会因此而报错。
如果同一个switch块中既使用无守卫条件的模式case标签,又使用有守卫条件的模式case标签,那么顺序很重要:对于同一类型的模式case标签,需要做到有守卫条件的在前,无守卫条件的在后,否则会出现支配性问题;不同类型的模式case标签则没有顺序方面的要求。
如下所示,case Integer i和case Integer i when i > 10的类型是Integer,case Double num和case Double num when num <= 15.5的类型是Double。根据支配性规则,case Integer i when i > 10必须位于case Integer i之前,case Double num when num <= 15.5必须位于case Double num之前;case Integer i和case Integer i when i > 10位于case Double num和case Double num when num <= 15.5之前或之后都可以。当然,case Number num一定要位于最后。
String getTrainer(Number height) {
return switch (height) {
case Integer i -> "Daniel";
case Integer i when i > 10 -> "Jason"; // 无法编译
case Double num -> "Kelly";
case Double num when num <= 15.5 -> "Peter"; // 无法编译
case Number num -> "Tom";
};
}
规则3 如果守卫条件恒为true,那么有守卫条件的模式case标签相当于无守卫条件的模式case标签。
我们来看下面这段代码:
Object obj = "Hello World";
final int A = 10;
final int B = 20;
switch (obj) {
case String s when A < B ->
System.out.println("Always true");
case String s when s.length() == 0 -> // 无法编译
System.out.println("Get string S");
default ->
System.out.println("Object " + obj);
}
A和B都是常量变量,所以守卫条件A < B属于编译时常量表达式,这意味着编译器会进行支配性检查。
由于A的值为10,B的值为20,因此A < B的结果必然为true,这一点在编译阶段就可以确定。换句话说,编译器将case String s when A < B当作case String s来处理。而这样一来,就相当于case String s位于case String s when s.length() == 0之前,显然违反了支配性规则。
规则4不能存在多个可以匹配所有取值的标签。
换句话说,switch结构中只能有一个兜底标签,要么是default标签,要么是可以无条件匹配选择器表达式的case标签。
这一点很好理解:如果存在多个兜底标签,那么肯定会导致不可达错误。
如下所示,选择器表达式s的类型是String,而case String t能够匹配String的所有非null取值,因此会与default标签发生冲突,导致default标签永远不可达。
static void matchAll(String s) {
switch (s) {
case String t:
System.out.println(t); break;
default: // 无法编译
System.out.println("Something else");
}
}
下面这段代码无法编译的原因类似:switch语句中存在两个兜底标签。
Number visitor = Integer.valueOf(1000);
switch (visitor) {
case Integer count -> System.out.println("Welcome: " + visitor);
case Number count -> System.out.println("Too many visitors");
default -> System.out.println("Undefined"); // 无法编译
}
题外话:try/catch结构也要遵循支配性规则
如果try/catch结构使用了多个捕获异常的catch子句,而且这些异常之间存在继承关系,那么子类异常(更具体的异常)必须在前,父类异常(更通用的异常)必须在后。
换句话说,捕获子类异常的catch子句必须位于捕获父类异常的catch子句之前,否则捕获子类异常的catch子句永远无法执行。
如下所示,两个catch子句捕获的异常分别是RuntimeException和ArithmeticException。由于RuntimeException是ArithmeticException的父类,因此第一个catch子句会支配第二个catch子句。
public void demo() {
int a = 0, b = 0;
try {
System.out.print(a / b);
} catch (RuntimeException e) {
System.out.print(-1);
} catch (ArithmeticException e) { // 无法编译
System.out.print(0);
} finally {
System.out.print("done");
}
}
穿透性
由于冒号语法有穿透性,而箭头语法没有穿透性,因此关于穿透性的讨论主要针对冒号语法。
模式匹配对穿透性的要求十分严格:无论是switch语句还是switch表达式,只要采用模式匹配,就必须保证无穿透性。
对于采用模式匹配的switch语句来说,各个case分支需要通过break、return或throw语句跳出以避免穿透,如下所示。
Object obj = 3.14;
switch (obj) {
case Integer i : System.out.println(i); break;
case Double d : System.out.println(d); return;
case String s : throw new IllegalArgumentException("Oops!");
default : System.out.println(obj);
}
break和return语句的区别在于,前者的作用是跳出switch语句,而后者的作用是跳出整个方法。
我们来看以下代码:case Integer i分支使用break语句跳出switch语句,控制流返回到demo()方法的末尾;demo()方法要求所有可能的执行路径返回String,而case Integer i分支并没有返回值,因此编译器会报错。
public String demo(Object obj) {
switch (obj) {
case String s:
System.out.println(s);
return "String";
case Integer i:
System.out.println(i);
break; // 无法编译
case Double d:
System.out.println(d);
return "Double";
default:
return "Something else";
}
}
我们知道,switch表达式必须有明确的结果。为了保证无穿透性,switch表达式要么通过yield语句产生值,要么通过throw语句抛出异常,但是不能使用return语句。
Object obj = 3.14;
int x = switch (obj) {
case Integer i : return 42; // 无法编译
case String s : yield s.length();
default : yield 0;
};
尾声:代码更加简洁,意图更加清晰
自从引入switch表达式、箭头语法、模式匹配等特性之后,switch结构变得极其灵活,但是学习成本也显著增加。对于习惯使用传统switch结构的开发者来说,可能需要一段时间来熟悉新的特性。
当决策逻辑比较简单,或者只需要进行基于整型或枚举的常量匹配时,传统的switch结构仍然适用。而对于本文开头提到的类型检查和转换,经过现代化改造的switch结构无疑更简洁也更清晰:
public void demo(Object obj) {
// 传统方式:instanceof
if (obj == null) {
System.out.println("null");
} else if (obj instanceof Integer) {
Integer i = (Integer) obj;
System.out.println(i * 2);
} else if (obj instanceof Double) {
Double d = (Double) obj;
System.out.println(d * d);
} else if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
} else {
System.out.println(obj);
}
// 采用模式匹配+箭头语法的switch语句
switch (obj) {
case null -> System.out.println("null");
case Integer i -> System.out.println(i * 2);
case Double d -> System.out.println(d * d);
case String s -> System.out.println(s.length());
default -> System.out.println(obj);
}
}
模式匹配是Java现代化改造的关键一环,也是最重要的特性之一。它不仅扩展了选择器表达式支持的类型,而且增强了类型安全性,还在一定程度上减少了样板代码。虽然部分开发者依然坚持“你发任你发,我用Java 8”,但是在条件允许的情况下,建议新项目优先考虑使用经过现代化改造的switch结构。
作者简介:
蒋楠,出身电子与计算机工程专业的高级技术产品经理,负责C端产品的规划和设计,对算法和数据密集型应用同样兴趣浓厚。兼具科技图书译者、马拉松跑者、航天爱好者等多重身份,译作包括《计算机简史》《计算机科学精粹》等。