原文链接:JEP 405: Record Patterns & Array Patterns (Preview)

摘要

通过引入用于结构记录(Record)值的记录模式(Record Pattern)和用于解构数组值的数组模式(Array Pattern)增强 Java 编程语言。记录模式、数组模式和类型模式(Type Pattern,JEP 394)可以嵌套使用,从而显著增强模式匹配的表达能力和实用性。

目标

  • 扩展模式匹配以支持更复杂、可组合的数据查询。
  • 不更改类型模式的语法和语义。

动机

在 Java 16 中,JEP 394 扩展了 instanceof 操作符,使其能够使用类型模式(Type Pattern)执行模式匹配(Pattern Matching)。这种适度扩展可以简化我们常见的 instanceof 后使用强制转换的惯用法:

// Old code
if (o instanceof String) {
    String s = (String)o;
    ... use s ...
}

// New code
if (o instanceof String s) {
    ... use s ...
}

在新代码中,如果在运行时可以将 o 的值强制转换为 String 而不抛出 ClassCastException,则 o 与类型模式 String s 匹配。如果匹配成果,则 instanceof 表达式的值为 true,同时模式变量 s 将会被初始化为类型为 String 的值 o,且能够在包含它的块中使用。

类型模式消除了很多强制类型转换。这只是向更具表现力、更 null 安全的编程风格迈出的第一步。由于 Java 支持了新的更具表现力的数据建模方式,因此模式匹配能够识别数据模型的语义来简化此类数据的使用方式。

模式匹配与记录类(Record Class)

记录类(JEP 395)是透明的数据载体。代码在接收到一个记录类的实例后通常需要提取作为其组件(Component)的数据。例如,我们可以使用类型模式来判断值是否为记录类 Point 的实例,如果是,则从其中提取 xy 组件:

record Point(int x, int y) {}

static void printSum(Object o) {
    if (o instanceof Point p) {
        int x = p.x();
        int y = p.y();
        System.out.println(x+y);
    }
}

变量 p 有点多余——它仅用于调用访问器方法 x()y(),它们返回组件 xy(每个记录类都有和组件一一对应的访问器方法)。如果这个模式不只能测试值是否为 Point 的实例,还能从中直接提取 xy 组件的话,那就更好了。换句话说:

record Point(int x, int y) {}

void printSum(Object o) {
    if (o instanceof Point(int x, int y)) {
        System.out.println(x+y);
    }
}

Point(int x, int y) 是一个记录模式(Record Patten)。它将模式中声明的用于提取组件的局部变量声明提升到模式本身中,并在值与模式相匹配时通过调用访问器方法初始化这些变量。实际上,记录模式会把一个记录实例分解成其组件。(只会给组件命名,而不会为 Point 本身命名;在未来我们可能会提供一种实现后者的方式)

然而,模式匹配真正的威力在于它可以强力的扩展以匹配更复杂的对象图。例如,可以考虑以下声明:

record Point(int x, int y) {}
enum Color { RED, GREEN, BLUE }
record ColoredPoint(Point p, Color c) {}
record Rectangle(ColoredPoint upperLeft, ColoredPoint lowerRight) {}

我们已经知道我们可以使用记录模式从对象中提取组件:

static void printUpperLeftColoredPoint(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint ul, ColoredPoint lr)) {
        System.out.println(ul);
    }
}

但是如果代码需要输出 ul 点的颜色则会更麻烦,因为它必须处理 ulnull 的可能性:

static void printColorOfUpperLeftPoint(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint ul, ColoredPoint lr)) {
        if (ul != null) {
            return;
        }
        Color c = ul.c();
        System.out.println(c);
    }
}

模式匹配允许我们解构对象,而不必担心 null 或者 NullPointerException。这使得代码比 Java 以前允许的任何方式都更安全和清晰。例如,我们可以使用嵌套记录模式(Nested Record Patten)ColoredPoint 开始解构对象图:

static void printColorOfUpperLeftPoint(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint(Point p, Color c), ColoredPoint lr)) {
        System.out.println(c);
    }
}

