Java 不能实现真正泛型的原因是什么?

C++的泛型通俗点说是通过在编译时的实例化将泛型类(以类为例)实例化为多个实例。 而Java则是在编译的时候进行泛型的错误检查,然后进行类型擦除,去掉…
关注者
1,092
被浏览
359,312
登录后你可以
不限量看优质回答私信答主深度交流精彩内容一键收藏

先问是不是再问为什么系列。

题主的问题的标题:

Java不能实现真正泛型的原因?

然后问题描述里:

为什么Java不能像C++一样来实现泛型呢

所以题主是把C++的模版作为“真正的泛型”的参照设计,进而问为什么Java不能像C++一样实现泛型。

事实上要在Java的基础上实现类似C++模版的泛型,从实现的角度看是相当简单的。不但Oracle的Java语言组于2014年在Project Valhalla的Model 1原型中实现过,在更早的1996年的Pizza中也实现过。

导致Java 5引入的泛型采用擦除式实现的根本原因是兼容性上的取舍,而不是“实现不了”的问题。

============================================

历史背景

大家先来了解一下Java泛型发展的背景:

Pizza -> Generic Java -> Java 5 -> Project Valhalla
                               \-> Scala

不了解清楚背景,下面的话题根本无法讨论。

Pizza:(1996年的实验语言,在Java的基础上扩展了泛型、函数式编程等功能)

Pizza是Martin Odersky与Phil Wadler设计的、基于Java、在JVM上运行的语言。

引用

Pizza Tutorial

中的一段Pizza代码例子:

StoreSomething<String> a = new StoreSomething("I'm a string!");
StoreSomething<int> b = new StoreSomething(17+4);

b.set(9);

int i = b.get();
String s = a.get();

“真正的泛型”实现,在Java正式发布1年后就已经有了。

Generic Java(GJ):(1997/1998年的实验语言,借鉴Pizza的经验,给Java添加泛型支持)

这是由Sun的Java核心开发组对Pizza的泛型设计深感兴趣,与Martin和Phil联系,新开的合作项目,目的是为Java添加泛型支持(而不引入Pizza的其它功能,例如函数式编程支持)。

这里是Java为了兼容性而采用擦除式泛型实现的起点。

Java 5 generics:(正式向Java语言添加编译时泛型支持,基本上是GJ + wildcards)

以上历史的综述:

The Origins of Scala

(Martin Odersky的吐槽访谈)

<- 这个必读。访谈把槽点写得非常详细,所以我不想在这边赘述。

Project Valhalla:(正在进行中的OpenJDK项目,计划给未来的Java添加改进的泛型支持以及自定义值类型支持)

从Project Valhalla的角度再探讨以上的历史,2014年的时候的原型,以及对未来的展望:

State of the Specialization

(2014-07)

<- 这个也必读。这里描述的是Project Valhalla原型的Model 1到Model 3之间的状态,讲解了Java采用C++式的泛型实现会导致的问题,以及2014年时Valhalla考虑如何设计未来的Java泛型。

Project Valhalla当前的实现是Model 3。其源码在

hg.openjdk.java.net/val

相关介绍:

一句话演示当前Project Valhalla的Model 3原型的功能:可以ArrayList<int>,可以ArrayList<int>[]、可以在T为原始类型的时候 new T[],可以反射得到T,同时仍然可以ArrayList (raw type)。

之前的Model 1原型的做法跟C++的效果颇为相似,但跟Java已有设计的兼容性不好,所以被抛弃。参考上面提到的JVMLS 2015演讲的对现状及Model 1的介绍的其中几页:


JVMLS 2016的演讲也提到了同一个状况:

============================================

关于Java所强调的兼容性

引用前面提到的Martin Odersky的访谈的一句:

Martin Odersky: In the generics design, there were a lot of very, very hard constraints. The strongest constraint, the most difficult to cope with, was that it had to be fully backwards compatible with ungenerified Java.

然后引用Java语言规范的一段:

Chapter 13. Binary Compatibility

(不拷贝文本过来了,就放个传送门吧)

Java所强调的兼容性,是“二进制向后兼容性”(binary backwards compatibility)。

