分类: Java
2009-07-01 11:30:54
很多 Java 程序员都使用过集合(Collection),集合中元素的类型是多种多样的,例如,有些集合中的元素是 Byte 类型的,而有些则可能是 String 类型的,等等。Java 语言之所以支持这么多种类的集合,是因为它允许程序员构建一个元素类型为 Object 的 Collection,所以其中的元素可以是任何类型。
当使用 Collection 时,我们经常要做的一件事情就是要进行类型转换,当转换成所需的类型以后,再对它们进行处理。很明显,这种设计给编程人员带来了极大的不便,同时也容易引入错误。
在 很多 Java 应用中,上述情况非常普遍,为了解决这个问题,使 Java 语言变得更加安全好用,近些年的一些编译器对 Java 语言进行了扩充,使 Java 语言支持了"泛型",特别是 Sun 公司发布的 JDK 5.0 更是将泛型作为其中一个重要的特性加以推广。
本 文首先对泛型的基本概念和特点进行简单介绍,然后通过引入几个实例来讨论带有泛型的类,泛型中的子类型,以及范化方法和受限类型参数等重要概念。为了帮助 读者更加深刻的理解并使用泛型,本文还介绍了泛型的转化,即,如何将带有泛型的 Java 程序转化成一般的没有泛型的 Java 程序。这样,读者对泛型的理解就不会仅仅局限在表面上了。考虑到多数读者仅仅是使用泛型,因此本文并未介绍泛型在编译器中的具体实现。Java 中的泛型和 C++ 中的模板表面上非常相似,但实际上二者还是有很大区别的,本文最后简单介绍了 Java 中的泛型与 C++ 模板的主要区别。
泛型本质上是提供类型的"类型参数",它们也被称为参数化类型(parameterized type)或参量多态(parametric polymorphism)。其实泛型思想并不是 Java 最先引入的,C++ 中的模板就是一个运用泛型的例子。
GJ(Generic Java)是对 Java 语言的一种扩展,是一种带有参数化类型的 Java 语言。用 GJ 编写的程序看起来和普通的 Java 程序基本相同,只不过多了一些参数化的类型同时少了一些类型转换。实际上,这些 GJ 程序也是首先被转化成一般的不带泛型的 Java 程序后再进行处理的,编译器自动完成了从 Generic Java 到普通 Java 的翻译。具体的转化过程大致分为以下几个部分:
A elem;
,方法声明 Node (A elem){};
,其中,A 用来标记 elem 的类型,它就是类型变量。转化后的程序和没有引入泛型时程序员不得不手工完成转换的程序是非常一致的,具体的转化过程会在后面介绍。GJ 保持了和 Java 语言以及 Java 虚拟机很好的兼容性,下面对 GJ 的特点做一个简要的总结。
以上是泛型的一些主要特点,下面通过几个相关的例子来对 Java 语言中的泛型进行说明。
为了帮助大家更好地理解 Java 语言中的泛型,我们在这里先来对比两段实现相同功能的 GJ 代码和 Java 代码。通过观察它们的不同点来对 Java 中的泛型有个总体的把握,首先来分析一下不带泛型的 Java 代码,程序如下:
1 interface Collection { |
接口 Collection
提供了两个方法,即添加元素的方法 add(Object x)
,见第 2 行,以及返回该 Collection
的 Iterator
实例的方法 iterator()
,见第 3 行。Iterator
接口也提供了两个方法,其一就是判断是否有下一个元素的方法 hasNext()
,见第 8 行,另外就是返回下一个元素的方法 next()
,见第 7 行。LinkedList
类是对接口 Collection
的实现,它是一个含有一系列节点的链表,节点中的数据类型是 Object,这样就可以创建任意类型的节点了,比如 Byte, String
等等。上面这段程序就是用没有泛型的传统的 Java 语言编写的代码。接下来我们分析一下传统的 Java 语言是如何使用这个类的。
代码如下:
1 class Test { |
从上面的程序我们可以看出,当从一个链表中提取元素时需要进行 类型转换,这些都要由程序员显式地完成。如果我们不小心从 String 类型的链表中试图提取一个 Byte 型的元素,见第 15 到第 16 行的代码,那么这将会抛出一个运行时的异常。请注意,上面这段程序可以顺利地经过编译,不会产生任何编译时的错误,因为编译器并不做类型检查,这种检查是 在运行时进行的。不难发现,传统 Java 语言的这一缺陷推迟了发现程序中错误的时间,从软件工程的角度来看,这对软件的开发是非常不利的。接下来,我们讨论一下如何用 GJ 来实现同样功能的程序。源程序如下:
程序的功能并没有任何改变,只是在实现方式上使用了泛型技术。
我们注意到上面程序的接口和类均带有一个类型参数 A,它被包含在一对尖括号(< >)中,见第 1,6 和 13 行,这种表示法遵循了
C++ 中模板的表示习惯。这部分程序和上面程序的主要区别就是在 Collection
, Iterator
, 或 LinkedList
出现的地方均用 Collection
, Iterator
, 或 LinkedList
来代替,当然,第 22 行对构造函数的声明除外。
下面再来分析一下在 GJ 中是如何对这个类进行操作的,程序如下:
1 class Test { |
在这里我们可以看到,有了泛型以后,程序员并不需要进行显式的 类型转换,只要赋予一个参数化的类型即可,见第 4,8 和 12 行,这是非常方便的,同时也不会因为忘记进行类型转换而产生错误。另外需要注意的就是当试图从一个字符串类型的链表里提取出一个元素,然后将它赋值给一个 Byte 型的变量时,见第 16 行,编译器将会在编译时报出错误,而不是由虚拟机在运行时报错,这是因为编译器会在编译时刻对 GJ 代码进行类型检查,此种机制有利于尽早地发现并改正错误。
类型参数的作用域是定义这个类型参数的整个类,但是不包括静态成员函数。这是因为当访问同一个静态成员函数时,同一个类的不同实例可能有不同的类型参数,所以上述提到的那个作用域不应该包括这些静态函数,否则就会引起混乱。
在 Java 语言中,我们可以将某种类型的变量赋值给其父类型所对应的变量,例如,String 是 Object 的子类型,因此,我们可以将 String 类型的变量赋值给 Object 类型的变量,甚至可以将 String [ ] 类型的变量(数组)赋值给 Object [ ] 类型的变量,即 String [ ] 是 Object [ ] 的子类型。
上述情形恐怕已经深深地印在了广大读者的脑中,对于泛型来讲,上述情形有所变化,因此请广大读者务必引起注意。为了说明这种不同,我们还是先来分析一个小例子,代码如下所示:
1 List |
上述代码的第二行将 List
赋值给了 List
,按照以往的经验,这种赋值好像是正确的,因为 List
应该是 List
的子类型。这里需要特别注意的是,这种赋值在泛型当中是不允许的!List
也不是 List
的子类型。
如果上述赋值是合理的,那么上面代码的第三行的操作将是可行的,因为 lo
是 List
,所以向其添加 Integer 类型的元素应该是完全合法的。读到此处,我们已经看到了第二行的这种赋值所潜在的危险,它破坏了泛型所带来的类型安全性。
一般情况下,如果 A 是 B 的子类型,C 是某个泛型的声明,那么 C
并不是 C
的子类型,我们也不能将 C
类型的变量赋值给 C
类型的变量。这一点和我们以前接触的父子类型关系有很大的出入,因此请读者务必引起注意。
在这一部分我们将讨论有关泛化方法(generic method )和受限类型参数(bounded type parameter)的内容,这是泛型中的两个重要概念,还是先来分析一下与此相关的代码。
这里定义了一个接口 Comparable
,用来和 A 类型的对象进行比较。类 Byte 实现了这个接口,并以它自己作为类型参数,因此,它们自己就可以和自己进行比较了。
第 14 行到第 25 行的代码定义了一个类 Collections
,这个类包含一个静态方法 max(Collection xs)
,它用来在一个非空的 Collection
中寻找最大的元素并返回这个元素。这个方法的两个特点就是它是一个泛化方法并且有一个受限类型参数。
之
所以说它是泛化了的方法,是因为这个方法可以应用到很多种类型上。当要将一个方法声明为泛化方法时,我们只需要在这个方法的返回类型(A)之前加上一个类
型参数(A),并用尖括号(< >)将它括起来。这里的类型参数(A)是在方法被调用时自动实例化的。例如,假设对象 m 的类型是 Collection
,那么当使用下面的语句:
Byte x = Collections.max(m); |
调用方法 max 时,该方法的参数 A 将被推测为 Byte。
根据上面讨论的内容,泛化方法 max 的完整声明应该是下面的形式:
|
但是,我们见到的 max 在 < A > 中还多了 "implements Comparable" 一项,这是什么呢?这就是我们下面将要谈到的"受限的类型参数"。
在上面的例子中,类型参数 A 就是一个受限的的类型参数,因为它不是泛指任何类型,而是指那些自己和自己作比较的类型。例如参数可以被实例化为 Byte,因为程序中有 Byte implements Comparable
的语句,参见第 5 行。这种限制(或者说是范围)通过如下的方式表示,"类型参数 implements 接口",或是 "类型参数 extend 类",上面程序中的"Byte implements Comparable
在 前面的几部分内容当中,我们介绍了有关泛型的基础知识,到此读者对 Java 中的泛型技术应该有了一定的了解,接下来的这部分内容将讨论有关泛型的转化,即如何将带有泛型的 Java 代码转化成一般的没有泛型 Java 代码。其实在前面的部分里,我们或多或少地也提到了一些相关的内容,下面再来详细地介绍一下。
首先需要明确的一点是上面所讲的这种 转化过程是由编译器(例如:Javac)完成的,虚拟机并不负责完成这一任务。当编译器对带有泛型的 Java 代码进行编译时,它会去执行类型检查和类型推断,然后生成普通的不带泛型的字节码,这种字节码可以被一般的Java虚拟机接收并执行,这种技术被称为擦除 (erasure)。
可见,编译器可以在对源程序(带有泛型的 Java 代码)进行编译时使用泛型类型信息保证类型安全,对大量如果没有泛型就不会去验证的类型安全约束进行验证,同时在生成的字节码当中,将这些类型信息清除掉。
对于不同的情况,擦除技术所执行的"擦除"动作是不同的,主要分为以下几种情况:
LinkedList
将被"擦除"为 LinkedList;
除此之外,还需要注意的一点是,在某些情况下,擦除技术需要引入类型转换(cast),这些情况主要包括:
情况 1. 方法的返回类型是类型参数;
情况 2. 在访问数据域时,域的类型是一个类型参数。
例如在本文"带有泛型的类"一小节的最后,我们给出了一段测试程序,一个 Test 类。这个类包含以下几行代码:
8 LinkedList |
第 10 行的代码进行了类型转换,这是因为在调用 next()
方法时,编译器发现该方法的返回值类型是类型参数 A(请参见对方法 next()
的定义),因此根据上面提到的情况 1,需要进行类型转换。
上面介绍了泛型转化中的擦除技术,接下来,我们讨论一下泛型转化中的另外一个重要问题--桥方法(bridge method)。
Java 是一种面向对象的语言,因此覆盖(overridden)是其中的一项重要技术。覆盖能够正常"工作"的前提是方法名和方法的参数类型及个数完全匹配(参 数的顺序也应一致),为了满足这项要求,编译器在泛型转化中引入了桥方法(bridge method)。接下来,我们通过一个例子来分析一下桥方法在泛型转化中所起的作用。 在本文"泛化方法和受限类型参数"一小节所给出的代码中,第 9 行到第 11 行的程序如下所示:
9 public int compareTo(Byte that) { |
第 12 行的方法 compareTo(Object that)
就是一个桥方法,在这里引入这个方法是为了保证覆盖能够正常的发生。我们在前面提到过,覆盖必须保证方法名和参数的类型及数目完全匹配,在这里通过引入这
个"桥"即可达到这一目的,由这个"桥"进行类型转换,并调用第 9 行参数类型为 Byte 的方法 compareTo(Byte
that),需要注意的一点是这里的 "Object" 也并不一定是完全匹配的类型,但由于它是 Java 语言中类层次结构的根,所以这里用
"Object" 可以接受其他任何类型的参数。
根据面向对象的基本概念,我们知道,重载(overloading)允许桥方法和原来的方法共享同一个方法名,正如上面例子所显示的那样,因此桥方法的引入是完全合法的。一般情况下,当一个类实现了一个参数化的接口或是继承了一个参数化的类时,需要引入桥方法。
到此,我们对泛型中的子类型,带有泛型的类,泛化方法,受限类型参数以及泛型的转化进行了简要的介绍,下面部分将结合这些技术对前面提到的例子进行一下总结,以便能够帮助读者更深刻更全面地理解泛型。
首先来分析一下本文提到的那个 Collection
的例子。这里先是定义了两个接口 Collection
和 Iterator
,然后又定义了一个对接口 Collection
的一个实现 LinkedList
。根据上面所介绍的对泛型的转化过程,这段代码转化后的 Java 程序为:
1 interface Collection { |
通过分析上述代码,我们不难发现,所有参数化类型 Collection, Iterator 和 LinkedList 中的类型参数 "A" 全都被擦除了。另外,剩下的类型变量 "A" 都用其上限进行了替换,这里的上限是 Object,见黑体字标出的部分,这是转化的关键部分。
下面我们分析一下在介绍有关泛化方法(generic method)和受限类型参数(bounded type parameter)时举的那个例子,该段 GJ 代码经过转换后的等价 Java 程序如下所示:
1 interface Comparable { |
同样请读者注意黑体字标出的部分,这些关键点我们在前面已经介绍过了,故不赘述。唯一需要注意的一点就是第 18,20,22 行出现的Comparable。 在泛型转化中,类型变量应该用其上限来替换,一般情况下这些上限是 "Object",但是当遇到受限的类型参数时,这个上限就不再是 "Object" 了,编译器会用限制这些类型参数的类型来替换它,上述代码就用了对 A 进行限制的类型 "Comparable" 来替换 A。
桥方法的引入,为解决覆盖问题带来了方便,但是这种方法还存在一些问题,例如下面这段代码:
根据以上所讲的内容,这部分代码转换后的 Java 程序应该是如下这个样子:
1 interface Iterator { |
相信有些读者已经发现了这里的问题,这不是一段合法的 Java 源程序,因为第 14 行和第 16 行的两个 next() 有相同的参数,无法加以区分。代码中的 %1% 和 %2% 是为了区分而人为加入的,并非 GJ 转化的结果。
不 过,这并不是什么太大的问题,因为 Java 虚拟机可以区分这两个 next() 方法,也就是说,从 Java 源程序的角度来看,上述程序是不正确的,但是当编译成字节码时,JVM 可以对两个 next() 方法进行识别。这是因为,在 JVM 中,方法定义时所使用的方法签名包括方法的返回类型,这样一来,只要 GJ 编译出的字节码符合Java字节码的规范即可,这也正好说明了 GJ 和 JVM 中字节码规范要求的一致性!
最后,值得一提的是,JDK 5.0 除了在编译器层面对 Java 中的泛型进行了支持,Java 的类库为支持泛型也做了相应地调整,例如,集合框架中所有的标准集合接口都进行了泛型化,同时,集合接口的实现也都进行了相应地泛型化。
GJ 程序的语法在表面上与 C++ 中的模板非常类似,但是二者之间有着本质的区别。
首先,Java 语言中的泛型不能接受基本类型作为类型参数――它只能接受引用类型。这意味着可以定义 List
其
次,在 C++ 模板中,编译器使用提供的类型参数来扩充模板,因此,为 List 生成的 C++ 代码不同于为
List 生成的代码,List 和 List 实际上是两个不同的类。而 Java
中的泛型则以不同的方式实现,编译器仅仅对这些类型参数进行擦除和替换。类型 ArrayList