未命名模式和变量(预览)

2023/07/18

概括

使用未命名模式(匹配记录组件而不说明组件的名称或类型)和未命名变量(可以初始化但不能使用)来增强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)的经验,我们预计复杂数据结构上的模式匹配将变得普遍。通常,结构的形状与其中的各个数据项一样重要。作为一个高度简化的示例,请考虑以下Ball和Box类,以及一个匹配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,但不使用变量red、blue和green。由于未使用变量,如果我们可以省略它们的名称,则该代码将更具可读性。

此外,如果将其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,但这种语法技巧并没有传达变量将不被使用的语义意图。此外,静态分析工具通常会抱怨未使用的变量,即使开发人员打算不使用并且可能没有办法消除警告。

下面是一个示例,其中表达式的副作用比其结果更重要,从而导致未使用的变量。以下代码使数据出队,但只需要每3个元素中的2个:

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

    acquiredContext这个名字只是很混乱,所以最好省略它。

  • 异常是最终的副作用,处理异常通常会产生未使用的变量。例如,大多数Java开发人员都编写了这种形式的catch块,其中异常参数的名称是不相关的:

     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"));

此代码生成一个Map,将每个键映射到相同的占位符值。由于未使用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,使用下划线来声明没有名称的变量。

在长度为2或以上的标识符中使用下划线的能力没有改变,因为下划线仍然是Java字母和Java字母或数字。例如,诸如_age、MAX_AGE和__(两个下划线)之类的标识符仍然是合法的。

使用下划线作为数字分隔符的能力没有改变。例如,诸如123_456_789和0b1010_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 ... }

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

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

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

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

此代码提取嵌套Point的x坐标,同时省略y和Color组件。

未命名的模式变量

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

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

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

当switch对多个case执行相同的操作时,未命名的模式变量特别有用。例如,前面的Box和Ball代码可以重写为:

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

前两个case使用未命名模式变量,因为它们的右侧不使用Box组件。第三个case是新的,它使用未命名模式来将Box与null组件进行匹配。

具有多个模式的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) ...
     }
    

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

     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 Leftovers)检查了未使用的lambda参数的问题,并确定了下划线的作用来表示它们,但也涵盖了许多其他问题,这些问题通过其他方式得到了更好的处理。此JEP解决了JEP 302中探讨的未使用lambda参数的使用问题,但没有解决其中探讨的其他问题。
Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章