JEP 405:记录模式 & 数组模式(预览)
原文链接: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
的实例,如果是,则从其中提取 x
和 y
组件:
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()
,它们返回组件 x
和 y
(每个记录类都有和组件一一对应的访问器方法)。如果这个模式不只能测试值是否为 Point
的实例,还能从中直接提取 x
和 y
组件的话,那就更好了。换句话说:
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
点的颜色则会更麻烦,因为它必须处理 ul
为 null
的可能性:
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)r
是 Rectangle
的实例,并且(2)r
的 upperLeft
组件匹配嵌套的记录模式 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);
为了支持可变元数组件的匹配,记录模式可以是可变元数的。例如,给定一个 MultiColoredPoint
值 m
:
- 当
m
的cols
组件为空时,m
匹配模式MultiColoredPoint(var a, var b)
; - 当
m
的cols
组件是只有一个元素的数组时,m
匹配模式MultiColoredPoint(var a, var b, var first)
; - 当
m
的cols
组件是至少有一个元素的数组时,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
)、AddExpr
和 MulExpr
(包含两个 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 中实现。