本文要点
在 QCon New York 的 Java Futures 演讲中,Java 语言架构师 Brian Goetz 带领我们快速浏览了 Java 语言近期及未来的一些特性。在本文中,他将深入研究局部变量的类型推断。
Java SE 10(2018 年 3 月)引入了局部变量的类型推断。在此之前,声明局部变量需要显式声明类型。现在,类型推断使编译器能够根据变量初始化值的类型来选择变量的静态类型:
在这个简单的例子中,变量 names 的类型是 ArrayList。
尽管在语法上类似于 JavaScript 中的相似特性,但这并不是动态类型,Java 中的所有变量仍然都是静态类型。局部变量的类型推断仅仅允许我们要求编译器为我们找出这个类型,而不用强制我们显式地提供该类型。
Java 中的类型推断
类型推断是静态类型语言使用的一种技术,编译器可以根据上下文推断出变量的类型。各语言在类型推断的使用和解析上各不相同。类型推断通常可以为程序员提供一个选项,而不是一种义务;我们可以自由地在显式类型和推断类型之间进行选择,并且我们应该负责任地做出这个选择,在可以增强可读性的地方使用类型推断,在可能造成混淆的地方避免使用类型推断。
Java 中的类型名可以很长,原因可能是类名本身就很长,也可能是有复杂的泛型类型参数,或者两者都有。编程语言的一个普遍事实是:类型越有趣,编码就越无趣,这就是为什么类型系统越复杂的语言往往更依赖于类型推断的原因。
Java 从 Java 5 开始引入了有限形式的类型推断,其范围在过去几年中稳步扩大。在 Java 5 中,当引入泛型方法时,我们还引入了在使用现场推断泛型类型参数的能力;我们通常按照如下形式进行编码:
而不是提供显式类型声明:
事实上,推断的形式非常常见,以至于一些 Java 开发人员甚至从未见过显式形式!
在 Java 7 中,我们扩展了类型推断的范围,以推断泛型构造函数调用的类型参数(被称为“钻石”调用);我们可以编写如下代码:
它是如下更显式形式的缩写
在 Java 8 中,当引入 Lambda 表达式时,我们还引入了推断 Lambda 表达式形参类型的能力。所以,我们可以编写如下代码:
它是如下更显式形式的缩写
在 Java 10 中,我们将类型推断更进一步地扩展到了局部变量的声明上。
一些开发人员认为日常使用推断类型比较好,因为这样可以使程序更简洁;另一些开发人员认为它会更糟,因为它从视图中删除了潜在可能有用的信息。但是,这两种观点都有些片面。有时,被推断的信息可能仅仅是一堆杂乱无章的信息,它们只会造成混乱(没有人抱怨我们经常对泛型类型参数使用类型推断),在这些情况下,类型推断使我们的代码更具可读性。在其他情况下,类型信息提供了关于正在发生的事情的重要线索,或者反映了开发人员的创造性选择;在这些情况下,最好坚持使用显式类型。
虽然多年来我们一直在扩展类型推断的范围,但我们遵循的一个设计原则是:仅将类型推断用于实现细节,而不是用于声明 API 元素;字段、方法参数和方法返回值的类型必须始终是显式类型,因为我们不希望 API 契约根据实现中的变更而发生微妙地变化。但是,在方法体的内部实现中, 提供更多地可以根据可读性来进行选择的自由是合理的。
类型推断是如何工作的?
类型推断经常被误解成是魔术或读心术;开发人员经常将编译器拟人化,并问“为什么编译器不能弄清楚我想要什么”。实际上,类型推断是要简单得多:约束求解。
不同的语言使用类型推断的方式不同,但是对于所有语言来说基本概念都是相同的:收集未知类型的约束,并在某个时刻求解它们。语言设计人员可以自由决策:可以在哪些地方使用类型推断、收集哪些约束以及在什么作用域内求解约束。
Java 中的类型推断是针对局部变量的;我们收集约束的作用域以及求解约束的时间被限制在程序的一个狭小部分中,比如单个表达式或语句。例如,对于局部变量,我们收集约束并求解的作用域是局部变量本身的声明,而不考虑对该局部变量的其他赋值。其他语言则采用更全局的方法来进行类型推断,它们在尝试求解变量的类型之前,会考虑变量的所有用法。虽然乍一看这似乎会更好,因为它更精确,但它通常更难使用。如果每次的使用都会影响变量的类型,那么当出现错误时(例如,由于编程错误而导致的类型被过度约束),错误消息通常会毫无用处,并且它可能是在远离被推断类型的变量的声明位置或错误使用变量的地方弹出。这些选择说明了语言设计者在使用类型推断时所面临的基本权衡之一:我们总是会用精确性和预测能力来换取复杂性和可预测性。我们可以对算法进行调整,以提高编译器“正确求解”的普及率(例如,通过收集更多的约束或在更大作用域内求解),但是当它失败时,结果几乎总是令人更不快。
举个简单的例子,考虑一个钻石调用:
我们知道 list 的类型是 List,因为它是一个显式类型。我们试图推断 ArrayList 的类型参数,我们将它设成 x 。所以,右侧的类型是 ArrayList。因为我们将右侧的赋值给了左侧的,所以右侧的类型必须是左侧类型的子类型,所以我们可以收集到如下约束:
其中 <: 表示“…的子类型”。( x 是一个泛型类型变量,它的隐式边界是 Object,我们还可以从上述事实中收集到一个细微的边界约束 x <: Object。)我们还可以从 ArrayList 的声明中知道 List 是 ArrayList 的超类型。由此可知,我们可以推出边界约束 x <: String(JLS 18.2.3),因为这是我们对 x 的唯一约束,所以我们可以得出 x=String 的结论 。
下面是一个更复杂的例子:
此处,右侧是一个泛型方法调用,因此我们可以从 List 如下的方法中推断出泛型类型参数:
在此,与预览示例相比,我们有更多的信息需要处理:参数类型,它们是 List 和 Set。因此,我们可以收集到如下的约束:
给定这组约束,我们通过计算最小上界(JLS 4.10.4)来求解 x ,即最精确的类型是二者的超类型,在本例中是 Collection。所以,v 的类型是 List<Collection>。
我们收集哪些约束?
在设计类型推断算法时,一个关键的选择是如何从程序中收集约束。对于某些程序结构(如赋值),右侧的类型必须与左侧的类型兼容,因此我们肯定能从中收集到约束。类似地,对于泛型方法的参数,我们可以从它们的类型中收集约束。但在某些情况下,我们可能会选择忽略其他的信息来源。
起初,这听起来很奇怪;收集更多的约束不是更好吗?因为这会导致更精确的答案。同样地,精确性并非始终是最重要的目标;收集更多的约束也可能增加过度约束解决方案的可能性(在这种情况下,推断将失败,或是选择一个诸如 Object 的备选答案),并且还会导致程序更加不稳定(实现中的微小变更可能会导致程序其他地方在输入或重载解析方面的惊人变化)。正如要解决的问题一样,我们要在精确性和预测能力与复杂性和可预测性之间进行权衡,这是一项主观任务。
请考虑这样一个相关的示例,来解释有时应该忽略一些约束: 参数为 Lambda 的方法,在重载时的类型解析。我们可以使用 Lambda 主体抛出的异常来缩小适用方法的范围(更高的精度),但这也可能会使 Lambda 主体实现中的细微变更可以改变重载选择的结果,这样的结果会让人感到奇怪(降低了可预测性),在这种情况下,精确性的提高并不能弥补可预测性的降低,因此在制定重载解析决策时不会考虑这一约束。
优雅的代码
现在,我们已经在整体上了解了类型推断的工作原理,接下来让我们深入了解一下它是如何应用于局部变量声明的。对于用 var 声明的局部变量,我们首先会计算初始化值的独立类型。(独立类型是我们通过计算“自下而上”表达式的类型而得出的类型,它忽略了赋值目标。某些表达式是没有独立类型,比如 Lambda 和方法引用,因此它们不能作为使用类型推断的局部变量的初始化值。)
对于大多数表达式,我们只需使用初始化值的独立类型作为局部变量的类型。但是,在某些情况下,特别是当独立类型不可表示时,我们可以改进或拒绝类型推断。
不可表示类型是我们不能用对应语言的语法写下来的类型。Java 中的不可表示类型包括交集类型( Runnable & Serializable )、捕获类型(那些从通配符捕获转换派生出来的类型)、匿名类类型(匿名类创建表达式的类型)和空类型(null 文本的类型)。首先,我们考虑拒绝对所有不可表示类型的推断,根据这个理论,var 应该只是显式类型的简写。但是,事实证明,不可表示类型在实际程序中非常普遍,这样的限制会使该特性的用处变少,这会更加令人沮丧。这意味着使用 var 的程序不一定仅仅是使用显式类型程序的简写(有些程序可以用 var 表示,但又不能直接用它表示)。
作为上述程序的一个示例,可以考虑匿名类声明:
如果我们要提供一个显式类型(一个显而易见的选择是 Runnable ),runTwice() 方法将无法通过变量 v 来访问,因为它不是 Runnable 的成员。但是使用推断类型,我们能够推断出匿名类创建表达式的更敏锐的类型,因此能够访问该方法。
每种不可表示类型都有它们自己的故事。对于空类型(此处是我们从 var x = null 推断出的类型),我们简单地拒绝了此类声明。这是因为驻留在空类型中的唯一值是 null ,而且它不太可能是一个只包含 null 的变量。因为我们不想通过推断 Object 或其他类型来“猜测”它的意图,所以我们拒绝这种情况,以便开发人员能够提供正确的类型。
对于匿名类类型和交集类型,我们只能使用推断类型;这些类型怪异且新颖,但基本上是无害的。这意味着,我们现在可以更广泛地接触到一些“怪异”的类型了,这些类型之前一直在处于水平线之下。例如,假设我们有如下代码:
这和上面的例子很像,所以我们知道结果是怎么的(我们要获取 Integer 和 Double 的最小上界)。结果发现它是一个比较难看的类型 Number & Comparable>。所以,list 的类型是 List<Number & Comparable>>。
正如我们所看到的那样,即使是一个简单的示例也会产生一些令人惊讶的复杂类型(它包含了一些不能显式写下来的类型)。
最棘手的问题是我们如何处理通配符捕获类型。捕获类型来自于泛型的黑暗角落;它们源于这样一个事实:在程序中每次使用的 ? 都对应于一个不同的类型。考虑如下的方法声明:
尽管 a 和 b 的类型在文本上是相同的,但它们实际上并不是同一类型,因为我们没有理由相信这两个列表具有相同的元素类型。(如果我们想让这两个列表具有相同的类型,我们可以在 T 中构造一个 m() 泛型方法,并对这两个列表使用 List 。)因此,每次在程序中使用 ? 时,编译器都会创建一个占位符,也叫做“捕获”,所以我们可以将通配符的不同用法分开。到目前为止,捕获类型仍停留它们所述领域的黑暗之中,但如果我们允许它们逃离黑暗,它们可能会造成混乱。
例如,假设我们在 MyClass 类中编写如下代码:
我们可能会认为 c 的类型是 Class,但右侧表达式的类型实际上是 Class> 。在程序中设置这样的类型对任何人都没有帮助。
一开始就禁止捕获类型的推断似乎是很有吸引力的,但是,在太多的情况下,这些类型都是突然出现。因此,我们选择使用一种称为向上投影(JLS 4.10.5)的转换来对它们进行清洗,该向上投影转换接受一个可能包含捕获类型的类型,并生成一个该类型的超类型,该超类型是不包含捕获类型的。在上面的例子中,向上投影将 c 的类型清洗为 Class<?> ,这是一种表现更好的类型。
清洗类型是一种实用的解决方案,但它并非没有妥协。通过推断一个不同于自然类型的表达式,这意味着如果我们使用“提取变量”重构将一个复杂表达式 f(e) 重构为 var x = e; f(x) ,这可能会改变下游类型推断或重载选择决策。在大多数情况下,这都不是问题,但当我们修改“自然”类型表达式时,这就会成为一个风险。在捕获类型的情况下,治疗的副作用要高于疾病本身。
意见分歧
与 Lambda 或泛型相比,局部变量的类型推断是一个非常小的特性(不过,正如我们所看到的那样,它的细节比大多数人认为的都要复杂得多),但是,围绕这个特性的争论却很激烈。
多年来,这都是 Java 最常被要求的特性之一;开发人员已经习惯了 C# 、Scala 或更高版本的 Kotlin 中的这一特性,但当他们回到 Java 时却非常怀念这一特性,所以他们对此颇有微词。我们决定根据它的流行程度继续向前推进,因为它已经被证明可以在其他类似 Java 的语言中很好地运行,并且它与其他语言特性的交互范围相对较小。
或许令人惊讶的是,当我们宣布我们将继续推进这一特性时,另一个声音出现了,那些人显然认为这是他们所见过的最愚蠢的想法。它被描述为“屈服于时尚”或“鼓励懒惰”(甚至更糟),并且人们对无法阅读的代码的反乌托邦式的未来做出了可怕的预测。支持者和反对者都通过呼吁相同的核心价值来证明他们的立场:可读性。
在发布了这个特性之后,实际情况并没有那么可怕;虽然有一个初始的学习曲线,开发人员必须找到正确的方法来使用新特性(就像使用其他特性一样),但是在大多数情况下,开发人员可以很容易地内化一些合理的指导原则,比如了解该特性何时可以增值,何时不可以,并据此使用它。
风格建议
Oracle Java 库团队的 Stuart Marks 编写了一个很有用的风格指南,以帮助理解局部变量类型推断的利弊。
与大多数严谨的风格指南一样,本指南的重点是明确所涉及的权衡。显式性是一种权衡;一方面,显式类型提供了变量类型明确且精确的声明,但另一方面,有时类型是明显的或不重要的,显式类型可能会与更重要的信息竞争,来吸引读者的注意。
风格指南中概述的一般原则包括:
选择好的变量名。如果我们为局部变量选择表达性强的名称,那么类型声明很可能就是不必要的了,甚至都不需要类型声明。另一方面,如果我们选择像 x 和 a3 这样的变量名,那么去掉类型信息很可能会使代码更加难以理解。
最小化局部变量的作用域。一个变量声明与其使用之间的距离越大,我们就越有可能对其进行不精确的推理。使用 var 声明作用域跨越多行的局部变量比那些具有较小作用域或具有显式类型的局部变量更容易导致疏忽。
当初始化值可以向读者提供足够的信息时可以考虑 var 。对于许多局部变量声明,初始化表达式可以使它很清楚表达发生了什么(例如 var names = new ArrayList() ),因此显式类型的需求就减少了。
无需太担心“接口编程”。开发人员的一个共同担忧是,长期以来我们都被鼓励对变量使用抽象类型(如 List ),而不是更具体的实现类型(如 ArrayList),但是如果我们允许编译器推断类型,它将会推断出更具体的类型。但是,我们也不必太担心这个问题,因为这个建议对于 API(比如方法返回类型)比实现中的局部变量更重要,特别是当我们遵循了前面关于保持较小作用域的建议时。
注意 var 和钻石推断之间的交互作用。var 和钻石符号都要求编译器为我们推断类型,如果已经存在了足够的类型信息来推断所需的类型(比如构造函数参数的类型),那么将它们放在一起使用是完全可以的。
注意 var 与数值的结合。数值是多形表达式,这意味着它们的类型可以依赖于它们被赋值的类型。(例如,我们可以编写简短的 x = 0 ,而数值 0 与 int、long、short 和 byte 都是兼容的。)但是,如果没有目标类型,数值的独立类型是 int ,因此将简短的 s = 0 变更为 var s = 0 将会导致 s 的类型变更。
使用 var 来分解链式或嵌套表达式。当为子表达式声明一个新的局部变量是一种负担时,它增加了使用链式和/或嵌套创建复杂表达式的诱惑,有时会牺牲可读性。通过降低声明子表达式的成本,局部变量类型推断可以降低错误操作的可能性,从而提高了可读性。
最后一项说明了在关于编程语言特性的辩论中经常会忽略的一个重要的问题。在评估一个新特征的后果时,我们通常只会考虑它可能被使用的最表层的方式;在局部变量类型推断的情况下,它将用 var 替换现有程序中的显式类型。但是,我们采用的编程风格受到许多因素的影响,包括各种构造结构的相对成本。如果我们降低了声明局部变量的成本,那么我们很有可能会在一个使用更多局部变量的地方重新实现平衡,这还有可能会使程序更具有可读性。但是,人们在争论一个特性是有益的还是有害的时,很少会考虑这种二阶效应。
不管怎样,这些指导原则中的很多都是好的风格建议,毕竟不管有没有推断,选择好的变量名是使代码更具可读的最有效的方法之一。
总结
正如我们所看到的那样,局部变量的类型推断并不像它的语法所建议的那样简单;虽然有些人可能希望它让我们忽略类型,但它实际上要求我们更好地理解 Java 的类型系统。但是,如果我们理解了它是如何工作的,并且遵循一些合理的风格指导原则,那么它将有助于使我们的代码更加简洁且更具有可读性。
作者介绍
Brian Goetz 是 Oracle 的 Java 语言架构师,同时也是 JSR-335(Java 编程语言的 Lambda 表达式)规范的负责人,他还是畅销书《Java 并发编程实战》的作者,自从 Jimmy Carter 担任总统开始,他就已经对编程很着迷了。
原文链接:
https://www.infoq.com/articles/java-local-variable-type-inference/
评论