记录模式 Rectangle(ColoredPoint(Point p, Color c), ColoredPoint lr) 包含嵌套的记录模式 ColoredPoint(Point p, Color c)。如果(1)rRectangle 的实例,并且(2)rupperLeft 组件匹配嵌套的记录模式 ColoredPoint(Point p, Color c),则值 r 匹配这个记录模式。

模式匹配的可读性随着对象图的复杂性变化,因为嵌套记录模式可以比传统的命令式代码更平滑、更简洁的从对象中提取数据。例如,要从一个矩形中一路向下提取到其左上角点的 x 坐标,我们通常会一步步地浏览对象图:

static void printXCoordOfUpperLeftPointBeforePatterns(Rectangle r) {
    if (r == null) {
        return;
    }
    ColoredPoint ul = r.upperLeft();
    if (ul == null) {
        return;
    }
    Point p = ul.p();
    if (p == null) {
        return;
    }
    int x = p.x();
    System.out.println("Upper-left corner: " + x);
}

模式匹配消除了浏览对象意外的复杂性,并把重点放在对象表示的数据上:

static void printXCoordOfUpperLeftPointWithPatterns(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint(Point(var x, var y), var c), var lr)) {
        System.out.println("Upper-left corner: " + x);
    }
}

最后,记录类可以有可变元数的记录组件,例如:

record MultiColoredPoint(int i, int j, Color... cols) { }

// Create some records
var origin   = new MultiColoredPoint(0, 0);
var red      = new MultiColoredPoint(1, 1, RED);
var colorful = new MultiColoredPoint(2, 2, RED, GREEN);

为了支持可变元数组件的匹配,记录模式可以是可变元数的。例如,给定一个 MultiColoredPointm

  • mcols 组件为空时,m 匹配模式 MultiColoredPoint(var a, var b)
  • mcols 组件是只有一个元素的数组时,m 匹配模式 MultiColoredPoint(var a, var b, var first)
  • mcols 组件是至少有一个元素的数组时,m 匹配模式 MultiColoredPoint(var a, var b, var first, ...)

总而言之,记录模式促进了 Java 中一种更具表达力、更 null 安全、面向表达式的编程风格。

模式匹配与数组

我们可以将模式匹配扩展到其他为数据建模的引用类型的值上。一个明显的候选者是数组类型。例如,当我们想要检查一个 Object 是否为 String 数组,并且其中至少有两个元素需要被提取出和输出。我们可以这样使用类型模式实现:

static void printFirstTwoStrings(Object o) {
    if (o instanceof String[] sa && sa.length >= 2) {
        String s1 = sa[0];
        String s2 = sa[1];
        System.out.println(s1 + s2);
    }
}

模式变量的流敏感(flow-sensitive)作用域意味着我们可以在 && 操作符的右侧和 if 块中使用模式变量 sa。但是在提取数据组件前检查数组长度像在访问记录组件前检查 null 一样繁琐。由于访问数组组件很常见,因此如果模式不仅可以检查值是否为数组,还能隐式检查数组的长度并直接表示数组的组件,那就更好了。换句话说:

static void printFirstTwoStrings(Object o) {
    if (o instanceof String[] { String s1, String s2, ... }){
        System.out.println(s1 + s2);
    }
}

String[] {String s1, String s2, ...} 是一个数组模式(Array Pattern)。如果值(1)是一个 String 数组,并且(2)它含有至少两个组件(... 模式匹配零或者多个额外组件),则值与该模式匹配。如果匹配成功,则 s1 被初始化为数组的第一个组件,s2 被初始化为数组的第二个组件。如果一个 String 数组有恰好两个元素,则它匹配不包含 ... 的模式 String[] {String s1, String s2 }

数组模式的语法对应了初始化数组的语法。换句话说,表达式 new String[] { "One", "Two", "Three" } 的值匹配模式 String[] { String s1, String s2, String s3 }

Java 支持多维数组,其数组的组件本身也是数组值。因而数组模式支持匹配多维数组的值。例如,如果一个值是恰好有两个 String 数组组件的数组,则该值与模式 String[][] { var sa1, var sa2 } 匹配。

