Java 新特性完整指南:Switch 模式匹配

  • 2023-07-18
    北京
  • 本文字数:10257 字

    阅读完需:约 34 分钟

Switch 语句由选择器表达式和包含 case 标签switch 块组成;对选择器表达式进行求值,并切换到与求值结果相匹配的 case 标签所对应的执行路径。

在原来 switch 语句中,case…:标签语法采用的是穿透语义(fall-through semantics)。Java 14 增加了对新标签语法case ...-> 的支持,该语法采用的是非穿透语义。

Java 14 还增加了对 switch 表达式的支持。Switch 表达式的计算结果为单个值。该版本还引入了yield语句,用于显式地生成一个值。

支持 switch 表达式(另有一篇文章进行了详细讨论)是指可以将 switch 用于需要表达式(如赋值语句)的实例。

问题

即使有了 Java 14 所做的功能增强,switch 语句的使用还是有一些限制:

  1. Switch 选择器表达式只支持特定类型,即基本整型数据类型byteshortcharint;对应的装箱形式ByteShortCharacterIntegerString类;枚举类型。

  2. Switch 选择器表达式的计算结果只能与常量做相等比较。在匹配 case 标签和常量值时只对一个值进行检查。

  3. null 值的处理方式与其他值不同。

  4. 错误处理方式不统一。

  5. 枚举的作用域不是很合理。

解决方案

为了克服这些限制,人们已经提出并实现了一种实用、便捷的解决方案:switch 语句模式匹配和表达式。这个解决方案解决了上面提到的所有问题。

Switch 模式匹配是在 JDK 17 中引入的,JDK 18、19 和 20 对其做了改进,JDK 21 最终将其完成。

模式匹配从以下几个方面克服了传统 switch 语句的局限性:

  1. 选择器表达式的类型可以是整型基本类型(不包括long类型),也可以是任何引用类型。

  2. 除了常量之外,case 标签还可以包含模式。不同于常量 case 标签只能应用于一个值,模式 case 标签可以应用于多个值。引入了一个新的 case 标签case p,其中p是一个模式。

  3. Case 标签可以包含null

  4. Case 标签后面有一个可选的when子句,可用于条件模式匹配或受保护模式匹配。带有 when 的 case 标签被称为受保护 case 标签。

  5. 枚举常量的 case 标签可以限定。当使用枚举常量时,选择器表达式不一定要是枚举类型。

  6. 引入MatchException,在模式匹配中实现更统一的错误处理。

  7. 传统的 switch 语句和穿透语义也支持模式匹配。模式匹配的一个好处是方便面向数据的编程,例如提高复杂数据查询的性能。

什么是模式匹配?

模式匹配是一个功能强大的特性,它扩展了程序中控制流结构的功能。除了可以匹配传统上支持的常量外,该特性还允许选择器表达式与多个模式进行匹配。Switch 语句的语义并没有变化;与 switch 选择器表达式的值进行匹配的 case 标签可能包含模式,如果选择器表达式的值与一个 case 标签模式匹配成功,就会选中控制流中那个 case 标签所对应的执行路径。唯一的增强是,选择器表达式既可以是基本整型(不包括 long 类型),也可以是任何引用类型。除了常量之外,case 标签还可以包含模式。此外,还有一个新增功能是,case 标签支持 null 和限定枚举常量。

以下是 switch 块中 switch 标签的语法:

SwitchLabel:  case CaseConstant { , CaseConstant }  case null [, default]  case Pattern  default
复制代码

模式匹配既可以用于具有穿透语义的传统case…:标签语法,也可以用于非穿透语义的case…->标签语法。尽管如此,必须注意的是,一个 switch 块不能同时使用这两种类型的 case 标签。

得益于这些修改,模式匹配让开发人员可以实现更复杂的控制流结构,为代码逻辑的处理提供了更丰富的方法。

环境设置

要运行本文中的示例代码,唯一的先决条件是安装 Java 20 或 Java 21。与 Java 20 相比,Java 21 只做了一项增强,即在 case 标签中支持限定枚举常量。可以通过以下命令查看 Java 版本:

java --versionjava version "20.0.1" 2023-04-18Java(TM) SE Runtime Environment (build 20.0.1+9-29)Java HotSpot(TM) 64-Bit Server VM (build 20.0.1+9-29, mixed mode, sharing)
复制代码

因为在 Java 20 中,switch 模式匹配是一个预览特性,所以必须使用以下语法运行javacjava命令:

javac --enable-preview --release 20 SampleClass.javajava --enable-preview  SampleClass
复制代码

