现代 Java(1):Java 也支持类型推断了?

类型推断是现代编程语言必备的能力,我们现在很少能够看到不支持类型推断的主流编程语言了。当然,C 语言是个例外。

类型推断就是编译器根据上下文信息对类型进行推算的能力。类型推断是一个极其复杂的话题,从工程应用的角度而言,我们不用过多关注其背后的数学原理。为了方便讨论,我们将类型的推断分为变量类型推断和泛型类型推断。

变量类型推断

变量的类型推断就是在变量声明时省略类型,编译器通过变量的初始化来推断其类型。

C++ 的 auto

变量类型推断最典型的例子莫过于 C++ 当中的这个场景了:

1
2
3
4
5
std::vector<std::map<std::string, std::vector<int>>> values;
...
for (std::vector<std::map<std::string, std::vector<int>>>::iterator i = values.begin(); i < values.end(); ++i) {
...
}

请注意,for 循环中的 i 的类型非常长,写起来繁琐之外,还很难写对这一度让 C++ 的开发者极度难受。不过,从 C++ 11 开始,类型推断的引入让事情变得简单了起来:

1
2
3
for (auto i = values.begin(); i < values.end(); ++i) {
...
}

i 的类型使用 auto 关键字替代,这样编译器就会根据 i 的初始化表达式 values.begin() 的类型推断出来。

Java 的 var

Java 从 Java 10 开始新增了 var 关键字来简化变量定义时的类型。

例如:

1
var list = new ArrayList<String>();

这里的 var 相当于 ArrayList<String>

var 只能用于局部变量的定义,不能用于类成员的定义,这一点与 C++ 的 auto 非常相似。

说明 在 Java 正式支持 var 关键字之前,著名的元编程框架 Lombok 就通过编译时修改 Java 语法树为 Java 添加了 var 关键字的支持,有兴趣的读者可以参考 Lombok 的官方文档:https://projectlombok.org/features/var

类型后置

C++ 和 Java 的变量声明中类型都在变量名前面,通常又被称为类型前置的形式。这类语言的特点是在语言诞生之初并没有类型推断的语法设计。

随着开发者对类型推断的需求的日益增长,业界编程语言设计的优秀实践的不断积累,越来越多的新语言选择了类型后置的形式。

下面是 Kotlin 的变量定义语法,类型后置的形式使得类型推断变得非常自然:

1
2
3
4
5
// 完整的变量定义
val s: String = "Hello World"

// 省略类型
val s = "Hello World"

变量 s 的类型可以通过初始化的表达式推断出来,因此可以省略。常见的采用类型后置的语法设计的语言还包括 Scala、Swift、TypeScript、Rust 等等。

全局类型推断

绝大多数编程语言在对变量的类型进行推断时,都只对变量定义时的初始化表达式做了分析,Rust 就是个例外。

1
2
3
let s;

s = "hello world";

Rust 允许先把变量定义出来,在后面根据对该变量的使用情况进行变量类型的推断。示例代码中变量 s 在定义时并没有声明类型,也没有进行初始化,Rust 编译器通过分析后面对 s 的赋值,推断出 s 的类型是 &str。这在 Kotlin 当中是不行的。

Rust 编译器通过上下文推断类型的能力在下面的例子当中用处更大。

作为对比,我们先给出 Kotlin 版本的写法:

1
2
val multiply2 = { i: Int -> i * 2 }
multiply2(10)

在这段 Kotlin 代码中,Lambda 表达式 multiply2 的参数 i 的类型必须显式地写出来,不然编译器就无法推断出 multiply2 的类型了。

接下来我们看一下等价的 Rust 代码:

1
2
let multiply2 = |i| i * 2;
multiply2(10);

注意 |i| 是 Rust 的 Lambda 表达式(或者闭包)的参数列表,我们发现 i 的类型并不需要明确地写出来,编译器通过分析后面的实参 10 即可推断出 i 的类型为 i32 了。

模板化的类型推断

multiply2 的例子还可以继续延伸。不管是 Kotlin 还是 Rust,multiply2 都是一个确定的类型,也就是说在上述代码之后追加一句 multiply2(30.0),编译器就会抱怨说 30.0Double(Kotlin)/ f64(Rust) 类型 ,而 multiply2 需要的是 Int(Kotlin)/i32(Rust)类型。不过,事情总有例外。

下面是使用 C++ 编写的等价代码:

1
2
3
auto multiply2 = [](auto i) { return i * 2; };
multiply2(10);
multiply2(30.0);

multiply2 的参数 i 的类型是 auto,它自身的类型也是 auto,这意味着它们的类型需要编译器来推断。接下来我们分别把 1030.0 传给 multiply2,然后我们就会发现,这都是合法的。这表明 multiply2 针对不同的类型会有不同的实现。对于 C++ 而言,auto 不仅仅是用于类型推断的关键字,很多时候我们把它当做模板的一种特殊形式来看待,似乎更容易理解。

Java 中的 Lambda 表达式的类型推断

既然提到了 Lambda 表达式的类型推断,那么我们能不能用 var 来定义 Lambda 表达式呢?答案当然是,不能。

1
2
3
4
5
var multiply2 = (int i) -> i * 2;
^^^
---------------------------------------------------------------------
Error: Cannot infer type: lambda expression requires an explicit target type
---------------------------------------------------------------------

