Skip to content

JEP 456: Unnamed Variables & Patterns | 未命名变量与模式

摘要

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

历史

未命名变量和未命名模式首先通过标题为 未命名模式和变量JEP 443 在 JDK 21 中进行了预览。我们在此提议将此功能定稿,不做任何更改。

目标

  • 捕获开发人员的意图,即给定的绑定或 lambda 参数是未使用的,并强制执行该属性,以明确程序并减少出错的机会。

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

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

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

非目标

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

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

动机

开发人员有时会声明不打算使用的变量,这可能是因为代码风格问题,或者因为语言在某些上下文中要求变量声明。编写代码时已知非使用的意图,但如果没有明确捕获,则后续的维护人员可能会意外地使用该变量,从而违反了原始意图。如果我们能够使这种变量不可能被意外使用,那么代码将更加具有信息性、可读性更强,并且出错的可能性更低。

未使用的变量

在代码中,有时需要声明一个从未使用过的变量,这在副作用比结果更重要的代码中尤为常见。例如,以下代码通过循环的副作用计算 total,但并未使用循环变量 order

java
static int count(Iterable<Order> orders) {
    int total = 0;
    for (Order order : orders)    // order 未使用
        total++;
    return total;
}

考虑到 order 并未被使用,其声明的突出显示是不幸的。虽然可以将声明简化为 var order,但无法避免给这个变量命名。名称本身可以缩短为例如 o,但这种语法技巧并不能传达变量从未被使用的意图。此外,即使开发人员有意不使用某个变量,静态分析工具通常也会对其发出警告,且开发人员可能无法使这些警告静音。

另一个例子是,当表达式的副作用比结果更重要时,以下代码出队数据但只需要每三个元素中的两个:

java
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 未使用
    ... new Point(x, y) ...
}

第三个对 remove() 的调用具有所需的副作用——出队一个元素——无论其结果是否赋值给变量,因此可以省略 z 的声明。然而,为了可维护性,该代码的作者可能希望通过声明变量来一致地表示 remove() 的结果。他们目前有两个都不理想的选项:

  • 不声明变量 z,这会导致不对称,并可能引发静态分析警告,警告忽略返回值;

  • 声明一个未使用的变量,并可能因此收到静态分析警告,指出存在未使用的变量。

未使用的变量在另外两个侧重于副作用的语句中也经常出现:

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

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

    名称 acquiredContext 只是多余的,因此最好省略它。

  • 异常是最终的副作用,处理异常时经常会出现未使用的变量。例如,大多数开发人员都写过这样的 catch 块,其中异常参数 ex 未被使用:

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

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

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

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

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

可以合理声明没有名称的变量类型包括方法外部不可见的变量:局部变量、异常参数和 lambda 参数,如上所示。这些类型的变量可以在不产生外部影响的情况下重命名或变为未命名。相比之下,字段——即使是 private 字段——也在方法之间传达对象的状态,而未命名的状态既无助于维护也不可行。

未使用的模式变量

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

java
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 的类型,但在 case 子句的右侧并未使用模式变量 redbluegreen。如果我们能够省略这些变量名,这段代码将更加清晰。

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

java
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 比之前的更复杂,省略嵌套类型模式中未使用变量的名称将进一步提高可读性。

未使用的嵌套模式

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

java
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 块中的条件更难阅读。当记录模式嵌套以提取组件内的数据时,这一点尤其明显,如:

java
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 循环:

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

    简单 for 循环的初始化也可以声明无名的局部变量:

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

    java
    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 等坐标,则可以在多个赋值语句中使用无名变量:

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

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

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

    java
    try { ... }
    catch (Exception _) { ... }                // 无名变量
    catch (Throwable _) { ... }                // 无名变量
  • try-with-resources 中:

    java
    try (var _ = ScopedContext.acquire()) {    // 无名变量
        ... 没有使用获取的资源 ...
    }
  • 参数不相关的 Lambda:

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

未命名模式变量

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

java
switch (ball) {
    case RedBall _   -> process(ball);          // 未命名模式变量
    case BlueBall _  -> process(ball);          // 未命名模式变量
    case GreenBall _ -> stopProcessing();       // 未命名模式变量
}

以及 BoxBall 的示例:

java
switch (box) {
    case Box(RedBall _)   -> processBox(box);   // 未命名模式变量
    case Box(BlueBall _)  -> processBox(box);   // 未命名模式变量
    case Box(GreenBall _) -> stopProcessing();  // 未命名模式变量
    case Box(var _)       -> pickAnotherBox();  // 未命名模式变量
}

通过允许我们省略名称,未命名模式变量使得基于类型模式的运行时数据探索在 switch 块和 instanceof 运算符中在视觉上更加清晰。

case 标签中的多个模式

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

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

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

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

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

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

并定义了具有多个模式的 case 标签的语义,即如果值与任何模式匹配,则与该值匹配。

如果 case 标签有多个模式,则任何模式声明任何模式变量都是编译时错误。

具有多个 case 模式的 case 标签可以有一个 守卫。守卫控制整个 case,而不是单个模式。例如,假设有一个 int 变量 x,则前一个示例中的第一个 case 可以进一步受限:

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

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

java
case Box(RedBall _) when x == 0, Box(BlueBall _) when x == 42 -> processBox(b);
    // 编译时错误

无名模式

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

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

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

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

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

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

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

此代码提取了嵌套 Pointx 坐标,同时明确指出不提取 yColor 组件的值。

回顾 BoxBall 的示例,我们可以通过使用无名模式而不是 var _ 来进一步简化其最终 case 标签:

java
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 302(《Lambda Leftovers》)研究了未使用 lambda 参数的问题,并确定了使用下划线来表示它们的作用,但还涵盖了许多其他问题,这些问题在其他方面得到了更好的处理。本 JEP 解决了 JEP 302 中探讨的未使用 lambda 参数的使用,但并未解决其中探讨的其他问题。