跳到主要内容

JEP 443:Unnamed Patterns and Variables(预览)

QWen Max 中英对照 JEP 443: Unnamed Patterns and Variables (Preview)

总结

通过 未命名模式 增强 Java 语言,该模式可以在不声明组件名称或类型的情况下匹配记录组件,同时引入 未命名变量,这些变量可以初始化但不能使用。两者都通过下划线字符 _ 表示。这是一个 预览语言功能

目标

  • 通过省略不必要的嵌套模式来提高记录模式的可读性。

  • 通过识别必须声明(例如,在 catch 子句中)但不会使用的变量,提高所有代码的可维护性。

非目标

  • 不允许使用未命名的字段或方法参数并不是目标。

  • 更改局部变量的语义并不是目标,例如,在确定赋值分析中。

动机

未使用的模式

记录(JEP 395)与记录模式(JEP 440)协同工作,简化数据处理。记录类将数据项的各个组件聚合为一个实例,而接收记录类实例的代码则使用记录模式通过模式匹配将实例分解为其组件。例如:

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

... new ColoredPoint(new Point(3,4), Color.GREEN) ...

if (r instanceof ColoredPoint(Point p, Color c)) {
... p.x() ... p.y() ...
}

在此代码中,程序的一部分创建了 ColoredPoint 实例,而另一部分使用 instanceof 进行模式匹配,以测试变量是否为 ColoredPoint,如果是,则提取其两个组成部分。

ColoredPoint(Point p, Color c) 这样的记录模式具有很好的描述性,但程序通常只需要其中的一些组件来进行进一步处理。例如,上面的代码在 if 块中只需要 p,而不需要 c。每次进行这种模式匹配时,都写出记录类的所有组件是非常繁琐的。此外,Color 组件无关紧要这一点并不直观,这也使得 if 块中的条件更难以阅读。当记录模式嵌套使用以提取组件内的数据时,这一点尤为明显,例如:

if (r instanceof ColoredPoint(Point(int x, int y), Color c)) {
... x ... y ...
}

我们可以使用 var 来减少不必要的组件 Color c 的视觉开销,例如,ColoredPoint(Point(int x, int y), var c),但通过完全省略不必要的组件来进一步降低成本会更好。这将通过从代码中删除杂乱内容,既简化了编写记录模式的任务,又提高了可读性。

随着开发者对记录类的数据导向方法及其配套机制(密封类,JEP 409)积累更多经验,我们预计针对复杂数据结构的模式匹配将变得普遍。通常情况下,结构的形状将与其内部的单个数据项同样重要。作为一个高度简化的示例,请考虑以下 BallBox 类,以及一个用于探索 Box 内容的 switch 语句:

sealed abstract class Ball permits RedBall, BlueBall, GreenBall { }
final class RedBall extends Ball { }
final class BlueBall extends Ball { }
final class GreenBall extends Ball { }

record Box<T extends Ball>(T content) { }

Box<? extends Ball> b = ...
switch (b) {
case Box(RedBall red) -> processBox(b);
case Box(BlueBall blue) -> processBox(b);
case Box(GreenBall green) -> stopProcessing();
}

每个 case 都会根据 Box 的内容来处理它,但变量 redbluegreen 并未被使用。由于这些变量未被使用,如果我们可以省略它们的名称,这段代码的可读性会更高。

此外,如果将 switch 重构为将前两个模式组合在一个 case 标签中:

case Box(RedBall red), Box(BlueBall blue) -> processBox(b);

那么为这些组件命名就是错误的:这两个名称都无法在右侧使用,因为左侧的任一模式都可能匹配。由于这些名称无法使用,如果我们可以省略它们,那就更好了。

未使用的变量

转向传统的命令式代码,大多数开发者都遇到过不得不声明一个他们并不打算使用的变量的情况。这种情况通常发生在语句的副作用比其结果更重要的时候。例如,以下代码在循环的副作用中计算 total,而没有使用循环变量 order

