10.3 Java语法糖的味道 10.3.1 泛型

10.3 Java语法糖的味道

几乎所有的编程语言都或多或少提供过一些语法糖来方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是它们或能提高效率,或能提升语法的严谨性,或能减少编码出错的机会。现在也有一种观点认为语法糖并不一定都是有益的,大量添加和使用含糖的语法,容易让程序员产生依赖,无法看清语法糖的糖衣背后,程序代码的真实面目。

总而言之,语法糖可以看作是前端编译器实现的一些“小把戏”,这些“小把戏”可能会使效率得到“大提升”,但我们也应该去了解这些“小把戏”背后的真实面貌,那样才能利用好它们,而不是被它们所迷惑。

10.3.1 泛型

泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的类型系统及抽象能力。

在2004年,Java和C#两门语言于同一年更新了一个重要的大版本,即Java 5.0和C#2.0,在这个大版本中,两门语言又不约而同地各自添加了泛型的语法特性。不过,两门语言对泛型的实现方式却选择了截然不同的路径。本来Java和C#天生就存在着比较和竞争,泛型这个两门语言在同一年、同一个功能上做出的不同选择,自然免不了被大家对比审视一番,其结论是Java的泛型直到今天依然作为Java 语言不如C#语言好用的“铁证”被众人嘲讽。笔者在本节介绍Java泛型时,并不会去尝试推翻这个结论,相反甚至还会去举例来揭示Java泛型的缺陷所在,但同时也必须向不了解Java泛型机制和历史的读者说清楚,Java选择这样的泛型实现,是出于当时语言现状的权衡,而不是语言先进性或者设计者水平不如C#之类的原因。

1.Java与C#的泛型

Java选择的泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics),而C#选择的泛型实现方式是“具现化式泛型”(Reified Generics)。具现化和特化、偏特化这些名词最初都是源于C++模版语法中的概念,如果读者本身不使用C++的话,在本节的阅读中可不必太纠结其概念定义,把它当一个技术名词即可,只需要知道C#里面泛型无论在程序源码里面、编译后的中间语言表示(Intermediate Language,这时候泛型是一个占位符)里面,抑或是运行期的CLR里面都是切实存在的,List<int>List<string>就是两个不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据。 而Java语言中的泛型则不同,它只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type,稍后我们会讲解裸类型具体是什么)了,并且在相应的地方插入了强制转型代码,因此对于运行期的Java语言来说,ArrayList<int>ArrayList<String>其实是同一个类型,由此读者可以想象“类型擦除”这个名字的含义和来源,这也是为什么笔者会把Java泛型安排在语法糖里介绍的原因。

读者虽然无须纠结概念,但却要关注这两种实现方式会给使用者带来什么样的影响。Java的泛型确实在实际使用中会有一些限制,如果读者是一名C#开发人员,可能很难想象代码清单10-2中的Java 代码都是不合法的。

代码清单10-2 Java中不支持的泛型用法
1
2
3
4
5
6
7
8
9
public class TypeErasureGenerics<E> {
public void doSomething(Object item) {
if (item instanceof E) { // 不合法,无法对泛型进行实例判断
...
}
E newItem = new E(); // 不合法,无法使用泛型创建对象
E[] itemArray = new E[^10]; // 不合法,无法使用泛型创建数组
}
}

上面这些是Java泛型在编码阶段产生的不良影响,如果说这种使用层次上的差别还可以通过多写几行代码、方法中多加一两个类型参数来解决的话,性能上的差距则是难以用编码弥补的。C#2.0引入了泛型之后,带来的显著优势之一便是对比起Java在执行性能上的提高,因为在使用平台提供的容器类型(如List<T>Dictionary<TKeyTValue>)时,无须像Java里那样不厌其烦地拆箱和装箱[^1],如果在Java中要避免这种损失,就必须构造一个与数据类型相关的容器类(譬如IntFloatHashMap这样的容器)。显然,这除了引入更多代码造成复杂度提高、复用性降低之外,更是丧失了泛型本身的存在价值。