例如说,一个在Java 1.2、Java 1.4.2版本上可以正常运行的Class文件,放在一个Java 5、6、7、8的JRE(包括JVM与标准库)上仍然要可以正常运行。“Class文件”这里就是Java程序的“二进制”表现。

需要特别强调的是,“二进制兼容性”并不等价于“源码兼容性”(source compatibility)。这个的解释请参考上面放的Java语言规范的传送门。

另外要强调的是,Java从来都不支持高版本的Java编译生成的Class文件在低版本的JRE上运行。如果有人尝试这样做,就会得到

UnsupportedClassVersionError

——Class文件开头所记录的版本号信息的一个主要用途就是这个。

============================================

关于编译时模版式展开的泛型与Java的二进制兼容性的冲突

其实在前面放的传送门中,Brian Goetz大大在

State of the Specialization

与JVMLS 2015的演讲都说得非常清楚了。感兴趣的同学请务必参考那些资料。

这里就挑一个简单的点出来举例说说。

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

假如在没有泛型的Java里,我们有程序使用了java.util.ArrayList类,而且我们利用了它可以存异质元素的特性:

ArrayList things = new ArrayList();
things.add(Integer.valueOf(42));
things.add("hello world");

那么这段代码在Java 5引入泛型之后还必须要继续可以运行。

这里有两种设计思路:

  1. 需要泛型化的类型(主要是容器(Collections)类型),以前有的就保持不变,然后平行地加一套泛型化版本的新类型;
  2. 直接把已有的类型泛型化,让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于已有类型的泛型版。

.NET在1.1 -> 2.0的时候选择了上面选项的1,而Java则选择了2。

从Java设计者的角度看,这个取舍很明白:.NET在1.1 -> 2.0的时候,实际的应用代码量还很少(相对Java来说),而且整个体系都在微软的控制下,要做变更比较容易;

而在Java 1.4.2 -> 5.0的时候,Java已经有大量程序部署在生产环境中,已经有很多应用和库程序的代码。如果这些代码在新版本的Java中,为了使用Java的新功能(例如泛型)而必须做大量源码层修改,那么新功能的普及速度就会大受影响,而且新功能会被吐槽。

(结果其实坚持高度的向后兼容性的话,新功能无论怎么设计都要被吐槽的…)

Java在1.1 -> 1.2的时候,推翻了原本的容器类型的设计,添加了新的“Java Collections Framework)。这就是有两套“平行的”API的例子。看1998年当时的一篇介绍文里的一句:

Get started with the Java Collections Framework
JDK 1.2 introduces a new framework for collections of objects, called the Java Collections Framework. "Oh no," you groan, "not another API, not another framework to learn!"

于是在Java 1.2的时候,有Vector(老)有ArrayList(新),有Hashtable(老)有HashMap(新),已经被当时的许多开发吐槽死。这还是在Java的很早期,实际应用代码还没有很多的时候。

想像一下如果Java 5说要再加一套泛型化的Java Collection Framework,那画面实在太美。

回头看看.NET。在2.0添加了泛型之后,大家新写代码都用新的System.Collections.Generic容器,似乎也没怎么怀念过老的System.Collections以及System.Collections.Specialized容器类型。但.NET自身的标准库就有不少方法已经把老的容器类型写在了参数或返回值位置上,而这些方法至今都还保持着当时的样子——新代码也还是可能被这些方法“侵蚀”而不得不使用老的容器类型。新老容器类型混搭用,画面也是太美…

随便举个例子。

System.Xml.Serialization.XmlAnyElementAttributes

是一个ICollection / IEnumerable,但却不是一个ICollection<XmlAnyElementAttribute> / IEnumerable<XmlAnyElementAttribute>。如果我们有个方法只接受泛型容器类型(ICollection<T>、IEnumerable<T>),那要传个XmlAnyElementAttributes进入就蛋疼了——先自己手动转成一个泛型版容器(例如 List<XmlAnyElementAttribute>)然后再传进去。

OK不说.NET了。就算.NET也有坑,最终结果是大家通常都不会想起层级有过非泛型的黑历史,而在泛型世界里大家都很开心。

回到Java。还是用java.util.ArrayList为例。在原地泛型化后,现在这个类型变成了java.util.ArrayList<E>。但是以前的代码直接用ArrayList,在新版本里必须还能继续用,所以就引出了“raw type”的概念——一个类型虽然被泛型化了,但还可以把它当作非泛型化的类型用。

ArrayList         - raw type
ArrayList<E>      - open generic type (assuming E is type variable)
ArrayList<String> - closed generic type
ArrayList<?>      - unbounded wildcard type

(注意ArrayList作为raw type,与实例化泛型类型ArrayList<Object>、通配符泛型类型ArrayList<?>并不直接等价)

下面这样的代码必须可以编译运行:

ArrayList<Integer> ilist = new ArrayList<Integer>();
ArrayList<String> slist = new ArrayList<String>();
ArrayList list; // raw type
list = ilist;   // assigning closed generic type to raw type
list = slist;   // ditto

于是对泛型实例化的ArrayList<Integer>和ArrayList<String>来说,非泛型的ArrayList必须是它们的共同超类型(super type)。

拿C++ STL的std::vector<T>来看。我们可以这样写么:

vector<int> v1;
vector<string> v2;
vector v3; // error: vector is not a type
v3 = v1;
v3 = v2;
vector* p = &v1;
p = &v2;

这里v3和p相关的代码显然不能行。

首先vector就不是一个类型(只能有未实例化的vector<T>,或实例化的vector<int>之类)。

其次,就算vector<int>与vector<string>作为vector<T>的两个实例化类型,之间并没有共同超类型。所以就算想造出一个vector类型作为它们的超类型也造不出来。

(不过会有C++程序员会说:本来vector<int>和vector<string>就不可能兼容,硬造个超类型出来干嘛呢?能干啥呢?

而也会有别的C++程序员看看自己的代码里,有不少泛型类型都带着个非泛型的基类来提供共通功能,然后用泛型类型来提供泛型的皮)

再回到Java。像C++那样在编译时就模版展开的话,对raw type的支持就会非常困难。

而要支持raw type,最直接的办法就是通过擦除法来实现泛型——编译时类型确实时泛型的,但编译结束后泛型信息被擦除,最后的结果就是raw type(加上一些额外的类型检查,如果有wildcard bounds的话还会带上bounds信息)。

OK,那么确定下要有raw type,并且擦除法实现raw type最直观,那么原始类型与泛型如何交互呢?

ArrayList<int> ilist = new ArrayList<int>();
ArrayList<long> llist = new ArrayList<long>();
ArrayList list; // raw type
list = ilist;
list = llist;

呃。对泛型类型ArrayList<E>来说,泛型参数E被擦除后就变成了Object,那么这里我们要让int、long与Object赋值兼容…

GJ / Java 5说:这个问题有点麻烦,赶不及在这个版本发布前完成了,就先放着不管吧。于是Java 5的泛型就不支持原始类型,而我们不得不写恶心的ArrayList<Integer>、ArrayList<Long>…

<- 这就是一个偷懒了的地方。

现在,Project Valhalla就要还上之前偷懒留下的技术债,让泛型能对原始类型(以及未来的自定义值类型)也有效。

而在当前Model 3的设计中,实现的方式就是:

  • 不再完全擦除泛型信息,而是当Class文件可以记录完整的、结构化的泛型信息,并且让编译器可以指定默认擦除类型;
  • 在运行时,根据Class文件记录的泛型信息进行特化;
  • 默认的Java实现会让泛型对原始类型特化,而对引用类型保持之前擦除式实现的行为;但运行时可以反射获取的泛型信息会增加;
  • 其它在JVM上运行的语言,其编译器可以在生成Class文件时指定泛型要完全特化,这样到运行时这些泛型类型就会得到完全特化(类似C#的实现)

所以Project Valhalla就不得不面对上面的ArrayList list = new ArrayList<int>();的赋值兼容性问题,而现在不能光靠擦除来解决问题了。咋搞?

有一种可能的实现方案是,在运行时实例化ArrayList<int>类型时,自动生成能与ArrayList正确桥接的方法,让ArrayList<int>能在外表上表现得像ArrayList<Integer>一样与ArrayList兼容,而在骨子里仍然保持特化的实现。

目前还没有定论这块最终会采用怎样的实现方式。让我们拭目以待,看看Project Valhalla最终会是个什么样子。

最后放俩传送门: