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