Why Java Sucks and C# Rocks(4):泛型

原文 - Why Java Sucks and C# Rocks(4):泛型

Java 5.0和C# 2.0发布于同一年,各自添加了一个重要的特性:泛型。泛型可以让程序员针对泛化的数据类型编写相同的算法,这大大增强了两种语言的类型系统及抽象能力。不过即便是这看似相近的功能,Java和C#两者在语言实现和功能上也有很明显的区别,这甚至会形成编程方式上的不同。在这里可能需要事先打声招呼,因为在这个特定的话题上,语言和运行时的确是密不可分的,因此在这篇文章中我会涉及到相对较多的“运行平台”上的比较,而这点在整个系列中是我尽量避免的。不过您请放心,只此一次,欢迎监督。

Java的泛型:Type Erasure#

Java语言里的泛型完全是由编译器实现的,JVM在这里不提供任何支持。因此Java泛型是所谓的“类型擦除(Type Erasure)式泛型”,因为代码中的类型信息在编译成bytecode之后便完全消失了,被“擦除”了。如果要说的具体一些,那便是说如下的代码:

Java
public class MyHashMap<TKey, TValue> {
private HashMap<TKey, TValue> m_map = new HashMap<TKey, TValue>();
public TValue get(TKey key) {
return this.m_map.get(key);
}
public void put(TKey key, TValue value) {
this.m_map.put(key, value);
}
public static void main(String[] args) {
MyHashMap<String, Integer> map = new MyHashMap<String, Integer>();
map.put("Hello", 5);
int i = map.get("Hello");
}
}

上面的代码简单地没有意义:我们开发了一个MyHashMap泛型类,封装了标准库中HashMap泛型类,并暴露出简单的get和set两个泛型方法。不过,这段代码在编译成bytecode之后其实就变成了下面的样子:

Java
public class MyHashMap {
private HashMap m_map = new HashMap();
public Object get(Object key) {
return this.m_map.get(key);
}
public void put(Object key, Object value) {
this.m_map.put(key, value);
}
public static void main(String[] args) {
MyHashMap map = new MyHashMap();
map.put("Hello", 5);
int i = (Integer)map.get("Hello");
}
}

事实上,这两段代码可以说是等价的。那么编译器在这里做了哪些事情呢?首先,它把标准库中的HashMap还原成本来面目:键和值都是Object类型的容器。同时,我们编写的MyHashMap类的泛型信息也消失了。最后,在使用MyHashMap的地方编译器为我们添加了类型转化的代码。这种做法的确在代码层面保证了类型安全,不过在运行时层面上和以前没有任何区别。

Type Erasure的缺点#

有人说,如何实现有什么大不了的,只要Java语言也实现了和C#一样的泛型不就行了么。只可惜,Java语言在实际上带来了许多限制。如果您是一个C#开发人员,可能很难想象以下Java代码都是不合法的:

Java
public class MyClass<E> {
public static void myMethod(Object item) {
if (item instanceof E) { // Compiler error
...
}
E item2 = new E(); // Compiler error
E[] iArray = new E[10]; // Compiler error
}
}

由于JVM不提供对泛型的支持,因此对于JVM上支持泛型的语言,如Scala,这方面的压力就完全落在编译器身上了。而且,由于这些语言以JVM为底,Type Erasure会影响JVM平台上几乎所有语言。以Scala为例,它的模式匹配语法可以用来判断一个变量的类型:

Scala
value match {
case x:String => println("Value is a String")
case x:HashMap[String, Int] => println("Value is HashMap[String, Int]")
case _ => println("Value is not a String or HashMap[String, Int]")
}

猜猜看,如果value变量是个HashMap[Int, Object]类型的对象,上面的代码会输出什么结果呢?由于JVM的Type Erasure特性,以上代码输出的却是“Value is HashMap[String, Int]”。这是因为在运行期间JVM并不包含泛型的类型信息,HashMap[K, V]即是HashMap,无论HashMap[String, Int]还是HashMap[Int, Object]都是HashMap,JVM无法判断不同泛型类型的集合之间有什么区别。不过还好,Scala编译器遇到这种情况会发出警告,程序员可以了解这些代码可能会出现的“误会”,一定程度上避免了违反程序员直觉的情况发生。

但是Java的泛型实现相对于C#来说更明显的区别可能是在性能上。.NET 2.0引入了泛型之后,带来的显著优势之一便是性能上的提高。因为在写一些容器类,如List<T>Dictionary<TKey, TValue>的时候,无须像Java平台里那样不断的拆箱装箱,这方面真正的泛型容器无疑具有性能优势。这篇文章便进行了这方面的讨论和比较。在评论中有人说,这方面可以通过使用特定类型的容器,如IntFloatHashMap来改进性能。但显然,这除了引入更多代码造成复杂度的提高之外,更加丧失了“泛型”本身的最大优势:抽象能力、泛化能力。试想,我们又该如何为不同的非泛型容器统一增加一些处理方法呢?而在.NET中,我们只要针对Dictionary<TKey, TValue>写通用的代码即可,运行时会为我们生成最优化的执行代码

