Java 10 局部变量类型推断浅析

作者:边城, dreamanzhao, 溪边九节, imqipan 时间:2023-11-25 06:24:13 

前言

java 10 引进一种新的闪闪发光的特性叫做局部变量类型推断。听起来很高大上吧?它是什么呢? 下面的两个情景是我们作为 Java 开发者认为 Java 比较难使用的地方。

上下文:陈词滥调和代码可读性

也许日复一日,你希望不再需要重复做一些事情。例如在下面的代码(使用 Java 9 的集合工厂),左边的类型也许会感觉到冗余和平淡。


import static java.util.Map.entry;
List<String> cities = List.of("Brussels", "Cardiff", "Cambridge")
Map<String, Integer> citiesPopulation
= Map.ofEntries(entry("Brussels", 1_139_000),
entry("Cardiff", 341_000));

这是一个非常简单的例子,不过它也印证了传统的 Java 哲学:你需要为所有包含的简单表达式定义静态类型。再让我们来看看有一些复杂的例子。举例来说,下面的代码建立了一个从字符串到词的柱状图。它使用 groupingBy 收集器将流聚合进 Map 。groupingBy 收集器还可以以一个分类函数为第一个参数建立映射的键和第二个收集器的 (counting()) 键计算关联的数量。下面就是例子:


String sentence = "A simple Java example that explores what Java
10 has to offer";
Collector<String, ?, Map<String, Long>> byOccurrence
= groupingBy(Function.identity(), counting());
Map<String, Long> wordFrequency
= Arrays.stream(sentence.split(" "))
.collect(byOccurrence);

复杂表达式提取到一个变量或方法来提升代码的可读性和重用性,这是非常有意义的。在这里例子中,建立柱状图的逻辑使用了收集器。不幸地是,来自 groupingBy 的结果类型几乎是不可读的!对于这一点你毫无办法,你能做的只有观察。

最重要的一点是当 Java 中增加新的类库的时候,他们开发越来越多的泛型,这就为开发者引进了更多的公式化代码(boilerplate code),从而带来了额外的压力。上面的例子并不是说明了编写类型就不好。很明显,强制将为变量和方法签名定义类型的操作执行为一种需要被尊重的协议,将有益于维护和理解。然而,为中间表达式声明类型也许会显得无用和冗余。

类型推断的历史

我们已经在 Java 历史上多次看到语言设计者添加“类型推断”来帮助我们编写更简洁的代码。类型推断是一种思想:编译器可以帮你推出静态类型,你不必自己指定它们。

最早从 Java 5 开始就引入了泛型方法,而泛型方法的参数可以通过上下文推导出来。比如

这段代码:

List<String> cs = Collections.<String>emptyList();

可以简化成:

List<String> cs = Collections.emptyList();

然后,在 Java 7 中,可以在表达式中省略类型参数,只要这些参数能通过上下文确定。比如:

Map<String, List<String>> myMap = new HashMap<String,List<String>>();

可以使用尖括号<>运算符简化成:

Map<User, List<String>> userChannels = new HashMap<>();

一般来说,编译器可以根据周围的上下文来推断类型。在这个示例中,从左侧可以推断出 HashMap 包含字符串列表。

从 Java 8 开始,像下面这样的 Lambda 表达式

Predicate<String> nameValidation = (String x) -> x.length() > 0;

可以省略类型,写成

Predicate<String> nameValidation = x -> x.length() > 0;

局部变量类型推断

随着类型越来越多,泛型参数有可能是另一个泛型,这种情况下类型推导可以增强可读性。Scala 和 C# 语言允许将局部变量的类型声明为 var,由编译器根据初始化语句来填补合适的类型。比如,前面对 userChannels 的声明可以写成这样:


var userChannels = new HashMap<User, List<String>>();

也可以是根据方法的返回值(这里返回列表)来推断:


var channels = lookupUserChannels("Tom");
channels.forEach(System.out::println);

这种思想称为局部变量类型推断,它已经在 Java 10 中引入!

例如下面的代码:


