一、Java泛型的由来
在泛型的概念提出来之前,一般的类和方法只能使用具体的类型:要么是基本类型,要么是自定义的类。如果要编写可以应用于多种类型的代码(泛化),这种刻板的限制对代码的束缚就会很大。对于经常使用未经泛型改造的集合类的程序员来说,可以深深体会到这种痛苦。在泛型之前,多态可以算是一种泛化机制,不过由于类继承的限制,比如final类型不能扩展,java的单根继承等,也使多态这种泛化手法受到很多限制。于是在JDK 5中引入了Java泛型(generics)。
Java泛型实现了参数化类型的概念,通过解耦类或方法与所使用的类型之间的约束,来使代码支持多种类型。泛型最主要的应用是在JDK5中的新集合框架中,下面是引入泛型前后对集合的使用:
不使用泛型:
// Removes 4-letter words from c. Elements must be strings
static void expurgate(Collection c) {
for (Iterator i = c.iterator(); i.hasNext(); )
if (((String) i.next()).length() == 4)
i.remove();
}
使用泛型:
// Removes the 4-letter words from c
static void expurgate(Collection<String> c) {
for (Iterator<String> i = c.iterator(); i.hasNext(); )
if (i.next().length() == 4)
i.remove();
}
从上面的代码片段中可以看到使用泛型的好处,使用泛型不用对类型做强制转换,这样编译器可以尽早的发现类型不匹配的问题,减少运行时类型错误的发生。泛型的主要目的之一就是
用来指定容器要持有什么类型的对象,而且由编译器来保证类型的正确
性。
当然为了保证与旧有版本的兼容性,Java泛型在实现上存在一些不够优雅的地方。与C++的模板相比,Java泛型给人一种投机取巧的感觉。
JVM是不支持泛型的,只是编译器“做了手脚”。
二、泛型的实现
1、类型信息让狗吃了
package study.java.core.generic;
import java.util.ArrayList;
public class ErasedTypeEquivalence {
public static void main(String[] args) {
Class c1 = new ArrayList<Integer>().getClass();
Class c2 = new ArrayList<String>().getClass();
System.out.println("c1:"+c1);
System.out.println("c2:"+c2);
System.out.println("c1 == c2 : " +(c1==c2));
}
}
运行结果:
c1:class java.util.ArrayList
c2:class java.util.ArrayList
c1 == c2 : true
ArrayList<String>和ArrayList<Integer>类型相同?开什么玩笑!呵呵,淡定,这就是Java泛型实现策略。Java中的泛型是在编译器这个层次实现的,在生成的Java字节码中不包含泛型中的类型信息,使用泛型的时候加上的类型参数会被编译器在编译的时候去掉,这个过程就称为
类型擦除(Type erasure)。从运行结果中可以看出,ArrayList<String>和ArrayList<Integer>都被编译器转化成了
原始类型,JVM是看不到泛型附加的类型信息的。所以,对于Java泛型,你需要面对一个残酷的现实:
在泛型代码内部,无法获得任何有关泛型参数类型的信息。
Java泛型中,类型信息不是让狗吃了,而是被编译器吃了。
2、类型擦除的过程
类型擦除的基本过程是:先找出用来替换类型参数的具体类,如果没有指定则为Object,如果指定类型参数的上界,则使用这个上界。然后把代码中的类型参数都替换成具体的类,同时去掉出现的类型声明,即去掉<>的内容。比如T get()方法声明就变成了Object get();List<String>就变成了List。
以下是对类Pair的类型擦除的分析过程:
package study.java.core.generic;
public class Pair<T> {
private T first;
private T sencond;
public Pair(T first,T sencond){
this.first = first;
this.sencond = sencond;
}
public T getFirst() {
return first;
}
public void setFirst(T first) {
this.first = first;
}
public T getSencond() {
return sencond;
}
public void setSencond(T sencond) {
this.sencond = sencond;
}
}
通过类分析器(利用反射获取类的完整结构)进行分析后得到的结果如下:
没有指定具体类型:
public class study.java.core.generic.Pair extends java.lang.Object{
//域
private java.lang.Object first;
private java.lang.Object sencond;
//构造器
public Pair(java.lang.Object, java.lang.Object);
//方法
public void setFirst(java.lang.Object);
public java.lang.Object getSencond( );
public void setSencond(java.lang.Object);
public java.lang.Object getFirst( );
}
T被替换为Object。
将Pair的类型参数加上限定,比如 Pair<T extends List>:
public class study.java.core.generic.Pair extends java.lang.Object{
//域
private java.util.List first;
private java.util.List sencond;
//构造器
public Pair(java.util.List, java.util.List);
//方法
public void setFirst(java.util.List);
public java.util.List getSencond( );
public void setSencond(java.util.List);
public java.util.List getFirst( );
}
T被替换为类型参数的上限java.util.List。
注意:
某些情况下,由于存在类型擦除,T被替换为类型参数的上限,所以会存在强制类型转换的情况,只不过编译器会帮我们处理罢了。比如在调用泛型方法,返回值被擦除的情况:
public static void main(String[] args) {
Pair<String> p = new Pair<String>("1", "2");
String f = p.getFirst();//编译器会先调用getFirst()方法,然后将返回的Object结果强制转换为String。由于在编译期间已经确保传入参数的正确性,所以此处强制转换不为出现异常。
}
当存在一个泛型域的时候,也会在字节码中插入相应的强制类型转换。对传递进来的值进行额外的编译期检查,并插入对传递出去的值的转型,这些动作都是发生在边界处。边界是运行时的问题:即对象进入和离开方法的地点。这些正是编译器在编译期执行类型检查并插入转型代码(擦除)的地点。
3、类型擦除的问题
由于类型擦除的存在,导致使用Java泛型出现如下问题:
一个类不能实现同一个泛型接口的两种变体。
interface Payable<T>{}
class Employee implements Payable<Employee>{}
class Hourly extends Employee implements Payable<Hourly>{}//编译不通过,Payable接口不能实现两次.
重载的问题,由于擦除的存在,重载方法将产生相同的类型签名,编译不会通过。
class UserList<W,T>{
void f(List<W> v){}
void f(List<T> v){}//两个方法签名一样,编译不通过!
}
类型擦除与多态冲突,这个在桥方法中说明。
4、桥方法
泛型方法的类型擦除会带来两个问题,1、类型擦除与多态的冲突。2、方法签名的冲突。Java泛型是如何解决这两个问题的?那就是通过引入桥方法。
package study.java.core.generic;
import java.util.Date;
public class DateInterval extends Pair<Date> {
public DateInterval(Date first, Date sencond) {
super(first, sencond);
}
@Override
public Date getFirst() {
return super.getFirst();
}
@Override
public void setFirst(Date first) {
super.setFirst(first);
}
public static void main(String[] args) {
DateInterval di = new DateInterval(new Date(), new Date());
Pair<Date> pr = di;//父类引用指向子类对象,典型的多态
Date date = new Date(2013, 10, 12);
System.out.println("原来的日期:"+pr.getFirst());
pr.setFirst(date);//貌似重写了,其实这都是假象!
System.out.println("新设置的日期:"+date);
System.out.println("通过父类引用修改后的日期(pr.setFirst(date)):"+pr.getFirst());
}
}
运行结果:
原来的日期:Sat Mar 29 09:00:13 CST 2014
新设置的日期:Wed Nov 12 00:00:00 CST 3913
通过父类引用修改后的日期(pr.setFirst(date)):Wed Nov 12 00:00:00 CST 3913
我们知道Java中的方法调用采用的是
动态绑定的方式,应该呈现出多态的特性:
子类覆写超类中的方法,如果将子类向下转型成超类后,仍然可以调用覆写后的方法。但是泛型类的类型擦除造成了一个问题,Pair 的原始类型中存在的方法是:
public void setFirst(Object first)
而子类DateInterval中存在的方法是:
public void setFirst(Date first)
我们的本意是想覆盖Pair中的setFirst方法,但从方法签名上来看,两个是完全不同的方法,这样
类型擦除就和多态产生了冲突。但从运行结果来看,DateInterval确实覆写了Pair中的setFirst方法,这是如何实现的呢?我们使用类分析器对DateInterval进行分析:
public class study.java.core.generic.DateInterval extends study.java.core.generic.Pair{
//构造器
public DateInterval(java.util.Date, java.util.Date);
//方法
public void setFirst(java.util.Date);//方法4
public volatile void setFirst(java.lang.Object); //方法1,注意签名
public java.util.Date getFirst( );//方法2
public volatile java.lang.Object getFirst( );//方法3,注意签名!方法2和方法3存在方法签名冲突吗?
public static void main(java.lang.String[]);
}
由类分析器得出的结果可以看出,编译器为我们添加了方法1和方法3两个方法,这两个方法就是桥方法。真正覆写超类的就是它。语句
pr.setFirst(date);
实际调用的是方法1,
public volatile void setFirst(java.lang.Object);
通过这个方法再去调用方法4。桥方法的内容就是做了
方法调用的转发,所以起名为桥方法(桥模式),具体内容如下:
public volatile void setFirst(Object first){
this.setFirst((java.util.Date) first);
}
这样结果就符合面向对象的多态特性,实现了方法的动态绑定。不过,这种做法容易给我们造成错觉,认为 public void setFirst(java.util.Date)覆盖了public void setFirst(java.lang.Object),其实这只是假象罢了。
对于方法2和方法3,从方法签名的角度来看,两个方法完全相同,那它们是如何共存的?如果是我们自己编写Java代码,这样的代码是无法通过编译器的检查的,但是虚拟机却是允许这样做的,因为
虚拟机通过参数类型和返回类型来确定一个方法,所以编译器为了实现泛型的多态允许自己做这个看起来“不合法”的事情。从JDK1.5开始,在一个方法覆盖另一个方法时可以指定一个更严格的返回类型,它的机制也是同样使用的桥方法,示例如下:
父类:getList返回的类型为List。
package study.java.core.generic;
import java.util.ArrayList;
import java.util.List;
public class Bridge {
public <T> List<T> getList(){
return new ArrayList<T>();
}
}
子类:getList返回的类型为ArrayList,更严格了。
package study.java.core.generic;
import java.util.ArrayList;
public class ArrayBridge extends Bridge{
@Override
public <T> ArrayList<T> getList() {
return new ArrayList<T>();
}
}
通过分析ArrayBridge可知,编译器为该类生成了桥方法:
public class study.java.core.generic.ArrayBridge extends study.java.core.generic.Bridge{
//构造器
public ArrayBridge( );
//方法
public java.util.ArrayList getList( );
public volatile java.util.List getList( );//桥方法
}
三、类型系统
在Java中,我们比较熟悉的是通过继承机制而产生的类型体系结构。比如String继承自Object。根据Liskov替换原则,子类是可以替换父类的。当需要Object类的引用的时候,如果传入一个String对象是没有任何问题的。但是反过来的话,即用父类的引用替换子类引用的时候,就需要进行强制类型转换。编译器并不能保证运行时刻这种转换一定是合法的。这种自动的子类替换父类的类型转换机制,对于数组也是适用的。 String[]可以替换Object[]。但是泛型的引入,对于这个类型系统产生了一定的影响。
1、一箱苹果是一箱水果吗?
在基于继承机制的类型体系结构中,一个苹果是一个水果,一箱苹果是一箱水果(从数组的角度看),但在泛型系统中一箱苹果却不是一箱水果。
数组角度:
Apple[] apples = new Apple[1];
Fruit[] fruits = apples;//将Apple[]赋值给Fruit[]是合法的,因为前者是后者的子类型。
fruits[0] = new Pear();//编译可以通过,但运行时会抛出ArrayStoreException异常。
对于数组来说,如果A是B的子类型,那么A[]也是B[]的子类型。由于数组的这个特点,在存储数组时,java需要在运行时检查类型的兼容,这会造成一定的性能消耗,需要注意。
泛型角度:
List<Apple> apples = new ArrayList<Apple>();
List<Fruit> fruits = apples;//将水果篮赋值为苹果篮,指明该水果篮是放苹果的
fruits.add(new Pear());//由于Pear(梨)也是一种水果,所以也可以放到该水果篮中。这样苹果篮就可以放任何水果了,不符合实际。
从泛型角度看,一箱苹果不是一箱水果,因为一箱水果也可以存放其他的水果子类型,所以将appleas赋值给fruits编译通不过。
2、泛型类型系统的两个维度
引入泛型之后的类型系统增加了两个维度:一个是类型参数自身的继承体系结构,另外一个是泛型类或接口自身的继承体系结构。第一个指的是对于 List<String>和List<Object>这样的情况,类型参数String是继承自Object的。而第二种指的是 List接口继承自Collection接口。
对于这个类型系统,有如下的一些规则:
1、相同类型参数的泛型类的关系取决于泛型类自身的继承体系结构。即List<String>是Collection<String> 的子类型,List<String>可以替换Collection<String>。这种情况也适用于带有上下界的类型声明。
2、当泛型类的类型声明中使用了通配符的时候, 其子类型可以在两个维度上分别展开。如对Collection<? extends Number>来说,其子类型可以在Collection这个维度上展开,即List<? extends Number>和Set<? extends Number>等;也可以在Number这个层次上展开,即Collection<Double>和 Collection<Integer>等。如此循环下去,ArrayList<Long>和 HashSet<Double>等也都算是Collection<? extends Number>的子类型。
3、如果泛型类中包含多个类型参数,则对于每个类型参数分别应用上面的规则。
4、由于存在类型擦除,类型参数自身的继承体系结构对泛型类型系统的影响就很小了,毕竟这个维度的信息会被擦除(使用通配符的时候除外)。比如,List<String>和List<Object>两个泛型类型就不存在父子关系,那就不能使用List<String>替换掉List<Object>,但可以使用List<String>替换List<?>。
通过分析泛型类型系统的维度就很容易明白一箱水果和一箱苹果的关系了。
四、通配符
1、通配符?
Java泛型使用通配符?来表示未知类型。通配符所代表的其实是一组类型,但具体的类型是未知的。List<?>所声明的就是所有类型都是可以的。但是List<?>并不等同于List<Object>。List<Object>实际上确定了List中包含的是Object及其子类,在使用的时候都可以通过Object来进行引用。而List<?>则其中所包含的元素类型是不确定。其中可能包含的是String,也可能是 Integer。如果它包含了String的话,往里面添加Integer类型的元素就是错误的。正因为类型未知,就不能通过new ArrayList<?>()的方法来创建一个新的ArrayList对象。因为编译器无法知道具体的类型是什么。但是对于 List<?>中的元素却总是可以用Object来引用的,因为虽然类型未知,但肯定是Object及其子类。考虑下面的代码:
public void wildcard(List<?> list) {
list.add(1);//编译错误
}
如上所示,试图对一个带通配符的泛型类进行操作的时候,总是会出现编译错误。其原因在于通配符所表示的类型是未知的。
2、上下界
因为对于List<?>中的元素只能用Object来引用,在有些情况下不是很方便。在这些情况下,可以使用上下界来限制未知类型的范围。
?extends
子类型通配符,如List<? extends Number>说明List中可能包含的元素类型是Number及其子类。
?super
超类型通配符,如List<? super Number>说明List中包含的是Number及其父类。
当引入了上界之后,在使用类型的时候就可以使用上界类中定义的方法。比如访问 List<? extends Number>的时候,就可以使用Number类的intValue等方法。
3、存取原则和PECS法则
总结 ? extends 和 ? super 通配符的特征,我们可以得出以下结论:
如果你想从一个数据类型里获取数据,使用 ? extends 通配符;
如果你想把对象写入一个数据结构里,使用 ? super 通配符;
如果你既想存,又想取,那就别用通配符。
这就是Maurice Naftalin在他的《Java Generics and Collections》中所说的存取原则,以及Joshua Bloch在他的《Effective Java》中所说PECS法则,“Producer Extends, Consumer Super”,这个更容易记忆和运用。
五、最佳实践
在使用泛型的时候可以遵循一些基本的原则,从而避免一些常见的问题。
1、在代码中避免泛型类和原始类型的混用。比如List<String>和List不应该共同使用。这样会产生一些编译器警告和潜在的运行时异常。当需要利用JDK 5之前开发的遗留代码,而不得不这么做时,也尽可能的隔离相关的代码。
2、在使用带通配符的泛型类的时候,需要明确通配符所代表的一组类型的概念。由于具体的类型是未知的,很多操作是不允许的。
3、泛型类最好不要同数组一块使用。你只能创建new List<?>[10]这样的数组,无法创建new List<String>[10]这样的。这限制了数组的使用能力,而且会带来很多费解的问题。因此,当需要类似数组的功能时候,使用集合类即可。
4、不要忽视编译器给出的警告信息。
参考资料:
- Generics gotchas
- Java Generics FAQs
- Generics in Java Programming Language
- Java Generics Quick Tutorial