跳到主要内容

JEP 456:Unnamed Variables & patterns

QWen Max 中英对照 JEP 456: Unnamed Variables & Patterns

总结

通过未命名的变量和未命名的模式来增强 Java 编程语言,这些变量和模式可以在需要变量声明或嵌套模式但从未使用过的情况下使用。两者都用下划线字符 _ 表示。

历史

未命名变量和未命名模式最早在 JDK 21 中通过 JEP 443 进行了预览,该提案标题为 Unnamed Patterns and Variables(未命名模式与变量)。我们在此建议在不做更改的情况下将此功能最终确定。

目标

  • 捕获开发者的意图,即某个绑定或 lambda 参数未被使用,并强制执行该属性,以澄清程序并减少出错的机会。

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

  • 允许在单个 case 标签中出现多个模式,前提是它们都没有声明任何模式变量。

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

非目标

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

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

动机

开发人员有时会声明他们并不打算使用的变量,这可能是出于代码风格的考虑,或者因为语言在某些情况下要求声明变量。在编写代码时,不使用的意图是已知的,但如果这种意图没有被明确地捕捉到,后来的维护者可能会不小心使用该变量,从而违背了初衷。如果我们能够使这些变量无法被意外使用,那么代码将更具信息性、更易读,并且更不容易出错。

未使用的变量

在代码的副作用比其结果更重要的情况下,声明一个从未使用过的变量的需求尤为常见。例如,以下代码将 total 作为循环的副作用进行计算,而未使用循环变量 order

static int count(Iterable<Order> orders) {
int total = 0;
for (Order order : orders) // order is unused
total++;
return total;
}

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

再举一个表达式的副作用比其结果更重要的例子,以下代码对数据进行出队操作,但每三个元素中只需要两个:

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 只是冗余内容,因此最好省略它。

  • 异常是最终的副作用,处理异常时常常会产生未使用的变量。例如,大多数开发者都写过这种形式的 catch 块,其中异常参数 ex 未被使用:

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

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

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

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

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

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

未使用的模式变量

局部变量也可以通过类型模式声明 —— 这样的局部变量被称为 模式变量 —— 因此类型模式也可以声明未使用的变量。考虑以下代码,它在 switch 语句的 case 标签中使用了类型模式,该语句针对一个 sealedBall 的实例进行切换:

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

Ball ball = ...
switch (ball) {
case RedBall red -> process(ball);
case BlueBall blue -> process(ball);
case GreenBall green -> stopProcessing();
}

switch 的用例使用类型模式检查 Ball 的类型,但模式变量 redbluegreen 并未在 case 子句的右侧使用。如果我们可以省略这些变量名,代码会更加清晰。

现在假设我们定义了一个记录类 Box,它可以容纳任何类型的 Ball,但也可能持有 null 值:

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

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

嵌套类型模式仍然声明了未使用的模式变量。由于此 switch 比前一个更复杂,因此在嵌套类型模式中省略未使用变量的名称会进一步提高可读性。

未使用的嵌套模式

我们可以将记录嵌套在记录中,从而导致数据结构的形状与其内部的数据项一样重要。例如:

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 ...
}

我们可以使用未命名的模式变量来降低视觉成本,例如 ColoredPoint(Point(int x, int y), Color _),但类型模式中出现的 Color 类型会分散注意力。我们可以通过使用 var 来消除它,例如 ColoredPoint(Point(int x, int y), var _),但嵌套的类型模式 var _ 仍然显得过于繁重。通过完全省略不必要的组件来进一步降低视觉成本将会更好。这不仅会简化编写记录模式的任务,还能通过去除代码中的杂乱部分提高可读性。

描述

未命名变量 是通过使用下划线字符 _(U+005F)来声明的,该字符在局部变量声明语句中代替局部变量的名称,或者在 catch 子句中作为异常参数,或者在 lambda 表达式中作为 lambda 参数。

未命名的模式变量是通过使用下划线字符代替类型模式中的模式变量来声明的。

未命名模式 用下划线字符表示,等价于未命名的类型模式 var _。它允许在模式匹配中省略记录组件的类型和名称。

单个下划线字符是表示名称缺失的最轻量合理的语法。它在其他语言(如 Scala 和 Python)中常用于此目的。最初,在 Java 1.0 中,单个下划线是一个有效的标识符,但后来我们将其重新定义为用于未命名变量和模式:在 Java 8(2014 年)中,当下划线用作标识符时,我们开始发出编译时警告,并在 Java 9(2017 年,JEP 213)中将其从语言规范中移除,从而将这些警告转为错误。

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

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

