写点什么

Java 新特性解析:模式匹配

2021 年 2 月 15 日

Java 新特性解析:模式匹配

本文要点


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

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

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

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

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


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


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


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


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


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


public EnumMap(Map<K, ? extends V> m) {    if (m instanceof EnumMap) {        EnumMap<K, ? extends V> em = (EnumMap<K, ? extends V>) m;        // optimized copy of map state from em    } else {        // insert elements one by one    }}
复制代码


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


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


public EnumMap(Map<K, ? extends V> m) {    if (m instanceof EnumMap<K, ? extends V> em) {        // optimized copy of map state from em    } else {        // insert elements one by one    }}
复制代码


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


在 instanceof 之后必须进行显式的类型转换,这是一种繁琐的操作,而融合这些操作的好处不仅仅是为了简洁(尽管简洁是美好的),它还消除了一个常见的错误来源。在剪切和粘贴 instanceof 及强制转换代码,容易在修改了 instanceof 的类型之后忘记修改强制转换类型,这就给了漏洞一个藏身之处。通过消除这个问题,我们可以消灭所有这种类型的 bug。


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


public boolean equals(Object o) {    if (!(o instanceof Point))        return false;    Point p = (Point) o;    return x == p.x && y == p.y;}
复制代码


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


public boolean equals(Object o) {    return (o instanceof Point p)        && x == p.x && y == p.y;}
复制代码


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


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

绑定变量的作用域

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


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


if (x instanceof Integer i) { ... }
复制代码


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


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


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


return (o instanceof Point p)    && x == p.x && y == p.y;
复制代码


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


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


if (x instanceof Foo f) {    // f in scope here}else {    // f not in scope here}
复制代码


类似地:


if (!(x instanceof Foo f)) {    // f not in scope here}else {    // f in scope here}
复制代码


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


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


if (x instanceof Integer num) { ... }else if (x instanceof Long num) { ... }else if (x instanceof Double num) { ... }
复制代码


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


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


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


if (x instanceof Foo f) {    useFoo(f);}else    throw new NotFooException();
复制代码


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


if (!(x instanceof Foo f))    throw new NotFooException();useFoo(f);
复制代码


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


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


if (!(x instanceof Foo f))    throw new NotFooException();else {    useFoo(f);}
复制代码


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


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


public EnumMap(Map<K, ? extends V> m) {    if (m instanceof EnumMap<K, ? extends V> em) {        // optimized copy of map state from em    }    ...
复制代码


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

未来的方向

到目前为止,这种模式匹配特征是极其有限的,只有一种模式(类型模式)和一种可以使用模式的地方(instanceof)。但即使能力有限,我们也已经获得了一个显著的好处:冗余的强制转换消失了,消除了冗余代码,使更重要的代码得到了更清晰的关注,同时消除了隐藏 bug 的地方。但这只是模式匹配将给 Java 带来的好处的开始。


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


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


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


Shape s = new Circle(new Point(3, 4), 5);
复制代码


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


if (s instanceof Circle(Point center, int radius)) {    // center and radius in scope here}
复制代码


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


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


if (s instanceof Circle(Point(int x, int y), int radius) {    // x, y, and radius all in scope here}
复制代码


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


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


sealed interface Node {    record ConstNode(int i) implements Node { }    record NegNode(Node n) implements Node { }        record AddNode(Node left, Node right) implements Node { }    record MultNode(Node left, Node right) implements Node { }}
复制代码


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


int eval(Node n) {    return switch (n) {        case ConstNode(int i) -> i;        case NegNode(var node) -> -eval(node);        case AddNode(var left, var right) -> eval(left) + eval(right);        case MulNode(var left, var right) -> eval(left) * eval(right);        // no default needed, Node is sealed and we covered all the cases    };}
复制代码


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


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


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

没有被采纳的选项

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


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

总结

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


作者简介


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


原文链接


Java Feature Spotlight: Pattern Matching

2021 年 2 月 15 日 22:336015

评论 1 条评论

发布
用户头像
这么说Kotlin还挺方便,直接if (a is A) { a.b() }就能用了。
2021 年 02 月 23 日 13:44
回复
没有更多了
发现更多内容

第二周作业

依赖倒置原则

Acker飏

极客大学架构师训练营

面向对象编程记录

asd945

架构师训练营第二周心得

努力努力再努力m

极客大学架构师训练营

架构师训练营第二周作业

努力努力再努力m

极客大学架构师训练营

架构师训练营第二周总结

毛叫

极客大学架构师训练营

编程的发展和设计的美

林昱榕

极客大学架构师训练营 编程的本质 面向对象的本质

「编程模型」C++代码组织

顿晓

c++ 命名空间 namespace 代码组织 编程模型

week2.课后作业

个人练习生niki

依赖倒置原则

架构师训练营第二课作业

曾祥斌

架构师训练营 - 第二周 - 作业

亮灯

架构师训练营第二次总结

+╮(╯▽╰)╭/>……

week2.学习总结

个人练习生niki

OOD

Kiroro

架构训练营第二周 - 作业

无心水

架构师 极客大学架构师训练营

架构师训练营 Week 02 总结

Wancho

面向对象设计

week02 学习总结

Just顾

架构师训练营学习总结(第二周)

战峰

架构师课程学习第二周心得

秤须苑

极客大学架构师训练营

框架设计

一点点..

架构师 0 期 | 设计模式练习

刁架构

极客大学架构师训练营

第二周学习总结

iHai

极客大学架构师训练营

架构师训练营第二周总结

陌生人

架构师训练营-作业2

进击的炮灰

架构师训练营第二周-作业 Cache优化

无心水

极客大学架构师训练营 ISP

万物互联=区块链+物联网

CECBC区块链专委会

AI 物联网 区块链技术 智能高效

面向对象编程原则

asd945

架构训练营第二周作业

Gavin

架构师训练营第二章作业

JUN

架构训练营第二周总结

Gavin

week02-作业

seki

Java 新特性解析:模式匹配-InfoQ