Java泛型的协变与逆变

从面向对象说起

Java作为一门面相对象的语言,当然是支持面相对象的三大基本特性的,反手就蹦出三个词:封装、继承、多态。

我们假设有三个类,动物、猫、狗。父类是动物Animal,有两个子类猫Cat和狗Dog。

那在Java中或其它任何支持面相对象的语言中,子类可以把引用赋值给父类。下面这段代码没有任何问题:

Animal animalOne = new Cat(); Animal animalTwo = new Dog(); 复制代码

理论上来说,一只猫是一只动物,一只狗也是一只动物,所以这完全是可以理解的。其实,这也是SOLID原则中的“里氏替换原则”的一种体现。

数组的协变

如果一只猫是一只动物,那一群猫是一群动物吗?一群狗是一群动物吗?Java数组认为是的。于是你可以这样写:

Animal[] animals = new Cat[2]; 复制代码

这看起来也没有什么问题。但既然都是一群动物了,我往这一群动物中添加一只猫、一只狗,它还是一群动物,这应该是合理的对吧?来看看这段代码:

Animal[] animals = new Cat[2]; animals[0] = new Cat(); // 下面这行代码会抛运行时异常 animals[1] = new Dog(); Animal animal = animal[0]; 复制代码

很好,编译没有任何问题。但是一运行,会抛出一个运行时异常:ArrayStoreException。这个异常头顶的注释已经写得很明显了,如果你往数组中添加一个类型不对的对象,就会抛这个异常。它是从JDK 1.0就存在的一个异常。

这么一想,对啊,animals虽然门面上是一个Animal数组,但是它运行时的本质还是一个Cat数组啊,一个Cat数组怎么能添加一个Dog呢?但Java编译器并没有这么智能,而且上述代码在编

译器看来也是合理合法的,所以也就让它编译过了。

所以这种情况,编译器100%过,而运行时100%抛异常,这不是大写的BUG是啥?

如果Cat是Animal的子类型,那么Cat[]也是Animal[]的子类型,我们称这种性质为协变(covariance)。Java中,数组是协变的

泛型的不变性

在Java 1.5之前,是没有泛型的。那个时候从集合中存取对象都是Object类型,所以每次取出对象后必须进行强转:

List list = new LinkedList(); list.add(123); list.add("123");  int a = (int)list.get(0); // 下面这段代码会在运行时抛异常 int b = (int)list.get(1); 复制代码

如果不小心存入集合中对象类型是错的,会在运行时报强转异常。而1.5提供泛型以后,可以让编译器自动帮助转换,并对代码进行检查,使程序更加安全。

在Java8又加入了泛型的类型推导功能,使用泛型以后,我们的代码看起来变得简洁又安全了:

List<Integer> list = new LinkedList<>(); list.add(123); // 下面这局代码编译节点会报错 list.add("123");  int a = list.get(0); 复制代码

《Effective Java》中,第28条(第三版)说,列表优先于数组。Java在使用列表+泛型时,吸取了上面数组的教训。前面提到,Java中数组是协变的,所以会有些问题。而Java中的泛型是不变(invariance)的,也就是说,List<Cat>并不是List<Animal>的子类型。所以像下面这样写,编译器会直接报错。

List<Cat> cats = new LinkedList<>(); // 编译器报错 List<Animal> animals = cats; 复制代码

这样就可以在编译期对代码进行检查,防止它在运行期才发现错误抛异常。

不变不能解决所有问题

泛型是不变的,所以我们使用泛型的时候,能够更加安全。

但是在使用一门面向对象的语言中,我们难免会有需要集合也支持一些面向对象的特性的场景。我们可以简单地把它们分成生产场景和消费场景

消费场景的协变

比如,我希望有一个Animal的集合,我不用去管它里面存的具体类型是什么,但我每次从这个集合取出来的,一定是一个Animal或其子类。这是一种典型的消费场景,从集合中取出元素来消费。

在消费场景,Java提供了通配符和extends关键字来支持泛型的协变。来看看这段代码:

List<? extends Animal> animals = new LinkedList<Cat>(); // 以下四行代码都不能编译通过 // animals.add(new Dog()); // animals.add(new Cat()); // animals.add(new Animal()); // animals.add(new Object()); // 可以添加null,但没意义 animals.add(null); // 可以安全地取出来 Animal animal = animals.get(0); 复制代码

也就是说,虽然因为泛型的不变性,List<Cat>并不是List<Animal>的子类型,但Java通过其它方式来支持了泛型的协变,List<Cat>是List<? extends Animal>的子类型。与此同时,Java在编译器层面通过禁止写入的方式,保证了协变下的安全性

为什么协变下不能写入呢?因为协变下写入是不安全的,想想文章最开头那个数组的协变的例子。

生产场景的逆变

我们希望有一个集合,可以往里面写入Animal及其子类。那可以通过super关键字来定义泛型集合:

// 下面这行代码编译不通过 // List<? super Animal> animals = new LinkedList<Cat>(); // 下面都是OK的写法 // List<? super Animal> animals = new LinkedList<Object>(); // List<? super Animal> animals = new LinkedList<Animal>(); // 等价于上面一行的写法 List<? super Animal> animals = new LinkedList<>(); animals.add(new Cat()); animals.add(new Dog()); // 取出来一定是Object Object object = animals.get(0);  // 这样写是OK的 List<? super Cat> cats = new LinkedList<Animal>(); 复制代码

逆变(contravariance),也称逆协变,从名字可以看出来,它与协变的性质是相反的。也就是说,List<Animal>是List<? super Cat>的子类型。

上界和下界

我们会在很多资料里看到对Java中泛型extends和super关键字的解读,说extends决定了上界,super决定了下界。

为什么这么说呢?其实看完上面两个小节,你会明白,这里的上界和下界,其实本质上指的是,在定义泛型的时候,子类型的边界。换句话说,在运行时真正的类型

我们用X来指代类型,看看下面两行代码:

// X可以是Animal及其子类,Animal是X的上界 List<? extends Animal> animals = new LinkedList<X>(); // X可以是Cat及其父类,Cat是X的下界 List<? super Cat> cats = new LinkedList<X>(); 复制代码

任意类型通配符

在Java代码中,你可能还看到这种写法:<?>,它代表任意类型通配符。老规矩,直接上代码:

List<?> anyOne = new LinkedList<Animal>(); List<?> anyTwo = new LinkedList<Cat>(); List<?> anyThree = new LinkedList<Object>(); // anyFour等价于anyThree的写法 List<?> anyFour = new LinkedList<>(); // 这种写法编译不通过 // List<?> anyFive = new LinkedList<?>();  // 具有extends和super的性质 // 这种写法编译不通过 // anyOne.add(new Cat()); // anyOne.add(new Object()); // 能取出来Object类型 Object o = anyOne.get(0); 复制代码

也就是说,它是“无界”的,对于任意类型X,List<X>都是List<?>的子类型。但List<?>不能add,get出来也是Object类型。它同时具有协变和逆变的两种性质,上界是Object,但不能调用add方法。

那它与List<Object>有什么区别呢?根据前面的推断,有两个比较明显的区别:

  • List<Object>可以调用add方法,但List<?>不能。
  • List<?>可以协变,上界是Object,但List<Object>不能协变。

Collection源码解读

看到这里你可能还有一些疑惑,什么时候应该用泛型的协变、逆变呢?我们来看看Collection接口的几个方法签名(JDK 1.8版本)。

boolean add(E e); boolean addAll(Collection<? extends E> c); boolean contains(Object o); boolean containsAll(Collection<?> c);  default boolean removeIf(Predicate<? super E> filter) {     Objects.requireNonNull(filter);     boolean removed = false;     final Iterator<E> each = iterator();     while (each.hasNext()) {         if (filter.test(each.next())) {             each.remove();             removed = true;         }     }     return removed; } 复制代码

add和addAll

首先我们来看add和addAll方法。下面这段代码:

Collection<Animal> animals = new LinkedList<>(); animals.add(new Cat()); animals.add(new Animal());  Collection<Cat> cats = new LinkedList<>(); Collection<Object> objects = new LinkedList<>(); animals.addAll(cats); // 以下代码编译不通过,因为不安全 animals.addAll(objects); 复制代码