Java的类型擦除式泛型无论在使用效果上还是运行效率上,几乎是全面落后于C#的具现化式泛型,而它的唯一优势是在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在Javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java 5.0之上。但这种听起来节省工作量甚至可以说是有偷工减料嫌疑的优势就显得非常短视,真的能在当年Java实现泛型的利弊权衡中胜出吗?答案的确是它胜出了,但我们必须在那时的泛型历史背景中去考虑不同实现方式带来的代价。

2.泛型的历史背景

泛型思想早在C++语言的模板(Template)功能中就开始生根发芽,而在Java语言中加入泛型的首次尝试是出现在1996年。Martin Odersky(后来Scala语言的缔造者)当时是德国卡尔斯鲁厄大学编程理论的教授,他想设计一门能够支持函数式编程的程序语言,又不想从头把编程语言的所有功能都再做一遍,所以就注意到了刚刚发布一年的Java,并在它上面实现了函数式编程的3大特性:泛型、高阶函数和模式匹配,形成了Scala语言的前身Pizza语言[^2]。后来,Java的开发团队找到了Martin Odersky,表示对Pizza语言的泛型功能很感兴趣,他们就一起建立了一个叫作“Generic Java”的新项目,目标是把Pizza语言的泛型单独拎出来移植到Java语言上,其最终成果就是Java 5.0中的那个泛型实现[^3],但是移植的过程并不是一开始就朝着类型擦除式泛型去的,事实上Pizza语言中的泛型更接近于现在C#的泛型。Martin Odersky自己在采访自述[^4]中提到,进行Generic Java项目的过程中他受到了重重约束,甚至多次让他感到沮丧,最紧、最难的约束来源于被迫要完全向后兼容无泛型Java,即保证“二进制向后兼容性”(Binary Backwards Compatibility)。二进制向后兼容性是明确写入《Java语言规范》中的对Java使用者的严肃承诺,譬如一个在JDK 1.2中编译出来的Class文件,必须保证能够在JDK 12乃至以后的版本中也能够正常运行[^5]。这样,既然Java到1.4.2版之前都没有支持过泛型,而到Java 5.0突然要支持泛型了,还要让以前编译的程序在新版本的虚拟机还能正常运行,就意味着以前没有的限制不能突然间冒出来。

举个例子,在没有泛型的时代,由于Java中的数组是支持协变(Covariant)的[^6],对应的集合类也可以存入不同类型的元素,类似于代码清单10-3这样的代码尽管不提倡,但是完全可以正常编译成Class文件。

代码清单10-3 以下代码可正常编译为Class
1
2
3
4
5
6
Object[] array = new String[^10]; 
array
[^0]: = 10; // 编译期不会有问题,运行时会报错
ArrayList things = new ArrayList();
things.add(Integer.valueOf(10)); //编译、运行时都不会报错
things.add("hello world");

为了保证这些编译出来的Class文件可以在Java 5.0引入泛型之后继续运行,设计者面前大体上有两条路可以选择:
1)需要泛型化的类型(主要是容器类型),以前有的就保持不变,然后平行地加一套泛型化版本 的新类型。
2)直接把已有的类型泛型化,即让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于 已有类型的泛型版。

在这个分叉路口,C#走了第一条路,添加了一组System.Collections.Generic的新容器,以前的System.Collections以及System.Collections.Specialized容器类型继续存在。C#的开发人员很快就接受了新的容器,倒也没出现过什么不适应的问题,唯一的不适大概是许多.NET自身的标准库已经把老容器类型当作方法的返回值或者参数使用,这些方法至今还保持着原来的老样子。

但如果相同的选择出现在Java中就很可能不会是相同的结果了,要知道当时.NET才问世两年,而Java已经有快十年的历史了,再加上各自流行程度的不同,两者遗留代码的规模根本不在同一个数量级上。而且更大的问题是Java并不是没有做过第一条路那样的技术决策,在JDK 1.2时,遗留代码规模尚小,Java就引入过新的集合类,并且保留了旧集合类不动。这导致了直到现在标准类库中还有Vector(老)和ArrayList(新)、有Hashtable(老)和HashMap(新)等两套容器代码并存,如果当时再摆弄出像Vector(老)、ArrayList(新)、Vector<T>(老但有泛型)、ArrayList<T>(新且有泛型)这样的容器集合,可能叫骂声会比今天听到的更响更大。

