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端产品的规划和设计,对算法和数据密集型应用同样兴趣浓厚。兼具科技图书译者、马拉松跑者、航天爱好者等多重身份,译作包括《计算机简史》《计算机科学精粹》等。