未命名变量

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

  • 块中的局部变量声明语句(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)。

声明未命名的变量不会将名称置于作用域中,因此该变量在初始化后无法被写入或读取。对于在局部变量声明语句中或 try-with-resources 语句的资源规范中声明的未命名变量,必须提供一个初始化器。

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

以下是上面给出的示例,重写为使用未命名变量的形式。

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

    static int count(Iterable<Order> orders) {
    int total = 0;
    for (Order _ : orders) // 未命名变量
    total++;
    return total;
    }

    简单 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()) {    // 未命名变量
    ... 不使用获取的资源 ...
    }
  • 参数无关紧要的 lambda 表达式:

    ...stream.collect(Collectors.toMap(String::toUpperCase,
    _ -> "NODATA")) // 未命名变量

未命名的模式变量

未命名的模式变量可以出现在类型模式中(JLS §14.30.1),包括 var 类型模式,无论该类型模式是出现在顶层还是嵌套在记录模式中。例如,Ball 示例现在可以写为:

switch (ball) {
case RedBall _ -> process(ball); // Unnamed pattern variable
case BlueBall _ -> process(ball); // Unnamed pattern variable
case GreenBall _ -> stopProcessing(); // Unnamed pattern variable
}

以及 BoxBall 示例:

switch (box) {
case Box(RedBall _) -> processBox(box); // Unnamed pattern variable
case Box(BlueBall _) -> processBox(box); // Unnamed pattern variable
case Box(GreenBall _) -> stopProcessing(); // Unnamed pattern variable
case Box(var _) -> pickAnotherBox(); // Unnamed pattern variable
}

通过允许我们省略名称,未命名的模式变量使得基于类型模式的运行时数据探索在视觉上更加清晰,无论是在 switch 块中还是与 instanceof 操作符一起使用时。

case 标签中的多种模式

目前,case 标签被限制为最多包含一个模式。随着未命名模式变量和未命名模式的引入,在单个 switch 块中更有可能出现多个具有不同模式但右侧相同的 case 子句。例如,在 BoxBall 示例中,前两个子句具有相同的右侧但模式不同:

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

我们可以通过允许前两个模式出现在同一个 case 标签中来简化问题:

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

因此,我们修订了 switch 标签的语法(JLS §14.11.1)以

SwitchLabel:
case CaseConstant {, CaseConstant}
case null [, default]
case CasePattern {, CasePattern } [Guard]
default

并定义具有多个模式的 case 标签的语义:如果值匹配任意一个模式,则视为匹配该值。

如果 case 标签具有多个模式,那么对于任何模式声明的模式变量,都属于编译时错误。

一个具有多个 case 模式的 case 标签可以包含一个guard。这个 guard 作用于整个 case,而不是单独的模式。例如,假设有一个 int 类型的变量 x,前面例子中的第一个 case 可以进一步约束为:

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

守卫是 case 标签的属性,而不是 case 标签内单个模式的属性,因此禁止编写多个守卫:

case .someCase(let value) where value > 10, .someOtherCase where someCondition:
// 这将导致编译错误
swift
case Box(RedBall _) when x == 0, Box(BlueBall _) when x == 42 -> processBox(b);
// compile-time error

未命名模式

未命名模式是一种无条件的模式,它可以匹配任何内容,但不声明也不初始化任何内容。与未命名类型模式 var _ 类似,未命名模式可以嵌套在记录模式中。然而,它不能用作顶级模式,例如,在 instanceof 表达式或 case 标签中。

因此,前面的例子完全可以省略 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 组件值。

重新审视 BoxBall 示例,我们可以通过使用未命名模式而不是 var _ 来进一步简化其最终的 case 标签:

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

风险与假设

  • 我们假设几乎没有正在积极维护的代码会使用下划线作为变量名。从 Java 7 迁移到 Java 22 的开发者如果没有看到 Java 8 中发出的警告或自 Java 9 以来发出的错误,可能会感到惊讶。他们在读取或写入名为 _ 的变量以及使用名称 _ 声明任何其他类型的元素(类、字段等)时,可能面临处理编译时错误的风险。

  • 我们预计静态分析工具的开发者将理解下划线对于未命名变量的新作用,并避免在现代代码中标记此类变量的未使用情况。

替代方案

  • 可以定义一个类似的概念,即未命名的方法参数。然而,这与规范(例如,重写具有未命名参数的方法意味着什么?)和工具(例如,如何为未命名参数编写 JavaDoc?)之间存在一些微妙的交互。这可能是未来 JEP 的主题。

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