到了这里,相信读者已经能稍微理解为什么当时Java只能选择第二条路了。但第二条路也并不意味着一定只能使用类型擦除来实现,如果当时有足够的时间好好设计和实现,是完全有可能做出更好的泛型系统的,否则也不会有今天的Valhalla项目来还以前泛型偷懒留下的技术债了。下面我们就来看看当时做的类型擦除式泛型的实现时到底哪里偷懒了,又带来了怎样的缺陷。

3.类型擦除

我们继续以ArrayList为例来介绍Java泛型的类型擦除具体是如何实现的。由于Java选择了第二条路,直接把已有的类型泛型化。要让所有需要泛型化的已有类型,譬如ArrayList,原地泛型化后变成了ArrayList<T>,而且保证以前直接用ArrayList的代码在泛型新版本里必须还能继续用这同一个容器,这就必须让所有泛型化的实例类型,譬如ArrayList<Integer>ArrayList<String>这些全部自动成为ArrayList的子类型才能可以,否则类型转换就是不安全的。由此就引出了“裸类型”(Raw Type)的概念,裸类型应被视为所有该类型泛型化实例的共同父类型(Super Type),只有这样,像代码清单10- 4中的赋值才是被系统允许的从子类到父类的安全转型。

代码清单10-4 裸类型赋值
1
2
3
4
5
6
ArrayList<Integer> ilist = new ArrayList<Integer>();
ArrayList<String> slist = new ArrayList<String>();
ArrayList list;
// 裸类型
list = ilist;
list = slist;

接下来的问题是该如何实现裸类型。这里又有了两种选择:一种是在运行期由Java虚拟机来自动地、真实地构造出ArrayList<Integer>这样的类型,并且自动实现从ArrayList<Integer>派生自ArrayList的继承关系来满足裸类型的定义;另外一种是索性简单粗暴地直接在编译时把ArrayList<Integer>还原回ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令,这样看起来也是能满足需要,这两个选择的最终结果大家已经都知道了。代码清单10-5是一段简单的Java泛型例子,我们可以看一下它编译后的实际样子是怎样的。

代码清单10-5 泛型擦除前的例子
1
2
3
4
5
6
7
public static void main(String[] args) {
Map<String, String> map = new HashMap<String, String>();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you?"));
}

把这段Java代码编译成Class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了Java泛型出现之前的写法,泛型类型都变回了裸类型,只在元素访问时插入了从Object到String的强制转型代码,如代码清单10-6所示。

代码清单10-6 泛型擦除后的例子
1
2
3
4
5
6
7
public static void main(String[] args) {
Map map = new HashMap();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println((String) map.get("hello"));
System.out.println((String) map.get("how are you?"));
}

类型擦除带来的缺陷前面已经提到过一些,为了系统性地讲述,笔者在此再举3个例子,把前面与C#对比时简要提及的擦除式泛型的缺陷做更具体的说明。

首先,使用擦除法实现泛型直接导致了对原始类型(Primitive Types)数据的支持又成了新的麻烦,譬如将代码清单10-2稍微修改一下,变成代码清单10-7这个样子。

代码清单10-7 原始类型的泛型(目前的Java不支持)
1
2
3
4
5
ArrayList<int> ilist = new ArrayList<int>();
ArrayList<long> llist = new ArrayList<long>();
ArrayList list;
list = ilist;
list = llist;

这种情况下,一旦把泛型信息擦除后,到要插入强制转型代码的地方就没办法往下做了,因为不支持int、long与Object之间的强制转型。当时Java给出的解决方案一如既往的简单粗暴:既然没法转换那就索性别支持原生类型的泛型了吧,你们都用ArrayList<Integer>ArrayList<Long>,反正都做了自动的强制类型转换,遇到原生类型时把装箱、拆箱也自动做了得了。这个决定后面导致了无数构造包装类和装箱、拆箱的开销,成为Java泛型慢的重要原因,也成为今天Valhalla项目要重点解决的问题之一。

第二,运行期无法取到泛型类型信息,会让一些代码变得相当啰嗦,譬如代码清单10-2中罗列的几种Java不支持的泛型用法,都是由于运行期Java虚拟机无法取得泛型类型而导致的。像代码清单10-8 这样,我们去写一个泛型版本的从List到数组的转换方法,由于不能从List中取得参数化类型T,所以不得不从一个额外参数中再传入一个数组的组件类型进去,实属无奈。

