Skip to content

JEP 286: Local-Variable Type Inference | 本地变量类型推断

摘要

通过增强 Java 语言,使其在局部变量的声明中扩展类型推断,以提高开发人员的体验。同时保持 Java 对静态类型安全的承诺,允许开发人员省略局部变量类型的明确声明,从而减少编写 Java 代码时的繁文缛节。该功能将允许以下声明的出现:

java
var list = new ArrayList<String>();  // 推断为 ArrayList<String> 类型
var stream = list.stream();          // 推断为 Stream<String> 类型

此特性仅适用于具有初始化程序的局部变量、增强型 for 循环的索引以及传统 for 循环中声明的局部变量;对于方法形式参数、构造函数形式参数、方法返回类型、字段、catch 形式参数或任何其他类型的变量声明都不可用。

目标

我们旨在通过减少与编写 Java 代码相关的仪式感,改善开发人员的体验,同时保持 Java 对静态类型安全的承诺,允许开发人员省略局部变量类型的明确声明。这个功能将允许例如以下的声明:

java
var list = new ArrayList<String>();  // 推断为 ArrayList<String> 类型
var stream = list.stream();          // 推断为 Stream<String> 类型

此处理仅限于具有初始化程序的局部变量、增强型 for 循环的索引以及传统 for 循环中声明的局部变量;对于方法形式参数、构造函数形式参数、方法返回类型、字段、catch 形式参数或任何其他类型的变量声明都不可用。

成功标准

定量上,我们希望在实际代码库中,大部分局部变量声明都可以使用此功能进行转换,从而推断出适当的类型。

定性上,我们希望典型用户可以理解局部变量类型推断的限制和动机(当然,在一般情况下是不可能实现的;我们不仅不能为所有局部变量推断出合理的类型,而且有些用户认为类型推断是一种心灵读取的形式,而不是一种约束求解的算法,在这种情况下,任何解释都似乎是不明智的)。但我们希望能够清楚地划定界限,以便编译器诊断可以有效地将其与用户代码中的复杂性联系起来,而不是作为语言中的任意限制。

动机

开发人员经常抱怨在 Java 中需要编写大量样板代码。对于局部变量的明确类型声明通常被认为是不必要的甚至是碍事的;通过良好的变量命名,很清楚地知道正在进行什么操作。

为每个变量提供明确的类型声明的需要也无意中鼓励开发人员使用过于复杂的表达式;通过更低仪式感的声明语法,减少了将复杂的链式或嵌套表达式拆分成简单表达式的不利因素。

几乎所有其他流行的静态类型 "花括号" 语言,无论是运行在 JVM 上还是不运行在 JVM 上,都已经支持某种形式的局部变量类型推断:C++ (auto),C# (var),Scala (var/val),Go (使用 := 进行声明)。Java 几乎是唯一一个没有采用局部变量类型推断的流行静态类型语言;在这一点上,这不应再是一个有争议的功能。

在 Java SE 8 中,类型推断的范围得到了显著扩大,包括对嵌套和链式泛型方法调用的推断以及对 lambda 形式参数的推断。这使得构建针对调用链设计的 API 变得更加容易,这些 API(例如 Streams)非常受欢迎,显示出开发人员已经习惯于推断中间类型。在以下调用链中:

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

没有人感到困扰(甚至注意不到)中间类型 Stream<Block>IntStream,以及 lambda 形式参数 b 的类型在源代码中没有明确出现。

局部变量类型推断允许在结构不太严格的 API 中产生类似的效果;许多局部变量的使用本质上是链式的,并且同样受到推断的益处,例如:

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

描述

对于具有初始化程序的局部变量声明、增强型 for 循环的索引以及传统 for 循环中声明的索引变量,允许在明确类型的位置接受保留类型名 var

java
var list = new ArrayList<String>(); // 推断为 ArrayList<String> 类型
var stream = list.stream();         // 推断为 Stream<String> 类型

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

不允许的局部变量声明形式包括:缺少初始化程序的局部变量声明、声明多个变量、具有额外的数组维度括号或引用正在初始化的变量。拒绝没有初始化程序的局部变量可以缩小功能的范围,避免了 "远程操作" 推断错误,并且在典型程序中只排除了一小部分局部变量。

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

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

适用性和影响

在扫描 OpenJDK 代码库中的局部变量声明时,我们发现有 13% 无法使用 var 进行书写,因为没有初始化器、初始化器具有 null 类型,或者(很少)初始化器需要目标类型。在剩余的局部变量声明中:

  • 94% 的初始化器与源代码中的精确类型相匹配(带参数化类型的情况下为 63%)
  • 5% 的初始化器具有一些更精确的可指示类型(带参数化类型的情况下为 29%)
  • 1% 的初始化器具有提及捕获变量的类型(带参数化类型的情况下为 7%)
  • <1% 的初始化器具有匿名类类型或交集类型(带参数化类型的情况下相同)

替代方案

我们可以继续要求在局部变量类型上进行明确声明。

与其支持 var,我们可以将支持限制在变量声明中使用 diamond(菱形操作符);这将解决一部分 var 所解决的问题。

上述设计包含了关于作用域、语法和不可指示类型的几个决策;这些选择的其他备选方案也在这里进行了记录。

作用域选择

在作用域上,我们可以采用其他几种方式来实现此功能。我们考虑将该功能限制为实际上是 final 的局部变量(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),这是合理的。
  • 交集类型特别难以映射到超类型——它们没有顺序,因此交集的一个元素并不本质上比其他元素“更好”。稳定的超类型选择是所有元素的最小公共超类型,但这通常会是 Object 或类似于此的不太有用的类型。因此我们允许它们。
  • 匿名类类型无法命名,但它们很容易理解——它们只是类。允许变量具有匿名类类型引入了一种有用的简写形式,用于声明局部类的单例实例。我们允许它们。

风险和假设

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

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

示例:

java
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,以及 常见问题