如果我们在 Java 中试图使用 var 来定义一个变量,并使用 Lambda 表达式来初始化,就会得到上面的错误。不过,这个错误并不是 var 的问题,而是 Java 对函数类型的支持问题。这个话题我们将在后面的文章中详细探讨,这里就不再展开说明了。

分支表达式的类型推断

分支表达式在现代编程语言中非常常见。C 语言甚至就已经有了分支表达式:

1
2
3
int a = ...;
int b = ...;
int c = a > b ? a : b;

没错,?: 可能是最古老的分支表达式之一。

Java 当中除了 ?: 表达式以外,还从 Java 12 开始支持了 Switch 表达式(Java 14 正式支持) ,因此 Java 中的表达式类型推断也是值得探讨的内容。

1
2
3
4
5
6
7
var x = "...";
var y = switch (x) {
case "A" -> 1;
case "B" -> 2.0;
case "C" -> "Hello";
default -> new ArrayList<String>();
};

在这个 switch 表达式中,四个分支表达式的值类型分别为 int(Integer)、double(Double)、StringArrayList<String>。这意味着整体表达式的返回值 y 的类型只能是其中的一个,从数学的角度来讲,y 的类型为这四种类型的交集,Java 的类型系统中也确实存在交集类型的概念,即:

1
Integer & Double & String & ArrayList<String>

交集类型的计算结果其实就是这些类型的公共父类,因此 y 在编译时的类型为 Serializable

如果没有公共父类呢?这在 Java 当中是不可能的,因为所有的类型都至少有一个公共父类是 Object

顺便提一句,Kotlin 的推断方法也是类似的。作为对比,我们给出 Rust 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
let x = "Hello";
let y = match x {
"A" => 1,
"B" => 2.0,
^^^
---------------------------------------------
`match` arms have incompatible types [E0308]
expected integer, found floating-point number
---------------------------------------------
"C" => "Hello",
_ => vec![],
};

Rust 编译器在遇到各个分支的类型不兼容的情况时,会直接报错。实际上,C++ 的行为也是类似的。

为什么会有这样的差别呢?

我稍微做一下猜测,供大家参考。Java 和 Kotlin 的对象都是分配在堆内存上的,栈内存上只需保留一个引用,而这个引用的类型不管是什么,占用的内存大小都是固定的,因此在做分支表达式的类型推断时可以尽可能向开发者友好的方向设计。而 C++ 和 Rust 的编译器需要在编译时确定 y 的类型,以便于给他在栈内存上分配内存,因此遇到不兼容的类型时就只好拒绝编译了。

泛型类型推断

除了对变量的类型进行推断以外,还有对泛型类型的推断。

变量初始化表达式的泛型类型参数推断

我们还是以 ArrayList 为例,在 Java 7 之前的版本,我们需要完整的将类型写出来:

1
ArrayList<String> list = new ArrayList<String>();

从 Java 7 开始,编译器稍微为我们做一点简化,允许我们把初始化表达式中的泛型参数省略掉了:

1
ArrayList<String> list = new ArrayList<>();

理由也很简单,变量的类型已经明确,后面的泛型参数 String 显然是冗余的。

不得不说,这一点 Java 做得比 C# 似乎更好一些,在 C# 中定义一个类似的 List 时必须完整的写出泛型参数。如果省略泛型参数,那么编译器就会报告如下错误:

1
2
3
4
5
List<string> list = new List<>();
^^
------------------------
Type argument is missing
------------------------

当然,C# 的设计者可能觉得这里使用 var 会更好(就像 Java 10 之后那样)。

方法泛型类型参数的推断

定义在方法中的泛型参数也支持类型推断,例如:

1
2
3
public static <T> T identity(T t) {
return t;
}

identity 在调用时,泛型参数 T 可以通过函数参数 t 推断出来,因此无须显式写出:

1
String value = identity("Hello");

这个特性还有一个更为常见的使用场景:

1
2
3
public static <T> T fromJson(String json, Class<T> cls) {
...
}

注意到 Class 的泛型参数是 fromJson 的泛型参数 T,因此可以通过 cls 的实参类型来推断 T 的类型。例如:

1
User user = fromJson("{}", User.class);

你可能会想,竟然有了泛型参数 T,我们是不是可以直接使用 T.class 而不用向 fromJson 中传入 Class<T> 了呢?当然不能,这是因为 Java 的泛型会在编译时擦除,也就是说 T 在运行时并不存在。

像绝大多数编程语言一样,Java 也可以通过方法的返回值类型来推断泛型参数,例如:

1
2
3
4
5
6
public static <T> T get(String key) {
return ...;
}

// 调用处
String name = get("name");

调用时,如果返回值类型已经明确,则无须显式指定泛型参数。不过,C# 却不支持通过返回值类型来推断泛型参数,例如:

1
2
3
4
5
6
7
8
9
10
11
12
public static T get<T>(String key) {
return ...;
}

// 调用处
string name = get("name");
^^^
-------------------------------------------------
The type arguments for method 'T get<T>(string)'
cannot be inferred from the usage.
Try specifying the type arguments explicitly.
-------------------------------------------------

小结

本文从变量类型和泛型参数类型的推断两方面对 Java 的相关特性进行了介绍。为了方便读者对类型推断有更全面的认识,我们也列举了其他编程语言的相关特性作为参照。

综合来看,Java 在类型推断方面做得中规中矩,虽然没有像常见的现代编程语言那样能够做到极致,但也能够应对绝大多数的场景了。


关于作者

霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导