但是,也可以使用源码启动器直接运行它,命令行如下:

java --source 20 --enable-preview Main.java
复制代码

还有一个jshell选项,但也需要启用预览功能:

jshell --enable-preview
复制代码

一个简单的模式匹配示例

我们从一个简单的模式匹配示例开始,其中,switch 表达式的选择器表达式类型是引用类型Collection ;case 标签包含case p形式的模式。

import java.util.Collection;import java.util.LinkedList;import java.util.Stack;import java.util.Vector;public class SampleClass {    static Object get(Collection c) {        return switch (c) {            case Stack s -> s.pop();            case LinkedList l -> l.getFirst();            case Vector v -> v.lastElement();            default -> c;        };    }    public static void main(String[] argv) {        var stack = new Stack<String>();        stack.push("firstStackItemAdded");        stack.push("secondStackItemAdded");        stack.push("thirdStackItemAdded");        var linkedList = new LinkedList<String>();        linkedList.add("firstLinkedListElementAdded");        linkedList.add("secondLinkedListElementAdded");        linkedList.add("thirdLinkedListElementAdded");        var vector = new Vector<String>();        vector.add("firstVectorElementAdded");        vector.add("secondVectorElementAdded");        vector.add("thirdVectorElementAdded");        System.out.println(get(stack));        System.out.println(get(linkedList));        System.out.println(get(vector));    }}
复制代码

编译并运行这个 Java 应用程序,输出如下:

thirdStackItemAddedfirstLinkedListElementAddedthirdVectorElementAdded
复制代码

模式匹配支持所有引用类型

在上面给出的示例中,选择器表达式的类型是Collection类类型。但是,选择器表达式的类型可以是任何引用类型。因此,case 标签模式可以是与选择器表达式的值兼容的任何引用类型。例如,下面是经过修改的SampleClass类,它使用了Object类型的选择器表达式,而 case 标签模式除了之前使用的StackLinkedListVector等引用类型外,还包括一个记录模式和一个数组引用类型的模式。

import java.util.LinkedList;import java.util.Stack;import java.util.Vector;record CollectionType(Stack s, Vector v, LinkedList l) {}public class SampleClass {    static Object get(Object c) {        return switch (c) {            case CollectionType r -> r.toString();            case String[] arr -> arr.length;            case Stack s -> s.pop();            case LinkedList l -> l.getFirst();            case Vector v -> v.lastElement();            default -> c;        };    }    public static void main(String[] argv) {        var stack = new Stack<String>();        stack.push("firstStackItemAdded");        stack.push("secondStackItemAdded");        stack.push("thirdStackItemAdded");        var linkedList = new LinkedList<String>();        linkedList.add("firstLinkedListElementAdded");        linkedList.add("secondLinkedListElementAdded");        linkedList.add("thirdLinkedListElementAdded");        var vector = new Vector<String>();        vector.add("firstVectorElementAdded");        vector.add("secondVectorElementAdded");        vector.add("thirdVectorElementAdded");        var r = new CollectionType(stack, vector, linkedList);        System.out.println(get(r));        String[] stringArray = {"a", "b", "c"};        System.out.println(get(stringArray));        System.out.println(get(stack));        System.out.println(get(linkedList));        System.out.println(get(vector));    }}
复制代码

这次的输出如下:

CollectionType[s=[firstStackItemAdded, secondStackItemAdded, thirdStackItemAdded], v=[firstVectorElementAdded, secondVectorElementAdded, thirdVectorElementAdded], l=[firstLinkedListElementAdded, secondLinkedListElementAdded, thirdLinkedListElementAdded]]3thirdStackItemAddedfirstLinkedListElementAddedthirdVectorElementAdded
复制代码

Null case 标签

传统上,如果选择器表达式的计算结果为空,则 switch 语句在运行时会抛出NullPointerException。选择器表达式为空不是编译时问题。下面这个简单的应用程序有一个匹配所有 case 标签的default ,我们通过它演示下选择器表达式为空如何导致运行时异常NullPointerException

import java.util.Collection;public class SampleClass {    static Object get(Collection c) {        return switch (c) {            default -> c;        };    }    public static void main(String[] argv) {        get(null);    }}
复制代码

我们可以在 switch 块外面显式地检测空值,并仅在值非空时执行 switch,但这涉及到添加 if-else 代码。在新的模式匹配特性中,Java 增加了对null的支持。下面这个应用程序中的 switch 语句使用case null来检测选择器表达式的值是否为空。

