本文要点

JavaSE14(于2020年3月发布)引入了一种模式匹配作为预览特性,将成为JavaSE16(将于2021年3月发布)的一项永久性特性。

模式匹配的第一阶段仅限于一种模式(类型模式)和一种语言构造(instanceof),但这只是整个完整特性的第一部分。

简单地说,模式匹配可以帮我们减少繁琐的条件状态提取。在进行条件状态提取时,我们问一个与某个对象有关的问题(比如“你是Foo吗”),如果答案是肯定的,我们就从对象中提取状态:“if(xinstanceofIntegeri){}”,其中i是绑定变量。

绑定变量需要进行明确的赋值,但比这个更进一步:绑定变量的作用域是程序中将被明确赋值的一组位置。这个叫作流式作用域。

模式匹配是一个强大的特性,将在几个Java版本中发挥重要作用。未来的部分特性将为我们带来switch的模式、Record的解构模式,等等,目的是让解构对象变得像构造对象一样容易(在结构上更相似)。

JavaSE14(于2020年3月发布)引入了一种模式匹配作为预览特性,将成为JavaSE16(将于2021年3月发布)的一项永久性特性。

模式匹配的第一阶段仅限于一种模式(类型模式)和一种语言构造(instanceof),但这只是整个完整特性的第一部分。

简单地说,模式匹配可以帮我们减少繁琐的条件状态提取。在进行条件状态提取时,我们问一个与某个对象有关的问题(比如“你是Foo吗”),如果答案是肯定的,我们就从对象中提取状态:“if(xinstanceofIntegeri){}”,其中i是绑定变量。

使用instanceof获取对象类型是一种条件提取形式,在获得到对象类型之后,总是要将对象强制转换为该类型。

我们可以在的复制构造函数中看到一个典型的例子:

publicEnumMap(MapK,?extsVm){if(minstanceofEnumMap){EnumMapK,?extsVem=(EnumMapK,?extsV)m;//optimizedcopyofmapstatefromem}else{//insertelementsonebyone}}

构造函数接收另一个Map作为参数,它可能是也可能不是一个EnumMap。如果是的话,构造函数可以将其强制转换为EnumMap,并使用更有效的方法复制Map的状态,否则的话,它将使用一般的插入方式。

这种“测试并进行强制转换”的习惯用法是多余的吗?在有了minstanceofEnumMap之后,我们还可以做些什么?模式匹配可以将测试和强制转换合并到单个操作中。类型模式将类型名称与绑定变量的声明组合在一起,如果instanceof成功,绑定变量将被绑定到对象的窄化类型:

publicEnumMap(MapK,?extsVm){if(minstanceofEnumMapK,?extsVem){//optimizedcopyofmapstatefromem}else{//insertelementsonebyone}}

在上面的例子中,EnumMapK,?extsVem是一种类型模式(看起来像是一种变量声明)。我们扩展了instanceof,让它可以接受模式和普通类型。我们先测试m是不是一个EnumMap,如果是,则将其转换为EnumMap,并将结果绑定到if语句第一行中的em变量。

另一个需要经常进行“测试后强制转换”的地方是在实现Object::equals时。IDE可能会为Point类生成equals()方法:

publicbooleanequals(Objecto){if(!(oinstanceofPoint))returnfalse;Pointp=(Point)o;returnx====;}

下面是使用模式匹配的等效代码:

publicbooleanequals(Objecto){return(oinstanceofPointp)x====;}

这段代码起到同样的效果,但更简单直接,因为我们可以只使用一个复合布尔表达式来表达一个等效的条件,而不是使用控制流的语句。绑定变量p只在明确被赋值的作用域内生效,例如与连接的表达式。

如果模式匹配可以消除Java代码中99%的强制类型转换操作,那么它肯定会很流行,但模式匹配不仅限于此。随着时间的推移,将会出现其他类型的模式,它们可以进行更复杂的条件提取,使用更复杂的方式来组合模式,以及提供其他可以使用模式的构造(比如switch,甚至是catch)。再加上Record和封印类的相关特性,模式匹配有可能简化我们编写的大部分代码。

绑定变量的作用域

模式包含一个测试操作,如果测试成功,则从对象中有条件地提取状态,并声明绑定变量,用于接收提取结果。到目前为止,我们已经看到了一种模式:类型模式。它们使用Tt来表示,其中测试操作为instanceofT,其中有一个要提取状态的元素(将对象引用转换为T),t是用于接收转换结果的变量名称。目前,模式只能放在instanceof的右侧。

模式的绑定变量是“普通”的局部变量,但它们有两个新奇的地方:声明的位置和作用域。我们习惯于通过语句(Foof=newFoo())或语句头部(例如for循环和try-with-resources代码块)在“最左侧”声明局部变量,而模式是在语句或表达式的“中间”声明局部变量,这可能需要一点时间来适应:

if(xinstanceofIntegeri){}

出现在instanceof右边的i实际上是声明的局部变量。

绑定变量的另一个新奇的地方是它们的作用域。“普通”局部变量的作用域从它被声明的位置开始,直到声明它的语句或块的结束。局部变量受制于明确的赋值,这是一种基于流的方式,当我们不能证明它已经被赋值时,就不能读取它。绑定变量也一样,但它们更进一步:绑定变量的作用域是程序中将被明确赋值的一组位置。这个叫作流式作用域。

我们已经看过流式作用域的一个简单示例。在Point的equals方法中,我们看到:

return(oinstanceofPointp)x====;

绑定变量p是在instanceof表达式中声明的,因为是一种短路操作,如果instanceof返回true,代码只执行到x==,所以p在表达式x==中得到了明确赋值,因此p此时的作用域就到此为止。但是,如果我们使用||替换,就会得到一个错误,说p不在作用域内,因为它可能在第一个||不是true的情况下到达第二个||表达式,此时p不会被赋值。

类似地,如果模式匹配出现在if语句的头部,变量绑定将出现在if语句其中的一个分支中,而不是同时出现在两个分支中:

if(xinstanceofFoof){//finscopehere}else{//fnotinscopehere}

类似地:

if(!(xinstanceofFoof)){//fnotinscopehere}else{//finscopehere}

因为绑定变量的作用域是绑定到控制流的,所以反转if条件或应用德摩根定律将以完全相同的方式转换作用域,就像它们转换控制流一样。

有人可能会想,既然我们可以继续使用传统的“作用域一直到块的末尾”规则,为什么要选择这种更复杂的作用域方法。答案是:我们可以这样,但我们可能并不喜欢这样的结果。Java禁止使用局部变量来跟踪局部变量,如果绑定变量的作用域一直运行到块的末尾,那么对于这样的情况:

if(xinstanceofIntegernum){}elseif(xinstanceofLongnum){}elseif(xinstanceofDoublenum){}

就会导致重新声明num,我们不得不为每一个分支使用一个新的名字(switch中的case标签模式也是如此)。通过在执行else时将num置于作用域之外,我们可以在else子句中自由地重新声明一个新的num(具有新的类型)。

流式作用域在处理绑定变量作用域是否跳出其声明语句方面可以获得最好的效果。在上面的if-else示例中,绑定变量在if-else其中的一个分支作用域中,而不是两者兼有,也不在if-else后面的语句中。但是,如果其中一个分支总是有突发情况(例如返回或抛出异常),我们可以用它来扩展绑定变量的作用域——这通常是我们想要的。

假设我们有下面这样的代码:

if(xinstanceofFoof){useFoo(f);}elsethrownewNotFooException();

这段代码没有问题,但有点麻烦。很多开发者更喜欢将其重构成:

if(!(xinstanceofFoof))thrownewNotFooException();useFoo(f);

两者的作用是一样的,但后者减少了阅读代码的认知负担。关键的代码路径就在最外层,而不是从属于某个if(或者更糟糕的是,一个深度嵌套的if),在我们的感知中处于前端和中心。此外,通过在进入方法时检查先决条件,并在先决条件失败时抛出异常,阅读代码的人就不需要在头脑中保留“如果它不是Foo该怎么办”的场景,因为前置条件失败已经提前处理过了。

在后者的代码中,f的作用域涵盖了方法的剩余部分,因为它经过明确的赋值:如果x不是Foo,if就会抛出异常,就不会到达useFoo(),如果x是Foo,就会将x强制转换类型后的结果赋值给f。但是,如果没有流式作用域,我们就必须这样写:

if(!(xinstanceofFoof))thrownewNotFooException();else{useFoo(f);}

对于这种情况,一些开发者不仅会因为关键代码被缩进到else块中而感到恼火,而且随着先决条件数量的增加(特别是当一个条件依赖于另一个条件时),结构会变得更加复杂,关键代码也会越来越向右移。

另一个需要考虑到的新问题是模式匹配与泛型的相互作用。在我们的EnumMap示例中,我们不仅要测试目标对象的类,还要测试它的类型参数:

publicEnumMap(MapK,?extsVm){if(minstanceofEnumMapK,?extsVem){//optimizedcopyofmapstatefromem}

我们知道,在Java中,泛型会被擦除的,我们不能问运行时无法回答的问题。这是怎么回事?这里的类型测试有一个静态组件和一个动态组件。编译器知道EnumMapK,V是MapK,V的子类型(从EnumMap的声明看出来:classEnumMapK,VimplementsMapK,V),所以如果一个MapK,V是EnumMap(动态测试),那它一定是EnumMapK,V。编译器检查类型模式中的类型参数是否与目标的已知信息一致。如果将目标转换为要测试的类型会导致未检查的转换异常,则不允许使用该模式。因此,我们可以在instanceof中使用类型参数,但仅限于可以静态验证其一致性的情况。

未来的方向

显然,下一个需要添加模式匹配支持的地方是switch语句,这个语句目前仅限于一组狭窄的类型(数字、字符串和枚举)和一组狭窄的条件(常量比较)。除了常量之外,在case中引入模式戏剧性地增加了switch的表达能力,我们可以对所有类型进行switch,并表达更有趣的多路条件语句,而不仅仅是一组常量的比较。

在Java中引入模式匹配的一个更重要的原因是,它为我们提供了一种更有原则的方法,可以将聚合分解为状态组件。Java的对象通过聚合和封装为我们提供了抽象。通过聚合,我们将数据从特定抽象成一般,而封装帮助我们确保聚合数据的完整性。但是,我们却常常为此付出高昂的代价。为了让使用者可以查询对象的状态,我们需要提供API,以一种受控的方式(比如提供访问器)查询对象的状态。但这些用于状态访问的API通常是临时性的,创建对象的代码与分解对象的代码看起来完全不一样(我们用newPoint(x,y)来构造一个Point,但却通过调用getX()和getY()来恢复状态)。模式匹配通过将解构引入到对象模型中来解决这个长期存在的差别。

这方面的一个例子是Record的解构模式。Record是一种简洁的透明数据类。这种透明性意味着它们的构造是可逆的。正如Record可以自动获取大量的成员(构造函数、访问器、Object方法),它们也可以自动获得解构模式,也就是所谓的“逆向构造函数”——构造函数将状态聚合成一个对象,解构模式将对象解构回状态。如果我们用下面的代码构造一个Shape:

Shapes=newCircle(newPoint(3,4),5);

就可以用下面的代码来解构:

if(sinstanceofCircle(Pointcenter,intradius)){//centerandradiusinscopehere}

Circle(Pointcenter,intradius)是一个解构模式。它检查目标对象是否为Circle,如果是,则将其转换为Circle,并提取中心和半径组件(如果是Record,它会通过调用相应的访问器方法来实现)。

解构模式也为组合提供了机会。Circle的Point组件本身也是一个可以解构的聚合体,我们可以用嵌套的方式表示为:

if(sinstanceofCircle(Point(intx,inty),intradius){//x,y,andradiusallinscopehere}

在提取Circle的center组件之后,我们进一步将结果与Point(varx,vary)模式匹配。这里有几个对称的地方。首先,构造和解构的句法表达在结构上是相似的——我们可以使用相似的语法来构建对象,并将其分解。(如果是Record,两者都可以基于Record的状态描述来获得。)在此之前,这是一种显著的不对称。我们用构造函数构建对象,然后通过API调用(比如getter)把它们拆开,与用于聚合的习惯性用法一点也不一样。这种不对称给开发者增加了认知负担,也给漏洞提供了藏身之处。其次,构造和解构现在以相同的方式进行组合——我们可以在Circle的构造函数中嵌套Point的构造函数,也可以在Circle的解构模式中嵌套Point的解构模式。

Record、封印类和解构模式以一种令人愉快的方式协同工作。假设我们有这样一组表达式树:

sealedinterfaceNode{recordConstNode(inti)implementsNode{}recordNegNode(Noden)implementsNode{}recordAddNode(Nodeleft,Noderight)implementsNode{}recordMultNode(Nodeleft,Noderight)implementsNode{}}

我们可以用switch写一个计算器,如下所示:

inteval(Noden){returnswitch(n){caseConstNode(inti)-i;caseNegNode(varnode)--eval(node);caseAddNode(varleft,varright)-eval(left)+eval(right);caseMulNode(varleft,varright)-eval(left)*eval(right);//nodefaultneeded,Nodeissealedandwecoveredallthecases};}

使用switch比if-else更简洁,更不容易出错,而且switch表达式知道,如果我们已经覆盖了一个封印类所有允许的子类型,那么就得到了总数,不再需要默认的case。

Record和封印类有时候被称为代数数据类型。通过在Record上添加模式匹配并对模式进行switch,我们可以安全而简便地抽取代数数据类型。(具有内置元组和sum类型的语言也往往提供了内置的模式匹配,这并非偶然。)

模式匹配的路线图比这里描述的要长得多——允许普通类同时声明构造函数和解构模式,以及静态工厂和静态模式(例如,(varcontents))。总之,我们希望这将引领一个更加“对称”的API设计时代。在这个时代,我们可以轻松地拆解对象(当然,只在我们想要这样做的时候),就像构造对象一样简单。

没有被采纳的选项

长期以来,编译器一直被要求能够根据过去的条件推断出细化的类型(通常称为流类型)。例如,如果我们有一个以xinstanceofFoo为条件的if语句,编译器可以推断出:在if语句体中,类型可以细化为XFoo交集类型(其中X是x的静态类型)。这样也可以消除强制类型转换,但我们为什么不这样做呢?简单地说是因为这个特性不够强大。流类型解决了这个特定的问题,但提供了非常低的回报,因为它的结果只是摆脱instanceof之后的类型转换,并没有提供更丰富的switch、解构或更好的API(就语言特性而言,它更像是一个“创可贴”,而不是一种真正的增强)。

类似地,另一个长期存在的请求是“类型switch”,也就是可以switch目标对象类型,而不仅仅是常量值。同样,这也提供了一个切实的好处——将一些“if-else”换为switch——但同样,这在整体上给语言带来的改进非常小。模式匹配给我们带来了这些好处——但还远远不止这些。

总结

模式匹配是一种强大的特性,将在Java的几个版本中发挥重要作用。在第一阶段,我们可以在instanceof中使用类型模式,以此来减少仪式代码。在未来,我们可以在switch中使用模式,可以使用Record的解构模式,可以像构造对象一样简便地解构对象。

BrianGoetz是Oracle的Java语言架构师,也是JSR-335(Java编程语言的Lambda表达式)规范负责人。他是畅销书《Java并发实践》(JavaConcurrencyinPractice)的作者。从吉米·卡特担任美国总统以来,他一直着迷于编程。

JavaFeatureSpotlight:PatternMatching

延伸阅读:

JavaonTruffle:实现真正的元循环-InfoQ