之前我也谈到过,使用值类型在某些场景下——如并行计算时,对性能的影响十分显著。这方面JVM多核计算的专家Dr. Cliff Click也表达过类似的观点,您可以在他的文章中搜索“Value Types”相关的内容。不过,这更像是前一篇谈Java基础类型时该讨论的问题,现在权当一个补充吧。

C#与Java的常见编程方式#

在C#中,我们时常会写一些这样的辅助方法:

C#
public static class Retriever
{
public static T TryGet<T>(IDictionary<string, object> dict, string key, T defaults)
{
object value;
if (dict.TryGetValue(key, out value) && value is T)
{
return (T)value;
}
else
{
return defaults;
}
}
}

由于.NET 2.0在运行时层面上对泛型提供了支持,因此事实上TryGet方法在调用时,泛型类型T也是方法体内获得的信息之一。于是,我们便可以在C#中便可以判断一个对象的类型是不是T。那么上面这代码可以如何使用呢?

C#
var dict = new Dictionary<string, object>();
int intValue = Retriever.TryGet(dict, "UserID", 0);
string userName = Retriever.TryGet(dict, "UserName", "");

这个辅助方法常用于与JSON的互操作中。例如客户端传递过来一个JSON字符串,我们只能将其反序列成“字符串到Object类型的映射”,因为在C#或Java这种强类型语言中,我们只有这样才能统一数字、时间或是布尔值等多种类型。这使我们在获取值的时候,必须不断和“类型”打交道。还好,我们在TryGet里封装了“尝试获取”、“判断类型”、“返回默认值”等逻辑,便可以让代码变得简洁优雅了许多(只可惜现在还在谈C# 2.0,如果有了3.0的扩展方法之后会更漂亮)。

当然,这方面还是动态语言的优势比较明显。当然,C# 4.0的动态支持在这方面也可以大显神威——不过这是后话,暂且按下不表。

同样,在Java中我们无法创建泛型类型的数组:

Java
public static <T> T[] convert(List<T> list) {
T[] array = new T[list.size()]; // Compiler error
...
}

为了创建数组,我们必须将元素的类型(类似于.NET中的Type对象)作为方法的参数传进去:

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

于是在使用时:

Java
List<Integer> list = ...
Integer[] array = convert(list, Integer.class);

对于我这种习惯写C#代码的人来说,这又何必这么麻烦呢?幸运的是,在Java语言中,Class本身可以进行泛型约束,这样我们至少可以保证传入的Class对象和list的元素是相同类型的(否则无法编译通过)。这样的麻烦之处还有许多,例如最近我写的项目中有一个类似于克隆对象的方法,假设现也在Java中实现一下吧:

Java
public static <T> T clone(T entity) {
T copy = new T(); // Compiler error
...
}

只可惜我们无法这样创建一个泛型对象,我们最多只能这么做:

public static <T> T clone(T entity) throws Exception {
T copy = (T)type.newInstance();
...
}

那么在C#中呢?我们完全可以添加针对构造函数的“泛型约束”来实现优雅高效(避免了反射)的代码:

C#
public static T Clone<T>(T entity) where T : new()
{
T copy = new T();
...
}

不过在这里,我认为更重要的一点是,一但添加了泛型约束,用户便必须传入一个拥有默认构造函数的类型作为泛型参数,否则无法编译通过。这点在Java语言中并没有类似的东西,这意味着如果传入的类型不包含默认构造函数的话,就只能在运行时由newInstance方法抛出异常了。

事实上,在Java中“显示”传入类型参数的做法,不仅仅是冗余和麻烦,我认为这也会导致一些API设计上的问题。我在想会不会有这种情况,例如某个泛型方法,它在v1.0中的实现不需要从调用折那里得到一个类型信息,但是在v2.0的开发过程中,API的编写者忽然发现这个方法需要得到泛型类型的具信息才能进行功能上的改进——那么这时能否加上额外的参数呢?当然不行,这样就破坏了公开的API,会造成不兼容的情况出现。这实在是种难以两全的做法。

关于Java的Type Erasure特性,以及基于C#与.NET中泛型特性的常见编程模式(如泛型类型字典),在我之前的文章中也有过讨论,您可以将其(以及评论)作为本文的补充。

本文为 赵劼 发表在 个人博客 的系列文章之一。