import java.util.Collection;public class SampleClass {    static void get(Collection c) {        switch (c) {            case null -> System.out.println("Did you call the get with a null?");            default -> System.out.println("default");        }    }    public static void main(String[] argv) {        get(null);    }}
复制代码

在运行时,应用程序输出如下:

你在调用get方法时使用了null参数?
复制代码

case null可以与case default合并,如下所示:

import java.util.Collection;public class SampleClass {    static void get(Collection c) {        switch (c) {            case null, default -> System.out.println("Did you call the get with a null?");        }    }    public static void main(String[] argv) {        get(null);    }}
复制代码

但是,case null 不能与任何其他 case 标签合并。例如,下面的类将 case null 与一个模式为Stack s的 case 标签做了合并:

import java.util.Collection;import java.util.Stack;public class SampleClass {    static void get(Collection c) {        switch (c) {            case null, Stack s -> System.out.println("Did you call the get with a null?");            default -> System.out.println("default");        }    }    public static void main(String[] args) {        get(null);    }}
复制代码

该类将产生如下编译时错误:

SampleClass.java:11: error: 非法case标签合并          case null, Stack s -> System.out.println("Did you call the get with a null?");
复制代码

带有 when 子句的受保护模式

有时,开发人员可能会使用与布尔表达式计算结果做匹配的条件式 case 标签模式。这时,when子句就派上用场了。该子句会计算布尔表达式,形成所谓的“受保护模式”。如下所示,代码中的第一个 case 标签使用when子句判断Stack是否为空。

import java.util.Stack;import java.util.Collection;public class SampleClass {    static Object get(Collection c) {        return switch (c) {            case Stack s when s.empty() -> s.push("first");            case Stack s2 -> s2.push("second");            default -> c;        };    }}
复制代码

对应的代码在-> 右侧,只有在 Stack 为空时才会执行。

对于带有模式的 case 标签,顺序很重要

在使用带模式的 case 标签时,开发人员必须确保不会因为顺序产生任何与类型或子类型层次结构相关的问题。这是因为,与常量 case 标签不同,case 标签中的模式使得选择器表达式可以匹配多个包含模式的 case 标签。Switch 模式匹配特性会匹配第一个模式与选择器表达式值相同的标签。

如果一个 case 标签模式的类型是在它之前出现的另一个 case 标签模式的类型的子类型,则会发生编译时错误,因为后一个 case 标签将被识别为不可访问代码。

下面是一个演示程序,你可以编译并运行它,其中类型为Object的 case 标签模式控制了后续类型为Stack的代码标签模式。

import java.util.Stack;public class SampleClass {    static Object get(Object c) {        return switch (c) {            case Object o  -> c;            case Stack s  -> s.pop();        };    }}
复制代码

在编译这个类时,会产生以下错误信息:

SampleClass.java:12: error: 该case标签为它前面的case标签所控制        case Stack s  -> s.pop();             ^
复制代码

像下面这样对调两个 case 标签的顺序就可以修复这个编译时错误:

public class SampleClass {    static Object get(Object c) {        return switch (c) {            case Stack s  -> s.pop();            case Object o  -> c;        };    }}
复制代码

类似地,如果 case 标签包含的模式与前面出现的无条件/非保护模式 case 标签具有相同的引用类型,则会导致编译类型的错误,就像下面的类这样:

import java.util.Stack;import java.util.Collection;public class SampleClass {    static Object get(Collection c) {        return switch (c) {            case Stack s -> s.push("first");            case Stack s2 -> s2.push("second");        };    }}
复制代码

上述代码在编译时会产生以下错误:

SampleClass.java:13: error: 该case标签为它前面的case标签所控制        case Stack s2 -> s2.push("second");             ^
复制代码

为了避免这种错误,case 标签的顺序应该直观、可读。应该首先列出常量标签,然后是case null标签、受保护的模式标签和非受保护类型的模式标签。default case 标签可以与case null 标签合并,也可以单独作为最后一个 case 标签。下面的类演示了正确的排序:

import java.util.Collection;import java.util.Stack;import java.util.Vector;public class SampleClass {    static Object get(Collection c) {        return switch (c) {            case null -> c;  //case label null            case Stack s when s.empty() -> s.push("first");  // 受保护case标签            case Vector v when v.size() > 2 -> v.lastElement();  // 受保护case标签            case Stack s -> s.push("first");  // 非受保护case标签            case Vector v -> v.firstElement();  // 非受保护case标签            default -> c;        };    }}
复制代码

模式匹配可用于传统的 switch 语句和穿透语义

模式匹配特性与它是 switch 语句还是 switch 表达式无关。模式匹配也与使用穿透语义的case…:标签还是使用非穿透语义的case…->标签无关。在下面的示例中,模式匹配与 switch 语句而不是与 switch 表达式一起使用。case 标签使用了具有穿透语义的case…:。第一个 case 标签中的when子句使用了一个受保护的模式。

import java.util.Stack;import java.util.Collection;public class SampleClass {    static void get(Collection c) {        switch (c) {            case Stack s when s.empty(): s.push("first"); break;            case Stack s : s.push("second");  break;            default : break;        }    }}
复制代码

模式变量的作用域

模式变量是出现在 case 标签模式中的变量。模式变量的作用域仅限于出现在->箭头右侧的块、表达式或 throw 语句。请看下面的演示代码,default 中使用了来自它前面的 case 标签的模式变量。

import java.util.Stack;public class SampleClass {    static Object get(Object c) {        return switch (c) {            case Stack s -> s.push("first");            default -> s.push("first");        };    }}
复制代码

上述代码会产生以下编译错误:

import java.util.Collection;SampleClass.java:13: error: cannot find symbol        default -> s.push("first");                   ^  symbol:   variable s  location: class SampleClass
复制代码

出现在受保护 case 标签模式中的模式变量,其作用域包括 when 子句,如下所示:

import java.util.Stack;import java.util.Collection;public class SampleClass {    static Object get(Collection c) {        return switch (c) {            case Stack s when s.empty() -> s.push("first");            case Stack s -> s.push("second");            default -> c;        };    }}
复制代码

由于模式变量的作用域有限,所以相同的模式变量名可以跨多个 case 标签使用。前面的例子说明了这一点,其中模式变量s用在了两个不同的 case 标签中。

当处理具有穿透语义的 case 标签时,模式变量的作用域扩展到了:右侧的一组语句。这就是为什么在上一节中,使用模式匹配和传统的 switch 语句,可以在两个 case 标签中使用相同的模式变量名。但是,具有穿透语义的 case 标签声明模式变量会导致编译时错误。关于这一点,下面的类可以证明:

import java.util.Stack;import java.util.Vector;import java.util.Collection;public class SampleClass {    static void get(Collection c) {        switch (c) {            case Stack s : s.push("second");            case Vector v  : v.lastElement();            default : System.out.println("default");        }    }}
复制代码

第一个语句组中缺少break; 语句,如果第二个语句组中的模式变量v未初始化,则 switch 可能会在第二个语句组处失败。上述类会产生如下编译时错误:

SampleClass.java:12: error: 非法穿透到模式        case Vector v  : v.lastElement();             ^
复制代码

只需在第一个语句组中添加break; 语句就可以修复这个错误:

import java.util.Stack;import java.util.Vector;import java.util.Collection;public class SampleClass {    static void get(Collection c) {        switch (c) {            case Stack s : s.push("second"); break;            case Vector v  : v.lastElement();            default : System.out.println("default");        }    }}
复制代码

每个 case 标签只能有一个模式

无论是类型为case…:的 case 标签,还是类型为case…->的 case 标签,都不允许在单个 case 标签中组合使用多个模式,否则会导致编译时错误。也许不太明显,在单个 case 标签中组合使用多个模式会导致非法穿透,如下所示:

import java.util.Stack;import java.util.Vector;import java.util.Collection;public class SampleClass {    static Object get(Collection c) {        return switch (c) {            case Stack s, Vector v -> c;            default -> c;        };    }}
复制代码

上述代码会产生以下编译时错误:

SampleClass.java:11: error: 非法穿透到模式        case Stack s, Vector v -> c;                      ^
复制代码

一个 switch 块中只能有一个匹配所有的 case 标签

无论是 switch 语句还是 switch 表达式,在一个 switch 块中包含多个匹配所有的 case 标签都会导致编译时错误。匹配所有的 case 标签是指:

  1. 一个带有模式、可以无条件匹配选择器表达式的 case 标签

  2. default case 标签请看下面的演示类:

import java.util.Collection;public class SampleClass {    static Object get(Collection c) {        return switch (c) {            case Collection coll -> c;            default -> c;        };    }}
复制代码

编译这个类会产生以下错误信息:

SampleClass.java:13: error: switch同时具有无条件模式和default标签        default -> c;        ^
复制代码

类型覆盖的穷尽性

穷尽性意味着 switch 块必须处理选择器表达式所有可能的值。穷尽性要求只有在下列一项或多项适用的情况下才能满足:

a) 使用模式 switch 表达式/语句;

b) 使用case null

c) 选择器表达式不属于以下遗留类型:charbyteshortintCharacterByteShortIntegerString或枚举类型。

