首先并没有什么“真正的泛型”。C++ 的模板是泛型,Java 基于擦除实现的泛型也是泛型。
事实上,对于泛型的翻译有两种策略:同构翻译(homogeneous translation)和异构翻译(heterogeneous translation)。C++ 和(目前的)Java 的策略分别处于异构翻译和同构翻译的极端,一个为每种类型组合都创建一份特化,一个所有类型共享一种实现。而 C#/CLR 处于两者中间,为共享布局的引用类型同构翻译,为值类型异构翻译。
不管是同构翻译还是异构翻译,都有自己的优缺点。Java 的擦除实现带有不小的问题,常常被人诟病,但同样具有巨大的优势(当然如果擦除实现没有这么大优势的话,Java 也不会采用这种实现了)。
对 Java 的诟病,以及对具化泛型的期望,往往在这几点上:
List
到底是 List<String>
还是 List<Integer>
,想要拿到实际泛型参数相关的信息,而因为类型擦除,实际上并不能做到这一点。List<Integer>
,而用不了针对 int
进行布局特化的 List<int>
,底层存放的全是对 Integer
对象的引用,这造成了巨大的内存浪费,同时对现代 CPU 的缓存策略极端不友好,大量间接寻址产生大量 cache miss,产生大量的性能下降。这也是当前 Java 泛型擦除最大的问题。List<String>
会被擦除为 List
,所以当通过一些手段(强制转换,raw type 等)将其他类型的值放入这个 List 的时候并不会出错,直到实际访问时才会发生问题。实际上这不是单一的需求和目标,各自的目的不同,付出的成本与对应的收益也不同,不能从一而论。
看起来类型擦除有不少的问题,但另一边的模板也存在一些问题。
模板可以把具体操作推迟到被实例化的时候,根据传入的类型参数具体内容而确定表达式的具体含义,并且每个特化有单独的布局,编译器能为每个特化单独优化,这看起来不错。
但是,异构翻译的问题也是显然的。模板的每个实例都要有着不同的代码,这意味着模板展开会导致代码膨胀,产生更大的硬盘和内存占用。像 Scala 提供了 @specialized
注解,可以为原始类型生成特化的版本,结果就是因为指数爆炸,可以很轻松的用几行代码生成上百兆的 JAR。当然这是很极端的例子,一般不可能产生如此之多的模板实例,但 C++ 模板产生的代码膨胀也是必须要关心的问题。
同时,因为 vector<int>
和 vector<float>
是无关的两个类型,C++ 缺乏 Java 的类型通配符或者 C# 的声明处类型变异等能力。事实上,参数化类型组更适合通过同构翻译来表现。
前面说了擦除的问题所在,这些一直被诟病的问题让擦除看起来并不是一个好的选择。但事实上,选择类型擦除的方式实现泛型对于 2004 年的 Java 来说是一个明智且务实的选择,即使放到现在来看,它的优点依然是不可忽视的。
使得当时的 Java 选择擦除最主要的原因是兼容性。
Java 在 2004 年已经积累了大量的生态,如果把现有的类修改成泛型类,需要让所有用户全部重新修改或编译,那完全是不可想象的,会直接导致 Java 1.4 前后生态完全分裂。而 C# 的选择是原类放在那不修改,新创建一套平行的泛型化的库,让用户慢慢抛弃老库。这种选择对于当时用户不多的 C# 来说是可以接受的,但对于已经有大量用户的 Java 来说,这一样是会让生态产生巨大的分裂,并不是一个好的选择。
而 Java 现在的擦除实现做到了一个非常夸张的目标:它完全维护了二进制兼容性和源代码兼容性。
虽然一些类修改为了泛型类,但所有用到它的地方都不受任何影响。用 Java 1.4 或更早版本编译出的字节码一样能用,同时也能直接不做修改的在 Java 1.5 中进行编译。所有用户都可以自由的选择如何迁移到泛型类,用户的用户也不会受到影响。能够维护这种完全的兼容性,Java 的泛型设计在当时来说可谓完全成功。
其次,擦除维护了 JVM 生态类型系统的自由度。
虽然 Java 和 JVM 常常被绑定在一起,但它们是各自独立的,有着各自的规范。根据一些统计,有超过 200 种语言会编译到字节码。其中一些语言和 Java 的类型系统并不完全兼容,渴求更高的表达能力。
类型擦除是一个很好的实现复杂类型的方式,Haskell 就是一个使用类型擦除的典型例子,它大多数类型检查都放在编译时,而在编译后擦除类型。
由于通过类型擦除实现泛型,像 Scala 这样的语言可以以与 Java 泛型高度协同的方案实现远远超出 Java 类型系统表达能力的类型系统,同时保持高度的互操作性。
一个典型的反例就是 C#/CLR。CLR 的泛型系统严重制约了其上语言泛型的表达能力,C#、F# 等语言都因此难以拥有更强的类型系统,任何与 CLR 冲突的小区别都是致命的(譬如,因为 CLR 缺乏 bottom type,Scala 的 List
、Option
等实现在其上就几乎无法直接表达,需要重写改写以适应 CLR),必须和 CLR 共同演化才能实现很多能力。在 CLR 上擦除泛型以实现更强大的类型系统是可能的,但代价就是完全和 CLR 原生的泛型生态撕裂,而原生就基于擦除实现的泛型就不会有这个问题。
前面已经说过异构翻译导致的代码膨胀问题,这会产生难以避免的运行时开销。当然,这个开销往往是值得的,但异构翻译的开销不止于此,另一个重要的开销问题就是运行时类型检查。
运行时类型检查可以避免堆污染,维护安全性,并对于 List<String>
这样的简单类型来说,看起来开销往往是微不足道的。但是,在遇到复杂的类型(比如说 Map<? extends List<? super Foo>>, ? super Set<? extends Bar>>
)时 ,类型检查的开销可能会出乎意料的大,这也是不得不考虑的问题。
异构翻译和同构翻译都存在自己的弊病,而 OpenJDK 的 Project Valhalla 吸取了探索者们的经验与教训,在 Java 语言和 JVM 中融合两种方案尝试中接近了终点。
原始类型方面,Valhalla 已经准备好了很漂亮的答卷(JEP 401 和 JEP 402)。而在泛型方面,Project Valhalla 有着非常具有野心的目标:它专注于实现布局特化,弥补 Java 完全擦除泛型最主要的性能问题,绕开运行时类型检查等高开销操作,同时还要保留逐步迁移的兼容性,还要保持擦除带来的类型系统高自由度的优势。
我翻译了一篇 Valhalla 项目的设计说明,从中可以更深入的了解 Valhalla 的目标、历史,以及 L-World 下全新的类型系统。
Project Valhalla 在权衡下只关注于布局特化,对其他具化泛型的用途并没有太多帮助。不过具化泛型的其他功能仅在语言层面就可以很好的实现。譬如 Scala 用 ClassTag
和 TypeTag
就能简单地做到保留类型的功能:
def newArray[T : ClassTag](n: Int) = new Array[T](n) newArray[String](10) // ok class Foo[T : ClassTag] { def newArray(n: Int): Array[T] = new Array(n) } new Foo[String]().newArray(10) // ok def typeOf[T : TypeTag] = implicitly[TypeTag[T]].tpe println(typeOf[Map[_, Seq[_ <: String]]]) // Map[_, Seq[_ <: String]]
相对来说,这些功能的代价较为高昂,受益却略偏低,Valhalla 不关心于此也是能接受的。
参考: