[java]你应该知道的泛型(Generic)与PECS原则

96
马前小卒
0.4 2017.07.30 12:51* 字数 1922

本文通过一个水果篮子的例子,试图帮助读者理解泛型使用中的PECS原则。本文假设读者对泛型以及泛型通配符有基础性的了解。

一个水果篮子

笔者将以一个装水果的篮子(List集合)为例,示范泛型的使用。水果的继承关系如下:

public class Fruit {...}
public class Apple extends Fruit {...}

泛型

泛型是java1.5出现的语言特征。在没有泛型之前,从集合中读取出的每个对象都必须进行类型转化。这样导致一些类型的错误只有在运行时才能发现:

/**不使用泛型**/
List basket = new ArrayList();//水果篮子
basket.add("水果");
Fruit fruit = (Fruit)basket.get(0);//编译正确,运行错误

有了泛型,就不再需要运行时的类型转化,可以直接告诉编译器集合接受什么类型的对象,编译器在编译时可以做检查:

/**使用泛型,不再需要类型转化**/
Fruit get = basket.get(0);

将篮子里将水果都拿出来

我写一个方法,将水果篮子中所有水果拿出来(即取出集合所有元素并进行操作)

public static void getOutFruits(List<Fruit> basket){
    for (Fruit fruit : basket) {
        System.out.println(fruit);
        //...do something other
    }
}

接着在装水果的蓝子(List<Fruit>)和装苹果的篮子(List<Apple>)上执行这个方法:

List<Fruit> fruitBasket = new ArrayList<Fruit>();
fruitBasket(new Fruit());
getOutFruits(fruitBasket);//成功

List<Apple> appleBasket = new ArrayList<Apple>();
appleBasket(new Apple());
//getOutFruits(appleBasket);//编译错误
//getOutFruits((List<Fruit>) appleBasket);//强制类型转换,同样编译错误
//不兼容的类型: List<Apple>无法转换为List<Fruit>

结果出人意料:装苹果的篮子(List<Apple>)执行时编译出错了。错误显示无法转换。强制转换也没有用。

这个不科学呀! 在面向对象中,子类型对象是可以转成父类型的。

这不科学

原来泛型是不可变。即对于任何2个不同类型的type1和type2,List<Type1>即不是List<Type2>的子类型,也不是List<Type2>的超类型。(《effective java》第25条 )

所以,Fruit和Apple虽是父子关系,但作为2个不同的类型,List<Apple>和List<Fruit>之间没有继承关系,所以2者之间无法转化。

使用<? extends T>进行改进

如果想解决上面的问题,即在装水果的蓝子(List<Fruit>)的地方,兼容装苹果的篮子(List<Apple>),则需要使用<? extends T>这种通配符泛型。

/**参数使用List<? extends Fruit>**/
public static void getOutFruits(List<? extends Fruit> basket){
    for (Fruit fruit : basket) {
        System.out.println(fruit);
        //...do something other
    }
}
public static void main(String[] args) {
    List<Fruit> fruitBasket = new ArrayList<>();
    fruitBasket.add(new Fruit());
    getOutFruits(fruitBasket);

    List<Apple> appleBasket = new ArrayList<>();
    appleBasket.add(new Apple());
    getOutFruits(appleBasket);//编译正确
}

问题解决了。说明List<? extends Fruit>,同时兼容了List<Fruit>和List<Apple>,我们可以理解为List<? extends Fruit>现在是List<Fruit>和List<Apple>的超类型(父类型)了

哎呦,原来使用<? extends T>就万事大吉,哈哈!
怎么可能?少年你还太年轻了!

再看这个例子

List<Apple> apples = new ArrayList<>();
apples.add(new Apple());
List<? extends Fruit> basket = apples;//按上一个例子,这个是可行的
for (Fruit fruit : basket)
{
    System.out.println(fruit);
}

//basket.add(new Apple()); //编译错误
//basket.add(new Fruit()); //编译错误

问题出现了,明明是就放水果的篮子(List<? extends Fruit>,可兼容List<Fruit>和List<Apple>),现在不仅不能放苹果到里面,连水果也不能放入了。不过从篮子取出水果是可以的,这又是怎么回事?

笔者试着用解释一下:用了<? extends Fruit>相当于告诉编译器,我们的篮子(集合)是用来处理水果以及水果的子类型。因为子类型有许多,我们并没有告诉编译器是哪个子类型。

编译器在这里遇到的问题是,如果add的是Apple类型时,则basket应该是List<Apple>,如果add是Fruit类型,则basket应该是List<Fruit>。而List<Apple>和List<Fruit>前面已经提过,是2个完全没有关系的类型,
所以编译器不知道是哪个子类型将加入集合,不知道到底是List<Apple>还是List<Fruit>,所以编译器只能报错。(注意,这里讨论的都是类型,而不是对象)

另一方面,编译器已经知道集合里全部都是水果的子类型,所以编译器可以保证取出的数据全部是水果。

所以,在上面的例子中,我们从篮子中拿水果,实际就是从集合里获取元素。简单的说,当只想从集合中获取元素,请把这个集合看成生产者,请使用<? extends T>,这就是Producer extends原则,PECS原则中的PE部分。

改用<? super T>试试

上一个例子里,我们不能往篮子里加水果。现在换一个角度,我们要实现如何往篮子里加水果,而且是不同的水果。这将用到<? super T>通配符泛型。

首先我们扩展一下水果的继承关系,增加苹果的子类型redApple:

public class Fruit {...}
public class Apple extends Fruit {...}
public class RedApple extends Apple {...}

下面使用<? super T>的例子:

List<Apple> apples = new ArrayList<>();
apples.add(new Apple());
List<? super Apple> basket = apples;//这里使用了super

basket.add(new Apple());
basket.add(new RedApple());
//basket.add(new Fruit()); //编译错误

Object object = basket.get(0);//正确
//Fruit fruit =basket.get(0);//编译错误
//Apple apple = basket.get(0);//编译错误
//RedApple redApple = basket.get(0);//编译错误

显然,苹果和红萍果都能正确地放入篮子(List<? super Apple>)。但奇怪的是,水果对象却不能。另一个奇怪现象是,篮子中只能取出Object类型的对象。

笔者试图解释一下:用了<? super Apple>相当于告诉编译器,集合接受处理Apple以及Apple的超类型,即Object,Fruit,Apple三个类型。
但编译器并不知道到底是List<Object>,List<Fruit>还是List<Apple>?

编译器只知道,苹果和苹果子类型是可以放进去(也是Fruit的子类型,也是Object的子类型)。这意味着,我们总是可以将一个苹果的子类型放入苹果的超类型的list中。

而取出时的情况是,编译器不知道是按哪个类型取出, 到底是Object,Fruit,Apple中的哪个呢?但是编译器可以选择永远不会错的类型,也就是Object的类型,因为Object是所有类型的超类型。

因此,在上面的例子中的,我们将数据放进集合List<? super Apple> basket,所以这个篮子是实际上消费元素,例如Apple。简单的说,当你仅仅想增加元素到集合,把这个集合看成消费者,请使用<? super T>。这就是Consumer super原则,PECS原则中的CS部分。

总结PECS原则

  • 如果你只需要从集合中获得类型T , 使用<? extends T>通配符
  • 如果你只需要将类型T放到集合中, 使用<? super T>通配符
  • 如果你既要获取又要放置元素,则不使用任何通配符。例如List<Apple>
  • PECS即 Producer extends Consumer super, 为了便于记忆。(《effective java》第28条)

为何要PECS原则?

你还记得前面提到泛型是不可变吗?即List<Fruit>和List<Apple>之间没有任何继承关系。API的参数想要同时兼容2者,则只能使用PECS原则。这样做提升了API的灵活性。
在java集合API中,大量使用了PECS原则,例如java.util.Collections中的集合复制的方法:

public static <T> void copy(List<? super T> dest, List<? extends T> src) {
  ...
}

集合复制是最典型的用法:

  • 复制源集合src,主要获得元素,所以用<? extends T>
  • 复制目标集合dest,主要是设置元素,所以用<? super T>

当然,为了提升了灵活性,自然牺牲了部分功能。鱼和熊掌不能兼得。

补充说明

  • 这里的错误全部是编译阶段不是运行阶段,编译阶段程序是没有运行。所以不能用运行程序的思维来思考。
  • 使用泛型,就是要在编译阶段,就找出类型的错误来。

参考资料

《Effective Java》第2版

java开发
Web note ad 1