为了实现穷尽性,如果子类型不多的话,则可以为选择器表达式类型的每个子类型添加 case 标签。然而,如果子类型众多,这种方法可能会很啰嗦;例如,为Object类型的选择器表达式的每个引用类型添加 case 标签,甚或为Collection类型的选择器表达式的每个子类型添加 case 标签,都是不可行的。

为了演示穷尽性要求,请看下面这个类:

import java.util.Collection;import java.util.Stack;import java.util.LinkedList;import java.util.Vector;public class SampleClass {    static Object get(Collection c)   {        return switch (c) {            case Stack s  -> s.push("first");            case null  -> throw new NullPointerException("null");            case LinkedList l    -> l.getFirst();            case Vector v  -> v.lastElement();        };    }}  
复制代码

该类会产生以下编译时错误消息:

SampleClass.java:10: error: switch表达式未涵盖所有可能的输入值                return switch (c) {                       ^
复制代码

如下所示,只需要增加一个 default case 标签就可以解决这个问题:

import java.util.Collection;import java.util.Stack;import java.util.LinkedList;import java.util.Vector;public class SampleClass {    static Object get(Collection c)   {        return switch (c) {            case Stack s  -> s.push("first");            case null  -> throw new NullPointerException("null");            case LinkedList l    -> l.getFirst();            case Vector v  -> v.lastElement();            default -> c;        };    }}  
复制代码

像下面这样,如果匹配所有的 case 标签所带有的模式可以无条件匹配选择器表达式,那么就可以满足穷尽性要求,但它无法显式地处理任何子类型。

import java.util.Collection;public class SampleClass {    static Object get(Collection c)   {        return switch (c) {            case Collection coll  -> c;        };    }}  
复制代码

default case 标签可用于满足穷尽性,但如果选择器表达式可能的取值非常少,有时候可以避免使用它。例如,如果选择器表达式的类型为java.util.Vector,只需提供一个子类java.util.Stack的 case 标签模式就可以避免。类似地,如果选择器表达式是密封类类型,则只有在密封类类型的permits子句中声明的类需要由 switch 块处理。

Switch case 标签中的泛型记录模式

Java 20 增加了对 switch 语句/表达式中泛型记录模式类型参数推断的支持。作为一个例子,考虑以下泛型记录:

record Triangle<S,T,V>(S firstCoordinate, T secondCoordinate,V thirdCoordinate){};
复制代码

在下面的 switch 块中,推断出的 record 模式如下:

Triangle<Coordinate,Coordinate,Coordinate>(var f, var s, var t): static void getPt(Triangle<Coordinate, Coordinate, Coordinate> tr){        switch (tr) {           case Triangle(var f, var s, var t) -> …;           case default -> …;        }}
复制代码

使用 MatchException 进行错误处理

Java 19 引入了java.lang.Runtime类的一个新的子类,旨在用更统一的方式处理模式匹配期间的异常。这个名为java.lang.MatchException的新类是一个预览 API。MatchException 不是专门为 switch 中的模式匹配而设计的,而是为所有模式匹配语言结构而设计的。当模式匹配最终未能匹配提供的任何模式时,在运行时可能就会抛出 MatchException。关于这一点,请看下面的应用程序。该应用程序的 case 标签中有一个 record 模式,而它所要匹配的 record 声明了一个除数为 0 的访问器方法。

record DivisionByZero(int i) {    public int i() {        return i / 0;    }}public class SampleClass {    static DivisionByZero get(DivisionByZero r) {        return switch(r) {        case DivisionByZero(var i) -> r;        };    }    public static void main(String[] argv) {        get(new DivisionByZero(42));    }}
复制代码

示例应用程序编译通过,没有错误,但运行时会抛出MatchException异常:

Exception in thread "main" java.lang.MatchException: java.lang.ArithmeticException: / by zero        at SampleClass.get(SampleClass.java:7)        at SampleClass.main(SampleClass.java:14)Caused by: java.lang.ArithmeticException: / by zero        at DivisionByZero.i(SampleClass.java:1)        at SampleClass.get(SampleClass.java:1)        ... 1 more
复制代码

小结

本文介绍了 Java 新增的对 switch 控制流结构模式匹配的支持。其主要改进是 switch 的选择器表达式可以是任何引用类型,并且 switch 的 case 标签可以包含模式,包括条件模式匹配。而且,如果你不愿意更新整个代码库,那么模式匹配也支持使用传统的 switch 语句及其穿透语义。

原文链接:

https://www.infoq.com/articles/pattern-matching-for-switch/