int total = 0;
for (Order order : orders) {
if (total < LIMIT) {
... total++ ...
}
}

鉴于 order 并未被使用,其声明的显眼程度令人遗憾。该声明可以简化为 var order,但无法避免给这个变量命名。名称本身可以缩短为例如 o,但这种语法技巧并不能传达该变量将不会被使用的语义意图。此外,静态分析工具通常会抱怨未使用的变量,即使开发者本就打算不使用它,并且可能没有方法来消除这些警告。

下面是一个表达式的副作用比其结果更重要的示例,这导致了未使用的变量。以下代码对数据进行出队操作,但每三个元素中只需要两个:

std::queue<int> q;
// ... (假设队列已填充)

while (!q.empty()) {
int unused = q.front(); q.pop(); // 弹出并丢弃第一个元素
int x = q.front(); q.pop(); // 获取第二个元素
int y = q.front(); q.pop(); // 获取第三个元素

process(x, y); // 只处理 x 和 y
}
cpp

在这个例子中,unused 变量被声明但未使用,因为它的值无关紧要,重要的是通过 q.pop() 移除队列中的元素。这种模式可能会引发编译器警告或代码审查中的疑问,因此需要小心处理。

Queue<Integer> q = ... // x1, y1, z1, x2, y2, z2 .. 
while (q.size() >= 3) {
int x = q.remove();
int y = q.remove();
int z = q.remove(); // z is unused
... new Point(x, y) ...
}

第三次调用 remove() 会产生预期的副作用 —— 即使其结果未被赋值给变量,也会从队列中移除一个元素 —— 因此可以省略 z 的声明。然而,为了代码的可维护性,开发者可能希望始终通过声明变量来一致地表示 remove() 的结果,即使该结果当前并未被使用,即使这可能导致静态分析警告。不幸的是,在许多程序中,变量名的选择往往不像上面代码中的 z 那样容易决定。

未使用的变量经常出现在另外两类关注副作用的语句中:

  • try-with-resources 语句始终用于其副作用,即资源的自动关闭。在某些情况下,资源代表了 try 块代码执行的上下文;代码并不直接使用该上下文,因此资源变量的名称并不重要。例如,假设有一个 ScopedContext 资源是 AutoCloseable 的,以下代码获取并自动释放一个上下文:

    try (var acquiredContext = ScopedContext.acquire()) {
    ... acquiredContext 未被使用 ...
    }

    名称 acquiredContext 只是冗余内容,因此最好能省略它。

  • 异常是终极的副作用,处理异常时常常会产生未使用的变量。例如,大多数 Java 开发者都写过这种形式的 catch 块,其中异常参数的名称无关紧要:

    String s = ...;
    try {
    int i = Integer.parseInt(s);
    ... i ...
    } catch (NumberFormatException ex) {
    System.out.println("错误的数字: " + s);
    }

即使是没有任何副作用的代码,有时也必须声明未使用的变量。例如:

...stream.collect(Collectors.toMap(String::toUpperCase,
v -> "NODATA"));

此代码生成一个将每个键映射到相同占位符值的映射。由于 lambda 参数 v 未被使用,其名称无关紧要。

在所有这些变量未被使用且其名称无关紧要的场景中,如果我们能够简单地声明没有名称的变量,那就更好了。这将使维护人员不必费心去理解无关的名称,并且可以避免静态分析工具对未使用变量产生误报。

可以合理地声明为无名称的变量类型是那些在方法外部不可见的变量:局部变量、异常参数和 lambda 参数,如上所示。这些类型的变量可以重命名或设置为无名称,而不会产生外部影响。相比之下,字段(即使是 private 字段)也会在方法之间传递对象的状态,而无名称的状态既没有帮助性,也不具备可维护性。

描述

未命名模式 由下划线字符 _(U+005F)表示。它允许在模式匹配中省略记录组件的类型和名称;例如,

  • ... instanceof Point(int x, _)
  • case Point(int x, _)

当类型模式中的模式变量由下划线表示时,会声明一个未命名的模式变量。它允许类型模式中类型或 var 后面的标识符被省略;例如,

  • ... instanceof Point(int x, int _)
  • case Point(int x, int _)

当局部变量声明语句中的局部变量、catch 子句中的异常参数或 lambda 表达式中的 lambda 参数由下划线表示时,会声明一个未命名变量。它允许在语句或表达式中省略紧跟在类型或 var 后面的标识符;例如,

  • int _ = q.remove();
  • ... } catch (NumberFormatException _) { ...
  • (int x, int _) -> x + x

对于单参数的 lambda 表达式,例如 _ -> "NODATA",作为参数的未命名变量不应与未命名模式混淆。

单个下划线是表示名称缺失的最轻量合理的语法。由于在 Java 1.0 中它作为标识符是有效的,因此我们在 2014 年启动了一项长期计划,将其重新用于无名模式和变量。从 Java 8(2014 年)开始,当把下划线用作标识符时,我们开始发出编译时警告,并在 Java 9(2017 年,JEP 213)中将这些警告转为错误。许多其他语言,如 Scala 和 Python,使用下划线来声明没有名称的变量。

在长度为两个或更多字符的标识符中使用下划线的能力保持不变,因为下划线仍然是一个 Java 字母以及 Java 字母或数字。例如,诸如 _ageMAX_AGE__(两个下划线)这样的标识符依然是合法的。

使用下划线作为数字分隔符 的能力保持不变。例如,像 123_456_7890b1010_0101 这样的数字字面量仍然是合法的。

未命名模式

未命名的模式是一种无条件的模式,它不绑定任何内容。它可以用于嵌套位置,以替代类型模式或记录模式。例如,

  • ... instanceof Point(_, int y)

是合法的,但这些不是:

  • r instanceof _
  • r instanceof _(int x, int y)

因此,前面的例子完全可以省略 Color 组件的类型模式:

if (r instanceof ColoredPoint(Point(int x, int y), _)) { ... x ... y ... }

同样,我们可以在省略 Point 组件的记录模式的同时提取 Color 组件:

if (r instanceof ColoredPoint(_, Color c)) { ... c ... }

在深层嵌套的位置中,使用未命名模式可以提高执行复杂数据提取的代码的可读性。例如:

if (r instanceof ColoredPoint(Point(int x, _), _)) { ... x ... }

此代码提取嵌套 Pointx 坐标,同时忽略 yColor 组件。

未命名的模式变量

匿名模式变量可以出现在任何类型模式中,无论该类型模式是在顶层还是嵌套在记录模式中。例如,以下两种出现都是合法的:

  • r instanceof Point _
  • r instanceof ColoredPoint(Point(int x, int _), Color _)

通过允许我们省略名称,未命名的模式变量使基于类型模式的运行时数据探索在视觉上更加清晰,尤其是在 switch 语句和表达式中使用时。

未命名的模式变量在 switch 语句对多个情况执行相同操作时特别有用。例如,前面的 BoxBall 代码可以重写为:

switch (b) {
case Box(RedBall _), Box(BlueBall _) -> processBox(b);
case Box(GreenBall _) -> stopProcessing();
case Box(_) -> pickAnotherBox();
}

前两个案例使用了未命名的模式变量,因为它们的右侧并没有使用 Box 的组件。第三个案例是新增的,它使用未命名的模式来匹配一个带有 null 组件的 Box

一个具有多个模式的 case 标签可以有一个守卫。守卫控制的是整个 case,而不是单个模式。例如,假设有一个 int 类型的变量 x,前面例子中的第一个 case 可以进一步约束:

case Box(RedBall _), Box(BlueBall _) when x == 42 -> processBox(b);

不允许将守卫与每个模式配对,因此这是被禁止的:

case Box(RedBall _) when x == 0, Box(BlueBall _) when x == 42 -> processBox(b);

未命名模式是类型模式 var _ 的简写。无论是未命名模式还是 var _ 都不能在模式的顶层使用,因此以下所有情况都是不允许的:

  • ... instanceof _
  • ... instanceof var _
  • case _
  • case var _

未命名变量

以下种类的声明可以引入命名变量(由标识符表示)或匿名变量(由下划线 _ 表示):

  • 块中的局部变量声明语句(JLS 14.4.2),
  • try-with-resources 语句的资源规范(JLS 14.20.3),
  • 基本 for 语句的头部(JLS 14.14.1),
  • 增强型 for 循环的头部(JLS 14.14.2),
  • catch 块的异常参数(JLS 14.20),以及
  • lambda 表达式的形参(JLS 15.27.1)。

(模式声明未命名局部变量的可能性,即模式变量(JLS 14.30.1),已在上文涵盖。)

声明未命名的变量不会将名称放入作用域中,因此该变量在初始化后无法被写入或读取。对于上述每种声明类型,必须为未命名的变量提供一个初始化器。

未命名的变量永远不会遮蔽任何其他变量,因为它没有名称,所以可以在同一块中声明多个未命名的变量。

以下是上面的示例,经过修改以使用未命名变量。

  • 带有副作用的增强型 for 循环:

    int acc = 0;
    for (Order _ : orders) {
    if (acc < LIMIT) {
    ... acc++ ...
    }
    }

    基本 for 循环的初始化部分也可以声明未命名的局部变量:

    for (int i = 0, _ = sideEffect(); i < 10; i++) { ... i ... }
  • 赋值语句,右侧表达式的结果不需要使用:

    Queue<Integer> q = ... // x1, y1, z1, x2, y2, z2, ...
    while (q.size() >= 3) {
    var x = q.remove();
    var y = q.remove();
    var _ = q.remove();
    ... new Point(x, y) ...
    }

    如果程序仅需要处理 x1x2 等坐标,则可以在多个赋值语句中使用未命名变量:

    while (q.size() >= 3) {
    var x = q.remove();
    var _ = q.remove();
    var _ = q.remove();
    ... new Point(x, 0) ...
    }
  • catch 块:

    String s = ...
    try {
    int i = Integer.parseInt(s);
    ... i ...
    } catch (NumberFormatException _) {
    System.out.println("Bad number: " + s);
    }

    可以在多个 catch 块中使用未命名变量:

    try { ... } 
    catch (Exception _) { ... }
    catch (Throwable _) { ... }
  • try-with-resources 中:

    try (var _ = ScopedContext.acquire()) {
    ... no use of acquired resource ...
    }
  • 参数无关的 lambda 表达式:

    ...stream.collect(Collectors.toMap(String::toUpperCase, _ -> "NODATA"))

风险与假设

  • 我们假设现存的和维护中的代码很少使用下划线作为变量名。这类代码几乎可以肯定是为 Java 7 或更早版本编写的,并且无法使用 Java 9 或更高版本重新编译。对于此类代码的风险在于,当读取或写入名为 _ 的变量以及使用 _ 声明任何其他类型的实体(类、字段等)时会出现编译时错误。我们假设开发人员可以通过修改代码来避免将下划线用作变量或其他类型实体的名称,例如,将 _ 重命名为 _1

  • 我们期望静态分析工具的开发者能够意识到下划线在现代代码中作为未命名变量的新角色,并避免标记对这类变量的未使用情况。

替代方案

  • 可以定义一个类似的概念,即未命名的方法参数。然而,这与规范有一些交互作用(例如,如何为未命名的参数编写 JavaDoc?)和重写(例如,重写带有未命名参数的方法意味着什么?)。我们不会在本 JEP 中探讨它。

  • JEP 302(Lambda 剩余问题)研究了未使用的 lambda 参数的问题,并确定了使用下划线来表示它们的作用,但也涵盖了其他许多问题,这些问题在其他方面得到了更好的处理。本 JEP 解决了在 JEP 302 中探讨的未使用的 lambda 参数的使用问题,但不涉及在那里探讨的其他问题。