Java 与 CSharp 中的泛型

简述

Java 选择的泛型实现方式是”类型擦除式泛型”(Type Erasure Generics),而 C# 选择的泛型实现方式是”具体化式泛型”(Reified Generics)。具现化、偏特化这些名词最初都是源于C++ 模板语法中的概念,可以不必纠结其概念定义。C# 里面泛型无论在程序源码、编译后的中间语言(IL,这时泛型是一个占位符),或是运行时期的CLR里面都是切实存在的,List 与 List 就是两个不同的类型,他们由系统在运行期生成,有自己独立的虚方法表和类型数据。而 Java 语言中的泛型则只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type),并且在相应的位置插入了强制转换代码,对于运行期 的 Java 语言而言,ArrayList 和 ArrayList 其实是同一个类型。

如果是 C# 开发者,很难想象以下的 Java 代码是不合法的

1
2
3
T t = new T();
T[] array = new T[10];
List<T>[] listArray = new ArrayList<T>[10];

上述示例仅是 Java 泛型在编码阶段的不良影响,这个阶段的问题还能通过其他方法弥补 (多写几行代码,方法中多加一两个类型参数),然而,在性能上的差距则是难以用编码弥补的。自 C# 2.0 引入了泛型后,带来的显著优势之一便是对比起 Java 在执行性能上的的提高,在使用平台提供的容器类型(例如 List Dictionary<TKey, TValue>)时,无需像 Java 那样不厌其烦的拆装箱,如果在 Java 中想避免这种性能损失,需要构造一个与数据类型相关的容器类(例如 IntFloatHashMap)。显然,这样除了引入了更多的代码,复杂度提高,复用性降低外,丧失了泛型本身的存在价值。

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

关于泛型

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

Java 到1.4.2版之前都没有支持过泛型,而到 Java 5.0 突然要支持泛型,还要让以以前编译的程序在新版本的虚拟机还能正常运行,就意味着以前没有的限制不能突然冒出来。

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

1
2
3
4
5
6
7
// 编译通过、运行时报错
Object[] array = new String[10];
array[0] = 10;
// 编译、运行都不会报错
ArrayList list = new ArrayList();
list.add(Integer.valueOf(10));
list.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(老但有泛型) ArrayList(新且有泛型) 这样的容器,可能会被骂的更狠。

如果当时有足够的时间来好好设计和实现,完全有可能做出更好的泛型系统,如今的 Valhalla 项目正在还以前泛型实现偷懒留下的技术债。

Java 类型擦除

由于 Java 选择了第二条,直接把已有的类型泛型化。要让所有需要泛型化的已有类型都原地泛型化。如 ArrayList,原地泛型化后变成了 ArrayList ,需要保证以前直接用 ArrayList 的代码泛型的新版本中还能使用这同一个容器,这就必须让所有泛型化的实例类型,如 ArrayList ArrayList 这些全部自动成为 ArrayList 的子类型才行,否则类型转换将是不安全的。由此引出了裸类型(Raw Type)的概念,裸类型应是所有该类型泛型化实例的共同父类型(Super Type),只有这样,如下的复制才是被系统允许的,从子类到父类的安全转型。

1
2
3
4
5
ArrayList<Integer> iList = new ArrayList<>();
ArrayList<String> sList = new ArrayList<>();
ArrayList list; // Raw use of parameterized class 'ArrayList'
list = iList;
list = sList;

接下来的问题是如何实现裸类型。这又出现了两种选择:

  1. 运行期由 JVM 自动地、真实地构造出 ArrayList 这样的类型,自动实现从 ArrayList 派生自 ArrayList 的继承关系来满足裸类型的定义。
  2. 简单粗暴地直接在编译时把 ArrayList 还原成 ArrayList ,只在元素访问、修改时自动插入一些强制类型转换和检查指令。

当然结果大家都知道了,Java 选择了第二种。将第一段代码编译成 Class 文件,再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了 Java 泛型出现以前的代码,类型变为了裸类型,只是在元素访问的时候插入了从 Object 到 String 的强制转型代码,如第二段代码所示。

1
2
3
4
5
6
// 泛型擦除前
Map<String, String> map = new HashMap<>();
map.put("hello", "你好");
map.put("how are you", "你好吗");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you"));
1
2
3
4
5
6
// 泛型擦除后
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"));

缺陷

  1. 使用泛型擦除实现导致了对原始类(Primitive Type) 数据的支持成了新麻烦。

    1
    2
    3
    4
    5
    ArrayList<int> iList = new ArrayList<>();
    ArrayList<long> lList = new ArrayList<>();
    ArrayList list;
    list = iList;
    list = lList;

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

  2. 运行期无法取到泛型类型信息。使得一些代码变得极其繁琐,例如本文第一段代码的几种 Java 不支持的泛型用法,都是由于运行期 JVM 无法取得泛型类型而导致的。

    1
    2
    3
    4
    public static <T> T[] convert(List<T> list, Class<T> componentType) {
    T[] array = (T[]) Array.newInstance(componentType, list.size());
    ...
    }

    上述代码,写一个从泛型版本的从 List 到数组的转换方法,由于不能从 List 中取得参数化类型 T,所以不得不从另一个额外参数中再传一个数组的组件类型进去,实属无奈。

  3. 通过擦除实现泛型,还丧失了一些面向对象应有的优雅,带来了一些模糊情况。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 'method(List<String>)' clashes with 'method(List<Integer>)'
    // both methods have same erasure
    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 List 编译之后都被擦除了,变成了同一裸类型 List。类型擦除导致这两个方法的特征签名一模一样。但实际上这仅该方法是无法重载的一部分原因。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    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;
    }

    上述的代码竟然是可以正常使用的(在一些 JVM 中,例如 JDK 6)。为两个方法指定不同的返回值,方法的重载竟然成功了,简直是打破了我们对于 Java 语言中返回值不参与重载选择的基本认知。

    实际上这当然不是根据返回值来确定的,能编译和执行成功,是因为两个 method() 方法加入了不同的返回值后才能共存在一个 Class 文件中。方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名中,所以返回值不参与重载选择,但是在 Class 文件格式之中,只要描述符不是完全一致的两个方法,就可以共存。

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

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

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

值类型

目前比较明确的是未来的 Java 应该会提供 “值类型” (Value Type) 的语言层面的支持。

说到值类型,这也是 C# 用户攻讦 Java 语言的常用武器之一

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

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