代码清单10-8 不得不加入的类型参数
1
2
3
4
public static <T> T[] convert(List<T> list, Class<T> componentType) {
T[] array = (T[])Array.newInstance(componentType, list.size());
...
}

最后,笔者认为通过擦除法来实现泛型,还丧失了一些面向对象思想应有的优雅,带来了一些模棱两可的模糊状况,例如代码清单10-9的例子。

代码清单10-9 当泛型遇见重载1
1
2
3
4
5
6
7
8
public class GenericTypes {
public static void method(List<String> list) {
System.out.println("invoke method(List<String> list)");
}
public static void method(List<Integer> list) {
System.out.println("invoke method(List<Integer> list)");
}
}

请读者思考一下,上面这段代码是否正确,能否编译执行?也许你已经有了答案,这段代码是不能被编译的,因为参数List<Integer>List<String>编译之后都被擦除了,变成了同一种的裸类型List, 类型擦除导致这两个方法的特征签名变得一模一样。初步看来,无法重载的原因已经找到了,但是真的就是如此吗?其实这个例子中泛型擦除成相同的裸类型只是无法重载的其中一部分原因,请再接着看一看代码清单10-10中的内容。

代码清单10-10 当泛型遇见重载2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class GenericTypes {
public static String method(List<String> list) {
System.out.println("invoke method(List<String> list)");
return "";
}
public static int method(List<Integer> list) {
System.out.println("invoke method(List<Integer> list)");
return 1;
}
public static void main(String[] args) {
method(new ArrayList<String>());
method(new ArrayList<Integer>());
}
}

执行结果:

1
2
invoke method(List<String> list) 
invoke method(List<Integer> list)

代码清单10-9与代码清单10-10的差别,是两个method()方法添加了不同的返回值,由于这两个返回值的加入,方法重载居然成功了,即这段代码可以被编译和执行[^7]了。这是我们对Java语言中返回值不参与重载选择的基本认知的挑战吗?

代码清单10-10中的重载当然不是根据返回值来确定的,之所以这次能编译和执行成功,是因为两个method()方法加入了不同的返回值后才能共存在一个Class文件之中。第6章介绍Class文件方法表 (method_info)的数据结构时曾经提到过,方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名中,所以返回值不参与重载选择,但是在Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。也就是说两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个Class文件中的。

由于Java泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响并带来新的需求,如在泛型类中如何获取传入的参数化类型等。所以JCP组织对《Java虚拟机规范》做出了相应的修改,引入了诸如Signature、LocalVariableTypeTable等新的属性用于解决伴随泛型而来的参数类型的识别问题,Signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名[^8],这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。修改后的虚拟机规范[^9]要求所有能识别49.0以上版本的Class文件的虚拟机都要能正确地识别Signature参数。

从上面的例子中可以看到擦除法对实际编码带来的不良影响,由于List<String>List<Integer>擦除后是同一个类型,我们只能添加两个并不需要实际使用到的返回值才能完成重载,这是一种毫无优雅和美感可言的解决方案,并且存在一定语意上的混乱,譬如上面脚注中提到的,必须用JDK 6的Javac才能编译成功,其他版本或者是ECJ编译器都有可能拒绝编译。

另外,从Signature属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段取得参数化类型的根本依据。

4.值类型与未来的泛型

在2014年,刚好是Java泛型出现的十年之后,Oracle建立了一个名为Valhalla的语言改进项目[^10], 希望改进Java语言留下的各种缺陷(解决泛型的缺陷就是项目主要目标其中之一)。原本这个项目是计划在JDK 10中完成的,但在笔者撰写本节时(2019年8月,下个月JDK 13正式版都要发布了)也只有少部分目标(譬如VarHandle)顺利实现并发布出去。它现在的技术预览版LW2(L-World 2)[^11]是基于未完成的JDK 14 EarlyAccess来运行的,所以本节内容很可能在将来会发生变动,请读者阅读时多加注意。

在Valhalla项目中规划了几种不同的新泛型实现方案,被称为Model 1到Model 3,在这些新的泛型设计中,泛型类型有可能被具现化,也有可能继续维持类型擦除以保持兼容(取决于采用哪种实现方案),即使是继续采用类型擦除的方案,泛型的参数化类型也可以选择不被完全地擦除掉,而是相对完整地记录在Class文件中,能够在运行期被使用,也可以指定编译器默认要擦除哪些类型。相对于使用不同方式实现泛型,目前比较明确的是未来的Java应该会提供“值类型”(Value Type)的语言层面的支持。

说起值类型,这点也是C#用户攻讦Java语言的常用武器之一,C#并没有Java意义上的原生数据类型,在C#中使用的int、bool、double关键字其实是对应了一系列在.NET框架中预定义好的结构体 (Struct),如Int32、Boolean、Double等。在C#中开发人员也可以定义自己值类型,只要继承于ValueType类型即可,而ValueType也是统一基类Object的子类,所以并不会遇到Java那样int不自动装箱就无法转型为Object的尴尬。

值类型可以与引用类型一样,具有构造函数、方法或是属性字段,等等,而它与引用类型的区别在于它在赋值的时候通常是整体复制,而不是像引用类型那样传递引用的。更为关键的是,值类型的实例很容易实现分配在方法的调用栈上的,这意味着值类型会随着当前方法的退出而自动释放,不会给垃圾收集子系统带来任何压力。

在Valhalla项目中,Java的值类型方案被称为“内联类型”,计划通过一个新的关键字inline来定义, 字节码层面也有专门与原生类型对应的以Q开头的新的操作码(譬如iload对应qload)来支撑。现在的预览版可以通过一个特制的解释器来保证这些未来可能加入的字节码指令能够被执行,要即时编译的话,现在只支持C2编译器。即时编译器场景中是使用逃逸分析优化(见第11章)来处理内联类型的, 通过编码时标注以及内联类实例所具备的不可变性,可以很好地解决逃逸分析面对传统引用类型时难以判断(没有足够的信息,或者没有足够的时间做全程序分析)对象是否逃逸的问题。

[^1]: 这里有对这种性能损失会有多大的量化的讨论:http://fsharpnews.blogspot.com/2010/05/java-vs- f.html。
[^2]: 一般认为Scala的前身应该是Funnel,从Pizza语言中借鉴了一些技术和思想。
[^3]: 准确地说Java 5.0的泛型还有一部分是由Gilad Bracha和奥胡斯大学独立开发的通配符功能。
[^4]: 以上资料来源于Martin Odersky的自述: https://www.artima.com/scalazine/articles/origins_of_scala.html。
[^5]: 注意,保证的是二进制向后兼容性(即编译结果的兼容性),不是源码兼容性,也不保证(甚至是 不允许)高版本JDK的编译结果能前向兼容地运行在低版本Java虚拟机之上。
[^6]: 现在Java数组也是具有协变性的,请读者不要误解成“有泛型的时代”之后这点就改变了。
[^7]: 笔者在测试时使用了JDK 6的Javac编译器进行编译,前面提到前端编译器的实现在《Java虚拟机规 范》中的定义并不够具体,所以其他的前端编译器,如Eclipse JDT的ECJ编译器,仍然可能会拒绝编 译这段代码,ECJ编译时会提示“Method method(List<String>)has the same erasure method(List<E>)as another method in type GenericTypes”。
[^8]: 在《Java虚拟机规范(第2版)》(JDK 5修改后的版本)的4.4.4节及《Java语言规范(第3版)》 的8.4.2节中都分别定义了字节码层面的方法特征签名,以及Java代码层面的方法特征签名,特征签名 最重要的任务就是作为方法独一无二不可重复的ID,在Java代码中的方法特征签名只包括了方法名
称、参数顺序及参数类型,而在字节码中的特征签名还包括方法返回值及受查异常表,本书中如果指 的是字节码层面的方法签名,笔者会加入限定语进行说明,也请读者根据上下文语境注意区分。
[^9]: JDK 5对虚拟机规范修改:http://java.sun.com/docs/books/jvms/second_edition/jvms-clarify.html。
[^10]: 项目主页:https://wiki.openjdk.java.net/display/valhalla/Main。
[^11]: Valhalla的LW2原型:https://wiki.openjdk.java.net/display/valhalla/LW2。