引言

上一篇文章,我们通过观察反编译后的文件,讲解了java中泛型的实现方式—擦除,并由此引出了擦除带来的问题以及解决方案。这篇文章,我们继续分析java中泛型带来的另一个重要的问题。

协变和逆变

定义

首先说一下Java中协变、逆变和不变的概念:
定义A、B两个类型,A是由B派生出来的子类,f()表示类型转换如new List();
协变:当A<=B时,f(A)<=f(B)成立。
逆变:当A<=B时,f(B)<=f(A)成立。
不变:当A<=B时,协变和逆变都不成立。

数组是协变的

看下面的例子:

  1. public class ArrayTest {
  2. private static void test(Fruit[] fruits){
  3. }
  4. public static void main(String[] args) {
  5. test(new Apple[2]);
  6. }
  7. }

test方法接收Fruit数组作为参数,我传入一个Apple数组也没有问题。
数组的协变使得编译期只要给定了合法的类型就能够通过编译,但是运行时可能出现问题,看下面的例子:

  1. public class Fruit {
  2. }
  3. public class Apple extends Fruit {
  4. }
  5. public class Orange extends Fruit {
  6. }
  7. public class Jonathan extends Apple {
  8. }
  9. public class CovariantArrays {
  10. public static void main(String[] args) {
  11. Fruit[] fruit = new Apple[10];
  12. fruit[0] = new Apple();
  13. fruit[1] = new Jonathan();
  14. fruit[0] = new Fruit();
  15. fruit[0] = new Orange();
  16. }
  17. }

我们将fruit类型的数组变量指向了一个apple类型的数组对象,这样是没问题的。我们也能将数组的元素设置为Apple或者其子类,但是第14行和第15行在运行时会报错:

  1. Exception in thread "main" java.lang.ArrayStoreException: person.andy.concurrency.generic.fruits.Fruit
  2. at person.andy.concurrency.generic.fruits.CovariantArrays.main(CovariantArrays.java:11)
  3. Exception in thread "main" java.lang.ArrayStoreException: person.andy.concurrency.generic.fruits.Orange
  4. at person.andy.concurrency.generic.fruits.CovariantArrays.main(CovariantArrays.java:12)

意思是我们不能向Apple数组中添加不是Apple类型的元素。
这段代码在编译期没有问题,在运行期会报错,编译器认为向Fruit数组中添加任何的Fruit及其子类的对象是没有问题的,但是实际的数组类型是Apple,所以这种问题可能直到运行期才被发现。

泛型是不变的

看下面的例子:

  1. public static void main(String[] args) {
  2. List<Fruit> iHelloList = new ArrayList<Apple>();
  3. }

这样的意思就像是,一个Apple的List不能赋值给一个Fruit的List,在某些情况下,我们是需要这样做的。

通配符

为了达到协变和逆变的目的,我们可以使用通配符。

协变

协变泛型的用法就是<? extends Fruit>。我们可以这样来声明:

  1. public static void main(String[] args) {
  2. List<? extends Fruit> fruits = new ArrayList<Apple>();
  3. List<? extends Fruit> fruits1 = new ArrayList<Jonathan>();
  4. List<? extends Fruit> fruits2 = new ArrayList<Orange>();
  5. }

这些都能够编译运行成功。
但是协变有自己的问题:

  1. public static void main(String[] args) {
  2. //这样赋值没有问题
  3. List<? extends Fruit> fruits = new ArrayList<Apple>();
  4. //下面的三行代码不能编译通过
  5. fruits.add(new Fruit());
  6. fruits.add(new Apple());
  7. fruits.add(new Object());
  8. //调用get方法返回的类型是Fruit
  9. Fruit fruit = fruits.get(0);
  10. }

这个示例中的第一行,我们通过通配符达到了协变的目的,告诉编译器我们的泛型可以是Fruit或者任何Fruit的子类。但是后面我们对list调用add方法,无论传入的参数是Fruit或者Apple,甚至是一个Object,都不能编译通过。
为什么编译器要这样做?我们先看一下add方法的声明:

  1. public boolean add(E e) {
  2. ensureCapacityInternal(size + 1); // Increments modCount!!
  3. elementData[size++] = e;
  4. return true;
  5. }

这个方法参数就是List的泛型参数E,根据之前对擦除的理解,泛型擦除之后,这个方法的参数应该是一个Object,既然是Object,理论上我们就能传递任何引用类型的对象,但是编译器为了泛型的类型安全肯定要做限制,先看下面这个简单的例子:

  1. public static void main(String[] args) {
  2. List<Apple> apples = new ArrayList<>();
  3. apples.add("a String");
  4. }

这段代码肯定不能编译通过。我们不能向Apple的list添加字符串类型的对象,这就是编译器做的限制,它知道我们的泛型类型是Apple,所以能作出这样的限制,保证类型安全 。
回到上面协变的例子,为什么使用了协变之后,add方法什么都不能添加了呢。它可以限制只让我们传入Fruit的,根本问题是

  1. List<? extends Fruit> fruits = new ArrayList<Apple>();

这句代码,使得fruits这个变量指向的是List对象,我们也可以让它指向List对象,协变使得编译器不知道fruits变量到底指向的是什么类型的List。所以只是限制了参数是Fruit及其子类,一旦出现fruit指向List但是我的add方法传入的参数是Orange,就会出现类型不安全的错误。
所以编译器就直接限制了add方法传入任何类型的参数都不能通过,这样保证了泛型的初衷,在编译期间就保证类型安全。
所以协变虽然达到了将Apple的List赋值给Fruit的List的目的,但是却限制了往list中添加元素的功能。
但是,调用get方法是没有问题的,并且返回类型是Fruit,因为编译器知道容器中的肯定是Fruit或者其子类,所以就默认加上了到Fruit的强制类型转换,这样是没有类型安全问题的。
看下面的示例和反编译后的代码:

  1. public static void main(String[] args) {
  2. List<? extends Fruit> fruits = new ArrayList<Apple>();
  3. Fruit fruit = fruits.get(0);
  4. }
  1. public static void main(String args[])
  2. {
  3. List fruits = new ArrayList();
  4. Fruit fruit = (Fruit)fruits.get(0);
  5. }

可以看到编译器加上的强制类型转换。
但是当我们调用list的contains()或者indexOf()方法的时候,就不会有上面add方法同样的问题:

  1. public static void main(String[] args) {
  2. List<? extends Fruit> apples = new ArrayList<Apple>();
  3. apples.contains(new Apple());
  4. apples.contains(new Object());
  5. apples.indexOf(new Apple());
  6. }

上面的代码完全是可以编译运行的,contains和indexOf方法的参数也是Object,为什么不会出现这个问题?这个得看他们的声明:

  1. public int indexOf(Object o) {}
  2. public boolean contains(Object o) {}

与上面的add方法对比,这两个的参数直接就是Object,而add方法的参数是通配符E,虽然add方法被擦除之后,参数同样会变成Object,但是通配符这样的参数声明会告诉编译器来进行参数类型限制,这也就是为什么上面那个简单的示例中apple的list不能添加String的原因,当使用协变时,编译器做参数类型限制发现变量的准确类型是不确定的,所以就直接都限制了,相反,contains和indexOf方法就是普通方法,编译器不会对参数做限制。

逆变

逆变的声明方式就是<? super Apple>,表明我们的泛型类型需要是Apple的父类。
我们可以这样声明:

  1. public static void main(String[] args) {
  2. List<? super Apple> fruits = new ArrayList<Apple>();
  3. List<? super Apple> fruits1 = new ArrayList<Fruit>();
  4. List<? super Apple> fruits2 = new ArrayList<Object>();
  5. }

这三种声明方式都能够编译运行成功。我们可以让List<? super Apple>这种类型的变量指向List、List甚至是List,只要是Apple的父类就行。
有了逆变之后,我们就能够调用add方法了:

  1. public static void main(String[] args) {
  2. List<? super Apple> fruits = new ArrayList<Apple>();
  3. //可以添加Apple本身
  4. fruits.add(new Apple());
  5. //可以添加Apple的子类
  6. fruits.add(new Jonathan());
  7. //不能添加Apple的任意父类
  8. fruits.add(new Fruit());
  9. //get方法返回的是Object
  10. Object object = fruits.get(0);
  11. }

当fruits这个变量指向一个泛型类型为Apple的父类的List的时候,可以向里面add什么类型呢?首先,既然类型是Apple的父类,那么肯定Apple本身是可以的,因为不管是List、List甚至是List,我添加Apple都不会有类型问题,Apple的子类肯定也是Apple的父类的子类,也可以添加成功,那么Apple的父类Fruit为什么不能编译通过呢?还是同样的原因,fruits这个变量指向的对象不能确定是List还是List还是List,如果我向一个List添加了一个Fruit,肯定就有类型问题了,所以编译器就禁止了这种操作。
当对List调用get方法,返回的类型是什么呢?从例子中就能看出是Object,因为编译器只知道fruit指向的是一个List<? super Apple>,但是不知道是一个List、List、还是一个List,所以它必须保证返回的类型是能兼容所有这些情况的,所以就返回了所有类的父类Object。
逆变同样达到了将Apple的list赋值给Fruit的list的功能,同时还支持向list中添加元素的操作,尽管添加的类型也是受限制的。

PECS原则

关于通配符,还有一个PECS原则。思考前面关于协变和逆变的描述,协变使得我们不能调用add方法来添加元素,但是可以通过get方法来获取元素,并且获取的参数类型是Fruit,逆变使得我们可以调用add方法来添加元素,但是调用get方法时只能得到最上层的父类Object,编译器对协变和逆变做的限制正好让他们分别适合做生产者和消费者,生产者可以通过get方法得到正确的类型,消费者可以通过add方法存入正确的类型。
PECS原则就是“Producer Extends,Consumer Super”,换句话说,就是如果参数化类型表示一个生产者,就使用<? extends T>;如果它表示一个消费者,就使用<? super T>。
Collections.copy()方法就是一个很好的利用PECS的例子:

  1. public static <T> void copy(List<? super T> dest, List<? extends T> src) {
  2. int srcSize = src.size();
  3. if (srcSize > dest.size())
  4. throw new IndexOutOfBoundsException("Source does not fit in dest");
  5. if (srcSize < COPY_THRESHOLD ||
  6. (src instanceof RandomAccess && dest instanceof RandomAccess)) {
  7. for (int i=0; i<srcSize; i++)
  8. dest.set(i, src.get(i));
  9. } else {
  10. ListIterator<? super T> di=dest.listIterator();
  11. ListIterator<? extends T> si=src.listIterator();
  12. for (int i=0; i<srcSize; i++) {
  13. di.next();
  14. di.set(si.next());
  15. }
  16. }
  17. }

src是需要被copy的list,是一个生产者,dest需要添加新的元素,是一个消费者,所以分别用了extends和super关键字来对参数做限制。

小结

理解了泛型的协变和逆变,你应该知道了怎样用List变量指向List对象了,也能知道为什么List或者说很多容器类的API一些方法中的参数为什么是泛化类型E而另外一些方法是Object类型。
编译器为了保证泛型的编译期类型安全,对协变和逆变做了很多限制,以List为例,协变不能调用add方法,逆变get方法只能得到Object类型,这样就产生了PECS原则,很多设计良好的泛型类都遵循协变、逆变和PECS原则,理解了背后的机制,你就能更好地理解这些良好设计代码蕴含的思想。