跳到主要内容

JEP 286:局部变量类型推断

QWen Max 中英对照 JEP 286 Local-Variable Type Inference

概述

增强 Java 语言,将类型推断扩展到带有初始值的局部变量声明。

目标

我们力求通过减少与编写 Java 代码相关的繁琐仪式来改善开发者的体验,同时通过允许开发者省略局部变量类型通常不必要的显式声明,保持 Java 对静态类型安全的承诺。例如,此特性将允许如下声明:

var list = new ArrayList<String>();  // infers ArrayList<String>
var stream = list.stream(); // infers Stream<String>

这种处理方式将仅限于带有初始化值的局部变量、增强型 for 循环中的索引,以及传统 for 循环中声明的局部变量;它不适用于方法的形式参数、构造函数的形式参数、方法返回类型、字段、捕获形式参数或任何其他类型的变量声明。

成功标准

量化来说,我们希望真实代码库中有相当大比例的局部变量声明可以使用此功能进行转换,并推断出合适的类型。

定性地说,我们希望局部变量类型推断的局限性及其这些局限性的动机,对于典型用户来说是可理解的。(当然,这在一般情况下是不可能完全实现的;不仅我们无法为所有局部变量推断出合理的类型,而且有些用户认为类型推断是一种读心术,而不是一种用于约束求解的算法,在这种情况下,任何解释似乎都不合理。)但是,我们寻求以这样一种方式划清界限,即可以清楚地解释为什么某个特定的结构会超出界限 —— 并且以这样一种方式,编译器诊断可以有效地将其与用户代码中的复杂性联系起来,而不是语言中的任意限制。

动机

开发者经常抱怨 Java 中需要过多的样板代码。局部变量的显式类型声明常常被认为是不必要的,甚至会碍事;如果变量命名得当,通常可以很清楚地知道正在发生什么。

为每个变量提供清单类型的需求也不经意间促使开发人员使用过于复杂的表达式;而使用较低复杂度的声明语法时,将复杂链式或嵌套表达式拆分为更简单的表达式的阻碍就减少了。

几乎所有的其他流行的静态类型“大括号”语言,无论是在 JVM 上还是在非 JVM 环境中,都已经支持某种形式的局部变量类型推断:C++(auto)、C#(var)、Scala(var/val)、Go(使用 := 声明)。Java 几乎是唯一一个尚未采用局部变量类型推断的流行静态类型语言;在当前阶段,这一特性不应再被视为具有争议性。

在 Java SE 8 中,类型推断的范围显著扩大,包括对嵌套和链式泛型方法调用的扩展推断,以及对 lambda 形参的推断。这使得构建为调用链设计的 API 变得更加容易,而此类 API(例如 Streams)已经非常流行,表明开发者已经习惯于中间类型的自动推断。在一个像这样的调用链中:

int maxWeight = blocks.stream()
.filter(b -> b.getColor() == BLUE)
.mapToInt(Block::getWeight)
.max();

没有人会费心(甚至注意到)中间类型 Stream<Block>IntStream,以及 lambda 形参 b 的类型没有显式地出现在源代码中。

局部变量类型推断允许在结构不太紧密的 API 中实现类似的效果;局部变量的许多用法本质上是链式的,同样可以从推断中受益,例如:

var path = Paths.get(fileName);
var bytes = Files.readAllBytes(path);

描述

对于带有初始化值的局部变量声明、增强型 for 循环索引,以及在传统 for 循环中声明的索引变量,允许使用保留类型名称 var 来代替显式类型:

var list = new ArrayList<String>(); // infers ArrayList<String>
var stream = list.stream(); // infers Stream<String>

标识符 var 不是一个关键字,而是一个保留类型名。这意味着使用 var 作为变量、方法或包名称的代码不会受到影响;而使用 var 作为类或接口名称的代码会受到影响(但这些名称在实践中很少见,因为它们违反了常规的命名约定)。