此外,我们还支持通过嵌套数组模式(Nested Array Patten)就地匹配数组的组件。例如,如果一个值是包含至少两个 String 数组的数组,而第二个 String 数组至少包含一个元素,则该值匹配模式 String[][] { var firstComponent, { String s1, ...}, ...}。如果匹配成功,则模式变量 firstComponent 被初始化为第一个数组组件的值,s1 被初始化为第二个数组组件的第一个元素的值。

嵌套模式提供了强大表达能力。例如,我们可以在数组模式中自由嵌套一个记录模式。以下方法打印存储在数组中的前两个点的 x 坐标之和:

static void printSumOfFirstTwoXCoords(Object o) {
    if (o instanceof Point[] { Point(var x1, var y1), Point(var x2, var y2), ... }) {
        System.out.println(x1 + x2);
    }
}

描述

我们通过提供两种新的模式——记录模式和数组模式——来扩展模式语言,这两种模式都支持嵌套模式。

模式的语法将变成:

Pattern:
  TypePattern
  ArrayPattern
  RecordPattern

TypePattern:
  LocalVariableDeclaration

ArrayPattern:
  ArrayType ArrayComponentsPattern

ArrayComponentsPattern:
  { [ ComponentPatternList [ , ...  ]  ] }

ComponentPatternList:
  ComponentPattern { , ComponentPattern }

ComponentPattern:
  Pattern
  ArrayComponentsPattern

RecordPattern:
  ReferenceType ( [ ArgumentPatternList ] [ , ... ] )

ArgumentPatternList:
  ArgumentPattern { , ArgumentPattern }

ArgumentPattern:
  Pattern

数组模式

数组模式(Array Pattern)由数组类型和一个可能为空的、用于和相应的数组组件匹配的组件模式(Component Patten)列表组成。可选的,数组模式可以以 ... 记号结束,该记号匹配任意数量(包括零)的剩余数组组件。

例如,匹配数组模式

String[] { String s1, String s2 }

的值必须是一个恰好有两个元素的 String 数组。

相反,匹配数组模式

String[] { String s1, String s2, ... }

的值必须是一个至少有两个元素的 String 数组。

null 值不匹配任意数组模式、

数组模式声明的模式变量集是组件模式声明的模式变量集合的并集。

数组模式支持多维数组的匹配。例如,匹配数组模式

String[][] { { String s1, String s2, ...}, { String s3, String s4, ...}, ...}

的值必须是一个至少有两个组件的数组,其前两个组件必须是至少有两个元素的 String 数组。

组件模式可以使用 var 与数组组件匹配,而无需再标明组件的类型。模式变量的类型是从模式本身推断出来的。例如,如果一个值匹配模式

String[] { var s1, ... }

则模式变量 s1 被推断为 String 类型,并被初始化为数组第一个组件的值。

var 也适用于多维数组。例如,如果值匹配数组模式

String[][] { var firstComponent, { String s3, String s4, ...}, ...}

firstComponent 的值可以进一步匹配模式:

String[] { String s1, String s2, ... }

如果表达式与数组模式中包含的数组类型向下转换兼容(downcast compatible)*(JLS §5.5),则表达式与数组模式兼容。

记录模式

记录模式(Record Pattern)由一个类型和一个可能为空的、用于于相应的记录组件进行匹配的参数模式(Argument Pattern)列表组成。可选的,当记录类有一个可变元数记录组件(必须为最后一个组件)的情况下,记录模式可以以 ... 记号结束,它与任意数量(包括零)的剩余记录组件匹配。

例如,给定这样一个记录声明

record Point(int i, int j) {}

如果一个值是记录类型 Point 的实例,则它匹配模式 Point(int a, int b);如果匹配成功,则模式变量 a 被初始化为在值上调用与组件 i 对应的访问器方法的结果,模式变量 b 被初始化为在值上调用与组件 j 对应的访问器方法的结果。

null 值不匹配任意记录模式。

记录模式可以使用 var 与记录组件匹配,而无需再标明组件的类型。在本例中,编译器推断 var 模式引入的模式变量的类型。例如,模式 Point(var a, varb) 是模式 Point(int a, int b) 的简写。

