Java泛型中的通配符(super和extends)辨析
Java中的泛型确实要比CPP中的泛型别扭,特别是容器和泛型结合的那一部分,实在令人疑惑。看来是Java1.0设计缺陷,后来找补又要向后兼容,只好设计了一个比较畸形的东西出来。想来向后兼容真的是很大的历史包袱啊。
话说我觉得《Java编程思想》的结构有点奇怪,看起来总觉得哪里不得劲,不如《C++ Primer》体系那么流畅……
闲话少说,Java泛型的<? super T>和<? Extends T>刚看的时候怎么都不能理解,查了一会大概有自己的一些理解,这里先粗浅记录一下。
我认为最重要的是理解Java泛型设计时的一个安全措施,那就是与泛型相关的异常都应该在编译期发现,因此才会出现<? super T>和<? Extends T>理解上的困难。
两个关键词的概念
<? extends T>:是指 “上界通配符(Upper Bounds Wildcards)”<? super T>:是指 “下界通配符(Lower Bounds Wildcards)”
为什么有边界这个概念?
之所以出现边界,是因为Java的泛型是在Java SE5的时候才出现的,为了保证以前没有使用泛型的代码依然是安全的,因此Java采取了一种被《Java编程思想》称为擦除(也是诡异的翻译,感觉跟句柄一样)的实现方式,即在运行时,实例化参数存在的信息会被擦除,因此List<Integer>和List<String>实际上会成为一种类型,即List类型。
擦除(erasure),这是 Java 语言中泛型实现的底层技术。擦除意味着编译器在生成类文件时基本上会抛开参数化类的大量类型信息。编译器用它的强制类型转换生成代码,就像程序员在泛型出现之前手工所做的一样。区别在于,编译器开始已经验证了大量如果没有泛型就不会验证的类型安全约束。
因为缺少类型信息,一些泛型的代码将无法通过编译(《Java编程思想》P374),如:
class HasF(){
public void f(){};
}
class Manipulator<T>{
private T obj;
public Manipulator(T o){obj = o;}
//实际上下面的语句将不能通过编辑
public void manipulate(){ obj.f(); }
}
public class Test{
public static void main(String args[]){
HasF hf = new HasF();
Manipulator<HasF> ma = new Manipulator<HasF>(hf);
ma.manipulate();
}
}
因为类型信息丢失了,因此Manipulator不能从实例化参数中得到obj拥有f()这个事实(Cpp则不会,因为Cpp的模板机制是在编译时进行类型检查的,如果将一个不存在f()方法的类型作为实例化参数就会报错,但如果传递一个有f()方法的就没问题。但Java则因为一定会丢失类型信息,因此直接认定Manipulator这个类是不合法的),因此这个代码被认为是不安全的,无法通过编译。而<? super T>和<? Extends T>是对这种擦除机制的一种打补丁。上述的class Manipulator只需要实现成下面这样就可以愉快地调用f()了。
class Manipulator<T extends HasF>{
private T obj;
public Manipulator(T o){obj = o;}
//实际上下面的语句将不能通过编辑
public void manipulate(){ obj.f(); }
}
因为extends关键字在这里告诉编译器,所有在运行时可能实例化Class Manipulator的类型参数,都只能是HasF的子类,这就保证了一定会有f()方法,这就满足了与泛型相关的异常都应该在编译期发现这个安全措施。
Java泛型的别扭之处
用《Java编程思想》中的例子,假如有以下继承体系:
class Fruit{};
class Apple extends Fruit{};
接着我们定义了一个容器:
class Plate<T>{
private T item;
public Plate(T t){item=t;}
public void set(T t){item=t;}
public T get(){return item;}
}
接着我定义一个水果盘子,从我们人类的逻辑看来,水果盘子当然可以装苹果:
Plate<Fruit> p=new Plate<Apple>(new Apple());
但是Java编译不能通过,在《Java编程思想》P391里提到:
真正的问题是我们在讨论容器的类型,而不是容器持有的类型。
因此,即使容器里装的东西之间有继承关系,但容器之间没有继承关系,因此:一个装有Fruit的容器,不是一个装有苹果的容器,所以无法这么赋值。为了解决这个奇怪的问题,Java再一次请出了:<? super T>和<? Extends T>。
上界通配符<? Extends T>
Plate<? extends Fruit>
翻译成人话就是:一个能放水果以及一切是水果派生类的盘子,这和我们人类的逻辑就符合了,而且Plate<? extends Fruit>就好像(我也不清楚严格意义上能不能称之为就是,所以用了好像)是Plate<Fruit>以及Plate<Apple>的基类,现在这样的赋值将被允许:
Plate<? extends Fruit> p = new Plate<Apple>(new Apple());
一个什么都装不进去的盘子
这样的形式也同时告诉编译器,这个p实际上指向的可能是任何用Fruit子类实例化的容器,即在运行时可能指向
Plate<Fruit>,Plate<Apple>,Plate<RedApple>,Plate<Orange>等等容器。这就导致了一个问题:因为无法预料到Fruit的子类到底有多少种,因此我们无法断定到底在运行时p可能指向哪一种容器(也可能指向一个Plate<RedApple>)那么即使这个赋值是合法的:
Plate<? extends Fruit> p=new Plate<RedApple>(new Apple());
也无法保证这个盘子可以装Apple——即使Apple是Fruit的子类,既然无法保证,就意味着向这个盘子里装Apple等任何Fruit的子类在编译器看来都是不安全的,因此实际效果就是:你无法向这个盘子里调用set()方法装任何物品,只能在初始化时一次性装好,完了可以取,取出来的物品都保证是Fruit。
下界通配符<? super T>
Plate<? super Apple>
下界通配符的概念恰好相反,一个能放苹果以及一切是苹果基类的盘子,从这个反的角度看来,Plate<Apple>看起来就像是任何苹果基类的盘子,包括Plate<Fruit>的基类一样,现在这样的赋值就允许了:
Plate<? super Apple> apple = new Plate<Fruit>(new Fruit());
与上界通配符有点类似,采用了下界通配符的容器也有一点限制,那就是:你可以任意往里面放Apple的子类对象,但是从apple中取出的所有对象,都只能当做Object来对待。
因为我们现在只知道apple指向的是一个使用Apple基类作为类型参数实例化的容器,可能指向了Fruit的容器,也可能指向了Food的容器,还有可能指向了Object的容器,因此,只要是Apple的子类,是可以安全地放入apple指向的那个容器的,因为apple可能指向的都是能容纳Apple父类的容器,因此绝对能够放得下Apple及其子类,因此,你可以往apple里任意存放Apple及其子类的对象。
但是取出的时候情况就不同了,因为他最大可能指向Object的容器,因此为了在编译期保证安全,所有从apple中取出的对象,都只能当做Object来看待,丢失了所有类型信息(可以做强制转化)。
PECS原则
最后看一下什么是PECS(Producer Extends Consumer Super)原则,已经很好理解了:
- 频繁往外读取内容的,适合用上界Extends。
- 经常往里插入的,适合用下界Super。
总之,都是因为擦除这个折衷的设计惹得麻烦。
0 条评论