不允许缺少初始化程序、声明多个变量、具有额外数组维度括号或引用被初始化变量的局部变量声明形式。拒绝没有初始化程序的局部变量缩小了该特性的适用范围,避免了“远处动作”推理错误,并且仅排除了典型程序中一小部分的局部变量。

实质上,推断过程只是赋予变量其初始化表达式的类型。一些微妙之处如下:

  • 初始化器没有目标类型(因为我们还没有推断出它)。像 lambda 表达式、方法引用和数组初始化器这类需要目标类型的多态表达式将会触发错误。
  • 如果初始化器具有 null 类型,则会发生错误——就像没有初始化器的变量一样,这个变量可能打算稍后进行初始化,并且我们不知道需要什么类型。
  • 捕获变量以及带有嵌套捕获变量的类型会被投影到不提及捕获变量的超类型。此映射将捕获变量替换为其上界,并将提及捕获变量的类型参数替换为有界通配符(然后递归进行)。这保留了捕获变量传统上的有限作用域,它们只在单个语句中被考虑。
  • 除上述例外情况外,可能推断出不可表示的类型,包括匿名类类型和交集类型。编译器和工具需要考虑这种可能性。

适用性和影响

扫描 OpenJDK 代码库中的局部变量声明时,我们发现 13% 的声明无法使用 var 来编写,因为它们要么没有初始化器,要么初始化器的类型为 null,或者(很少见的情况下)初始化器需要一个目标类型。在剩余的局部变量声明中:

  • 94% 的初始化器具有与源代码中完全一致的类型(在参数化类型的案例中占 63%)
  • 5% 的初始化器具有某种更精确的可表示类型(在参数化类型的案例中占 29%)
  • 1% 的初始化器具有提到捕获变量的类型(在参数化类型的案例中占 7%)
  • 不到 1% 的初始化器具有匿名类类型或交集类型(在参数化类型的案例中情况相同)

替代方案

我们可以继续要求对局部变量类型进行显式声明。

与其支持 var,我们可以将支持限制在变量声明中使用 diamond(钻石符号)的场景;这样可以解决 var 所处理情况的一个子集。

上述设计包含了一些关于范围、语法和不可表示类型(non-denoteable types)的决策;这里记录了那些同样被考虑过的替代选项。

作用域选择

我们还有其他几种方法可以用来限定此功能的范围。我们考虑过将该功能限制在实际上的最终局部变量(val)中。然而,我们放弃了这个立场,原因如下:

  • 绝大多数(在 JDK 和更广泛的代码库中均超过 75%)带有初始值的局部变量实际上已经是有效的不可变变量,这意味着此特性本可以提供的任何“推动”以减少可变性的效果将会有限;

  • Lambda 表达式/内部类的捕获机制已经为有效 final 的局部变量提供了显著的推动力;

  • 在一个代码块中,假设有 7 个有效 final 的局部变量和 2 个可变变量,那么为这些可变变量指定的类型在视觉上会显得格格不入,从而削弱该特性的大部分优势。

另一方面,我们可以扩展此功能以包含“空白” final 的本地等价物(即,不需要初始化器,而是依赖于明确的赋值分析)。我们选择限制为“仅带有初始化器的变量”,因为它覆盖了很大一部分候选变量,同时保持了功能的简单性并减少了“远处动作”错误。

同样,我们也可以在推断类型时考虑所有赋值,而不仅仅是初始化赋值;虽然这样可以进一步增加能够利用此特性的局部变量的百分比,但也会增加“远处动作”错误的风险。

语法选择

对于语法存在多种不同的意见。这里有两个主要的自由度:使用什么关键字(varauto 等),以及是否为不可变局部变量设置一个单独的新形式(vallet)。我们考虑了以下语法选项:

  • 仅使用 var x = expr(类似 C#)
  • 使用 var,以及用于不可变局部变量的 val(类似 Scala、Kotlin)
  • 使用 var,以及用于不可变局部变量的 let(类似 Swift)
  • 使用 auto x = expr(类似 C++)
  • 使用 const x = expr(已经是保留字)
  • 使用 final x = expr(已经是保留字)
  • 使用 let x = expr
  • 使用 def x = expr(类似 Groovy)
  • 使用 x := expr(类似 Go)

在收集了大量意见后,var 明显比 Groovy、C++ 或 Go 的方法更受欢迎。对于不可变局部变量的第二种语法形式(vallet),存在着相当多样化的意见;这是一个在额外的仪式与额外捕获设计意图之间的权衡。最终,我们选择仅支持 var。关于此决定的一些细节可以在此处找到:这里

不可表示类型

有时初始化器的类型是非可表示类型,例如捕获变量类型、交集类型或匿名类类型。在这种情况下,我们可以选择:i) 推断类型,ii) 拒绝表达式,或 iii) 推断一个可表示的超类型。

编译器(以及细心的程序员!)必须已经能够自如地推理不可表示类型。然而,将它们用作局部变量的类型将会显著增加它们的曝光率,揭示编译器/规范中的错误,并迫使程序员更频繁地面对这些问题。从教学角度来看,显式类型声明和隐式类型声明之间有一个简单的语法转换是很不错的。

也就是说,简单地拒绝具有不可表示类型的初始化程序是毫无帮助的学究行为(通常会让程序员感到惊讶,例如在声明 var c = getClass() 中)。而且映射到超类型可能会出乎意料并且造成信息丢失。

这些考虑使我们得出了不同的答案:

  • 一个 null 类型的变量实际上毫无用处,并且没有合适的替代推断类型,因此我们拒绝这些情况。
  • 允许捕获变量流入后续语句确实为语言增添了新的表达能力,但这并不是该特性的目标。相反,所提出的 投影 操作是我们无论如何都需要用来解决类型系统中各种缺陷的方法(例如,参见 JDK-8016196),在此处应用它是合理的。
  • 交集类型尤其难以映射到超类型——它们没有顺序,因此交集中的某个元素并不天然比其他元素“更好”。超类型的稳定选择是所有元素的 最小上界 (lub),但结果通常为 Object 或其他同样无用的类型。因此我们允许它们。
  • 匿名类类型无法被命名,但它们很容易理解——它们只是类而已。允许变量拥有匿名类类型提供了一种声明局部类单例实例的有用简写方式。我们允许它们。

风险与假设

风险:由于 Java 已经在右侧(lambda 形参、泛型方法类型参数、菱形语法)进行了显著的类型推断,因此在这样的表达式的左侧尝试使用 var 可能会失败,并且可能会出现难以阅读的错误信息。

当左侧被推断时,我们通过使用简化的错误消息来缓解这个问题。

示例:

Main.java:81: error: cannot infer type for local
variable x
var x;
^
(cannot use 'val' on variable without initializer)

Main.java:82: error: cannot infer type for local
variable f
var f = () -> { };
^
(lambda expression needs an explicit target-type)

Main.java:83: error: cannot infer type for local
variable g
var g = null;
^
(variable initializer is 'null')

Main.java:84: error: cannot infer type for local
variable c
var c = l();
^
(inferred type is non denotable)

Main.java:195: error: cannot infer type for local variable m
var m = this::l;
^
(method reference needs an explicit target-type)

Main.java:199: error: cannot infer type for local variable k
var k = { 1 , 2 };
^
(array initializer needs an explicit target-type)

风险:源不兼容(有人可能使用了 var 作为类型名称)。

通过保留类型名称来缓解此问题;像 var 这样的名称不符合类型命名约定,因此不太可能被用作类型。名称 var 确实常被用作标识符;我们继续允许这样做。

风险:可读性降低,重构时会出现意外。

像任何其他语言特性一样,局部变量类型推断既可以用来编写清晰的代码,也可能写出晦涩难懂的代码;最终编写清晰代码的责任在于使用者。有关使用 var风格指南,以及常见问题解答可以参考。