记录模式声明的模式变量集是参数模式声明的模式变量集合的并集。

在匹配可变元数记录组件时,记录模式可以用 ... 记号。这种可变元数记录模式(Variable-Arity Record Pattern) 是包含嵌套的可变元数数组模式的固定元数记录模式的简写。例如,给定下面的前置声明

record MultiColoredPoint(int i, int j, Color... cols) {}

这样的可变元数记录模式

MultiColoredPoint(var a, var b, var firstColor, var secondColor, ...)

是模式

MultiColoredPoint(var a, var b, Color[] { var firstColor, var secondColor, ... })

的简写。

该简写对应了实例化可变元数记录类的简写语法。例如,这样的表达式

new MultiColoredPoint(42, 0, RED, GREEN, BLUE)

是它的简写:

new MultiColoredPoint(42, 0, new Color[] { RED, GREEN, BLUE })

如果表达式与模式中包含的记录类型向下转换兼容,则表达式与记录模式兼容。

未来的工作

在常用模式匹配的 Java 程序中,添加新的模式形式是一个重要的步骤。

命名记录和数组模式

记录模式和数组模式都提供了解构值的方法,但它们不提供同时命名被解构的值的方法。在其他具有类似解构模式的语言中的经验表明,需要同时命名和解构一个值的情况相对较少。默认情况下支持这一点需要开发人员使用大量 dummy 名称,或者使用很多弃元模式,这都会增加大量语法混乱。

一些语言引入了一种新的模式形式,通常称为 as pattern,专门用于解构的同时命名值。

弃元模式

通常,在结构化对象中的一些组件不希望被显式声明为模式变量。例如:

void int getXfromPoint(Object o) {
    if (o instanceof Point(var x, var y)){
        return x;
    }
    return -1;
}

在这个方法中,模式变量 y 是完全冗余的。一些提议建议 Java 使用 _ 符号来表示不需要命名的参数,一个可能的扩展是允许类似 Point(var x, var _) 这样的模式。不过也可能可以去除 var ,或者为 var _ 添加语法糖。

增强数组模式

虽然前文描述的数组模式很有用,但我们还能添加其他功能。例如,假设我们需要匹配一个 String 数组,我们只对数组的第八个和第九个元素感兴趣。当前我们需要一个类似 String[]{ var dummy1, var dummy2, var dummy3, var dummy4, var dummy5, var dummy6, var dummy7, var eightElement, var ninthElement, ... } 的模式,这非常麻烦。某种基于索引的组件模式在这种情况下可能会很有用,类似这样:String[] { [8] -> var eighthElement, [9] -> var ninthElement}

解构模式

记录模式解构记录类型的值。我们希望最终能让所有类支持这个功能,而不仅仅是记录类。我们把这种通用的分解称之为解构(deconstruction),以表明它在构造过程中的二元性。

对于记录类而言,如何解构实例是显而易见的。对于一般类,这需要一个解构模式(deconstruction pattern)的显式声明来描述如何解构类的实例。

避开如何声明解构模式的语法细节,使用解构模式可以写出非常优雅的代码。例如我们有一个类 Expr,它有子类 IntExpr(包含一个 int)、AddExprMulExpr(包含两个 Expr)、NegExpr(包含一个 Expr),那我们可以只用一步匹配 Expr 并对特定的子类型进行操作:

int eval(Expr n) {
    return switch(n) {
        case IntExpr(int i) -> i;
        case NegExpr(Expr n) -> -eval(n);
        case AddExpr(Expr left, Expr right) -> eval(left) + eval(right);
        case MulExpr(Expr left, Expr right) -> eval(left) * eval(right);
        default -> throw new IllegalArgumentException(n);
    };
}

我们进一步设想,类 Expr 实际上是一个 sealed 类(JEP 397),只允许有上面四个子类,那么编译器可以推断出 default 规则是不必要的。

现在,我们为了表达这样的 ad-hoc polymorphic 计算,我们需要使用繁琐的访问者模式。在未来使用模式匹配可以让代码变得简单而透明。

依赖

这个 JEP 基于JEP 394(instanceof 模式匹配),在 Java 16 中实现。