Path path = Paths.get("src/web.log");
try (Stream<String> lines = Files.lines(path)){
long warningCount
= lines
.filter(line -> line.contains("WARNING"))
.count();
System.out.println("Found " + warningCount + " warnings in the
log file");
} catch (IOException e) {
e.printStackTrace();
}

在 Java 10 中可以重构成这样:


var path = Paths.get("src/web.log");
try (var lines = Files.lines(path)){
var warningCount
= lines
.filter(line -> line.contains("WARNING"))
.count();
System.out.println("Found " + warningCount + " warnings in the
log file");
} catch (IOException e) {
e.printStackTrace();
}

上述代码中的每个表达式仍然是静态类型(即值的类型):

  • 局部变量 path 的类型是 Path

  • 变量 lines 的类型是 Stream<String>

  • 变量 warningCount 的类型是 long

也就是说,如果给这些变量赋予不同值则会失败。比如,像下面这样的二次赋值会造成编译错误:


var warningCount = 5;
warningCount = "6";
| Error:
| incompatible types: java.lang.String cannot be converted to int
| warningCount = "6"

然而还有一些关于类型推断的小问题;如果类 Car 和 Bike 都是 Vehicle 的子类,然后声明

var v = new Car();

这里声明的 v 的类型是 Car 还是 Vehicle?这种情况下很好解释,因为初始化器(这里是 Car)的类型非常明确。如果没有初始化器,就不能使用 var。稍后像这样赋值

v = new Bike();

会出错。换句话说,var 并不能完美地应用于多态代码。

那应该在哪里使用局部变量类型推断呢?

什么情况下局部类型推断会失效?你不能在字段和方法签名中使用它。它只能用于局部变量,比如下面的代码是不正确的:

public long process(var list) { }

不能在不明确初始化变量的情况下使用 var 声明局部变量。也就是说,不能使用 var 语法声明一个没有赋值的变量。下面这段代码

var x;

这会产生编译错误:


| Error:
| cannot infer type for local variable x
| (cannot use 'var' on variable without initializer)
| var x;
| ^----^

也不能把 var 声明的变量初始化为 null。实事上,在后期初始化之前它究竟是什么类型,这并不清楚。


| Error:
| cannot infer type for local variable x
| (variable initializer is 'null')
| var x = null;
| ^-----------^

不能在 Lambda 表达式中使用 var,因为它需要明确的目标类型。下面的赋值就是错的:


var x = () -> {}
| Error:
| cannot infer type for local variable x
| (lambda expression needs an explicit target-type)
| var x = () -> {};
| ^---------------^

但是,下面的赋值却是有效的,原因是等式右边确实有一个明确的初始化。

var list = new ArrayList<>();

这个列表的静态类型是什么?变量的类型被推导为 ArrayList<Object>,这完全失去了泛型的意义,所以你可能会想避免这种情况。

对无法表示的类型(Non-Denotable Types)进行推断

Java 中存在大量无法表示的类型——这些类型存在于程序中,但是却不能准确地写出其名称。比如匿名类就是典型的无法表示的类型,你可以在匿名类中添加字段和方法,但你没办法在 Java 代码中写出匿名类的名称。尖括号运算符不能用于匿名类,而var 受到的限制会稍微少一些,它可以支持一些无法表示的类型,详细点说就是匿名类和交叉类型。

var 关键字也能让我们更有效地使用匿名类,它可以引用那些不可描述的类型。一般来说是可以在匿名类中添加字段的,但是你不能在别的地方引用这些字段,因为它需要变量在赋值时指定类型的名称。比如下面这段代码就不能通过编译,因为 productInfo 的类型是 Object,你不能通过 Object 类型来访问 name 和 total 字段。


Object productInfo = new Object() {
String name = "Apple";
int total = 30;
};
System.out.println("name = " + productInfo.name + ", total = " +
productInfo.total);

使用 var 可以打破这个限制。把一个匿名类对象赋值给以 var 声明的局部变量时,它会推断出匿名类的类型,而不是把它当作其父类类型。因此,匿名类上声明的字段就可以引用到。


var productInfo = new Object() {
String name = "Apple";
int total = 30;
};
System.out.println("name = " + productInfo.name + ", total = " +
productInfo.total);

乍一看这只是语言中比较有趣的东西,并不会有太大用处。但在某些情况下它确实有用。比如你想返回一些值作为中间结果的时候。一般来说,你会为此创建并维护一个新的类,但只会在一个方法中使用它。在 Collectors.averagingDouble() 的实现中就因为这个原因,使用了一个 double 类型的小数组。

有了 var 之后我们就有了更好的处理办法 - 用匿名类来保存中间值。现在来思考一个例子,有一些产品,每个都有名称、库存和货币价值或价值。我们要计算计算每一项的总价(数量*价值)。这些是我们要将每个 Product 映射到其总价所需要的信息,但是为了让信息更有意义,还需要加入产品的名称。下面的示例描述了在 Java 10 中如何使用 var 来实现这一功能:


var products = List.of(
new Product(10, 3, "Apple"),
new Product(5, 2, "Banana"),
new Product(17, 5, "Pear"));
var productInfos = products
.stream()
.map(product -> new Object() {
String name = product.getName();
int total = product.getStock() * product.getValue();
})
.collect(toList());
productInfos.forEach(prod ->
System.out.println("name = " + prod.name + ", total = " +
prod.total));
This outputs:
name = Apple, total = 30
name = Banana, total = 10
name = Pear, total = 85

并非所有无法表示的类型都可以用 var - 它只支持匿名类和交叉类型。由通配符匹配的类型就不能被推断,这会避免与通配符相关的错误被报告给 Java 程序员。支持无法表示的类型的目的是在推断类型中尽量保留更多信息,让人们可以利用局部变量并更好地重构代码。这一特性的初衷并不是要人们像上面的示例中那样编写代码,而是为了使用 var 简化处理无法表示类型相关的一些问题。以后是否会使用 var 来处理无法表示的类型的一些细节问题,尚不可知。

类型推断建议

类型推断确实有助于快速编写 Java 代码,但是可读性如何呢?开发者大约会花 10 倍于写代码的时候来阅读代码,因此应该让代码更易读而不是更易写。var 对此带来的改善程度总是主观评价的,不可避免地会有人喜欢它,也会有人讨厌它。你应该关注的是如何帮助团队成员阅读你的代码,所以如果他们喜欢阅读使用 var 的代码,那就用,不然就不用。

有时候,显示类型也会降低可读性。比如,在循环遍历 Map 的 entryset 时,你需要找到 Map.Entry 对象的类型参数。这里有一个遍历 Map 的示例,这个 Map 将国家名称映射到其中的城市名称列表。


Map<String, List<String>> countryToCity = new HashMap<>();
// ...
for (Map.Entry<String, List<String>> citiesInCountry : countryToCity.entrySet()) {
List<String> cities = citiesInCountry.getValue();
// ...
}

然后用 var 来重写这段代码,减少重复和繁琐的东西:


var countryToCity = new HashMap<String, List<String>>();
// ...
for (var citiesInCountry : countryToCity.entrySet()) {
var cities = citiesInCountry.getValue();
// ...
}

这里不仅带来了可读性方面的优势,在改进和维护代码方面也带来了优势。如果我们在显式类型的代码中将城市从 String 表示的名称改为 City 类,以保留更多城市信息,那就需要重写所有依赖于特定类型的代码,比如:


Map<String, List<City>> countryToCity = new HashMap<>();
// ...
for (Map.Entry<String, List<City>> citiesInCountry : countryToCity.entrySet()) {
List<City> cities = citiesInCountry.getValue();
// ...
}

但使用了 var 关键字和类型推导,我们就只需要修改第一行代码就好:


var countryToCity = new HashMap<String, List<City>>();
// ...
for (var citiesInCountry : countryToCity.entrySet()) {
var cities = citiesInCountry.getValue();
// ...
}

这说明了一个使用 var 变量的重要原则:不要为了易于编码而优化,也不要为了易读而优化,而要了易维护性而优化。同时要考虑部分代码可能以后会修改而要折衷考虑代码的可读性。当然如果说添加类型推断对代码只会有好处略显武断,有时明确的类型有助于代码可读性。特别是当某些生成的表达式类型不是很直观时,我将选择显式而不是隐式类型,比如从下边的代码中我并不能看出 getCitiest() 方法会返回什么对象:


Map<String, List<City>> countryToCity = getCities();
var countryToCity = getCities();

既然要同时考虑到可读性和 var ,那么如何折衷就成了一个新问题,一个建议是:关注变量名,这很重要!因为 var 失去代码的易读性,看到这样的代码你根本不知道代码的意图是什么,这就使得起好一个变量名更加重要。理论上这是JAVA程序员应努力的方面之一,实际上许多 Java 代码可读性的问题根本不在语言的特性本身,而存在于一些变量的命名不太恰当上。

IDE 中的类型推断

许多 IDE 都有提取局部变量的功能,它们可以正确地推断出变量的类型,并为你写出来。这一特性与 Java 10 的 var 有一些重复。IDE 的这个特性和 var 一样都可以消除显式书写类型的必要性,但是它们在其它方面有一些不同。

局部提取功能会在代码中生成完整的、类型明确的局部变量。而 var 则是消除了在代码写显式书写类型的必要。所以虽然他们在简化书写代码方面有着类似的作用,但 var 对代码可读性的影响是局部提取功能所不具备的。就像我们前面提到,它多数时候会提高可读性,但有时候会可能会降低可读性。

与其它编程语言比较

Java 并不是首先实现变量类型推断的语言。类型推断在近几十年来被广泛应用于其它语言中。实际上,Java 10 中通过 var 带来的类型推断非常有限,形式上也相对拘束。这是一种简单的实现,可以将与 var 声明相关的编译错误限制在一条语句当中,因为 var 推断算法只需要计算赋值给变量的表达式的类型。此外,用在大多数语言中的 Hindley-Milner 类型推断算法在最坏的情况下会花费指数级时间,这会降低 javac 的速度。

总结

var 对于 Java 语言的生产力和可读性来说是一项很不错的新特性,但不应该止步于此。将来版本的 Java 将会继续保持语言的革新和现代性。举例来说,在 Java 10 发布仅仅 6 个月之后,就将发布并长期支持的 Java 11,其 var 关键字将可以在 lambda 表达式的参数中使用。这能让你拥有正式的参数类型推断能力,这很有用,不过,你还是需要加上 Java 注解,下面是例子:

(@Nonnull var x, var y) -> x.process(y)

一些函数式编程的想法已经被实现,并且已经为与将来的 Java 版本的结合做好准备。举例来说,模式匹配和值类型。这并不意味着 Java 会变得不再是我们熟悉和喜爱的 Java,它只是会变得比以前更灵活、可读性更强,并且更简洁。

来源:https://www.oschina.net/translate/jdk-10-local-variable-type-inference

标签:java10,局部,变量
0
投稿

猜你喜欢

  • 详细解读Java Spring AOP

    2022-10-09 11:06:06
  • SpringCloud实战小贴士之Zuul的路径匹配

    2021-05-26 13:26:36
  • JAVA设计模式之建造者模式原理与用法详解

    2022-09-25 19:43:04
  • Flutter WillPopScope拦截返回事件原理示例详解

    2023-07-19 12:53:35
  • Android之AttributeSet案例详解

    2022-03-20 12:05:12
  • Android studio 自动换行和取消自动换行操作

    2023-08-09 12:16:16
  • Java 异步实现的几种方式小结

    2022-09-23 00:26:56
  • android studio与手机连接调试步骤详解

    2023-06-09 09:54:26
  • Spring中的注解之@Override和@Autowired

    2022-08-07 19:56:47
  • 实例讲解Java读取一般文本文件和word文档的方法

    2023-11-13 05:09:53
  • Gradle属性设置及环境变量全面教程

    2023-08-10 18:20:51
  • java学习之利用TCP实现的简单聊天示例代码

    2021-07-13 00:43:18
  • C语言使用strcmp()函数比较两个字符串的实现

    2023-10-15 13:06:55
  • Mybatis-plus配置分页插件返回统一结果集

    2022-05-27 19:15:06
  • Spring MVC过滤器-登录过滤的代码实现

    2021-06-05 15:01:43
  • C#微信公众号开发 微信事件交互

    2023-04-22 21:18:31
  • Java语言描述二叉树的深度和宽度

    2021-12-02 10:59:34
  • Springboot-admin整合Quartz实现动态管理定时任务的过程详解

    2023-08-26 03:01:07
  • C#实现学生成绩管理系统

    2021-06-05 22:51:31
  • Unity解析gif动态图操作

    2022-05-27 08:16:43
  • asp之家 软件编程 m.aspxhome.com