为什么这段代码可以编译通过且运行时安全?对于animals,它的泛型是<Animal>,根据里氏替换原则,add方法可以添加Animal及其子类对象。

而对于addAll方法来说,因为方法参数声明的是<? extends E>,而这里的E是我们声明Collection用的泛型Animal,所以其实addAll的方法参数类型是Collection<? extends Animal>。

结合前文我们知道,这里应用了协变的特性,Collection<Cat>在参数传递的时候被转换成了Collection<? extends Animal>。

而我们看源码可以发现,这里的参数传进来之后,是只读的,也就是只有消费场景,所以可以使用协变。而如果是allAll(Collection<E> c)这种方法参数的话,就不能支持上述代码,往其中添加一个cats了。

contains和containsAll

contains方法没有使用泛型,而是直接使用了一个Object对象,它可以在任何时候调用。那为什么contains方法不像add方法一样,使用泛型,是contains(T t)呢?

因为如果这样定义了的话,contains方法也会像add方法一样,受到协变的限制,声明为Collection<? extends Animal>的对象就不能使用contains方法了。尽管我们确信在contains方法内部并不会修改List中的对象(因此不会有类型安全的问题)。在Java中我们没有办法解决这个问题,因此,只能写成contains(Object o)。

对于containsAll方法,先看看这段代码:

Collection<Animal> animals = new LinkedList<>(); Collection<Cat> cats = new LinkedList<>(); Collection<Object> objects = new LinkedList<>();  animals.containsAll(cats); animals.containsAll(objects); 复制代码

为什么containsAll的方法参数是Collection<?> c呢?

首先,不能用Collection<Object> c,因为这样的话,就不能协变了,上述代码animals.containsAll(cats)就会编译不通过,尽管我们知道这段代码是安全的。

然后,为什么不能像allAll方法那样,用协变Collection<? extends E> c呢?因为我们知道,containsAll方法对Collection没有副作用,而addAll有。所以我们不能animals.addAll(objects),但可以animals.containsAll(objects)。

最后,为什么又不能用逆变Collection<? super E> c呢?因为这样的话,就不能让animals.containsAll(cats)编译通过了。

所以只能选择Collection<?> c。它是无界的,且具有协变性质,且取出来是Object对象,刚好内部实现也是循环去调用contains方法,与contains方法的参数类型Object一致。

同理,remove和removeAll和这两个方法是类似的写法,这里就不过多描述了。

removeIf

这个方法的参数是一个Predicate。用过Java 8的都知道,这是一个函数式接口。在这里使用了逆变,Predicate<? super E> filter定义了filter的下界。对于Predicate来说,这里是一个生产场景,所以应该使用逆变。

这里为什么要用逆变其实也很简单,因为在调用removeIf的时候,我们只能保证animals里面的元素是Animal,但我们并不知道具体的子类型。所以下面这种代码是不安全的,

Collection<Animal> animals = new LinkedList<>(); Predicate<Cat> catPredicate = cat -> true; // 因为removeIf逆变的限制,所以下面这行代码编译不通过 animals.removeIf(catPredicate); 复制代码

对我们日常工作有什么用?

看到这里,可能有的朋友已经开始吐槽了,我有必要了解这些吗?面试造火箭,工作拧螺丝?

其实不然,泛型是Java乃至很多面向对象语言的一种最基本的语言特性,所以知道它为什么这么设计是非常重要的。平时我们看源码的时候,看到这样的代码才会心中有数。

另一方面,随着编程水平的提高,难免有一些比较复杂的代码设计,或多或少会使用到泛型。合理地使用泛型、结合泛型的协变和逆变的特性能够让我们的代码变得更安全,比如上面Collection中用到的Predicate,就用了逆变的性质。

简单总结一下,Java的数组是协变的,泛型是不变的。但泛型可以通过extends关键字实现协变,通过super关键字实现逆变,分别应用于不同的场景。协变应用于消费场景,定义了上界。逆变应用于生产场景,定义了下界。

当然了,不同语言有不同的解决方案。后面会有一篇文章为大家分享Kotlin是如何设计泛型和协变/逆变的,敬请期待~

您可能还会对下面的文章感兴趣: