Java泛型的协变与逆变

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

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

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

Animal animalOne = new Cat(); Animal animalTwo = new Dog();
1
理论上来说,一只猫是一只动物,一只狗也是一只动物,所以这完全是可以理解的。其实,这也是SOLID原则中的“里氏替换原则”的一种体现。

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

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

Animal[] animals = new Cat[2];
animals[0] = new Cat();
// 下面这行代码会抛运行时异常 animals[1] = new Dog();
Animal animal = animal[0];
1
很好,编译没有任何问题。但是一运行,会抛出一个运行时异常: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
如果不小心存入集合中对象类型是错的,会在运行时报强转异常。而1.5提供泛型以后,可以让编译器自动帮助转换,并对代码进行检查,使程序更加安全。

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

List list = new LinkedList<>();
list.add(123);
// 下面这局代码编译节点会报错 list.add(“123”);
int a = list.get(0);
1
《Effective Java》中,第28条(第三版)说,列表优先于数组。Java在使用列表+泛型时,吸取了上面数组的教训。前面提到,Java中数组是协变的,所以会有些问题。而Java中的泛型是不变(invariance)的,也就是说,List并不是List的子类型。所以像下面这样写,编译器会直接报错。

List cats = new LinkedList<>();
// 编译器报错 List animals = cats;
1
这样就可以在编译期对代码进行检查,防止它在运行期才发现错误抛异常。

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

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

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

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

List<? extends Animal> animals = new LinkedList();
// 以下四行代码都不能编译通过
// 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);
1
也就是说,虽然因为泛型的不变性,List并不是List的子类型,但Java通过其它方式来支持了泛型的协变,List是List<? extends Animal>的子类型。与此同时,Java在编译器层面通过禁止写入的方式,保证了协变下的安全性。

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

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

// 下面这行代码编译不通过
// List<? super Animal> animals = new LinkedList();
// 下面都是OK的写法
// List<? super Animal> animals = new LinkedList();
// List<? super Animal> animals = new LinkedList();
// 等价于上面一行的写法 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();
1
逆变(contravariance),也称逆协变,从名字可以看出来,它与协变的性质是相反的。也就是说,List是List<? super Cat>的子类型。

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

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

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

// X可以是Animal及其子类,Animal是X的上界 List<? extends Animal> animals = new LinkedList(); // X可以是Cat及其父类,Cat是X的下界 List<? super Cat> cats = new LinkedList(); 1 任意类型通配符 在Java代码中,你可能还看到这种写法:<?>,它代表任意类型通配符。老规矩,直接上代码:

List<?> anyOne = new LinkedList(); List<?> anyTwo = new LinkedList();
List<?> anyThree = new LinkedList(); // 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);
1
也就是说,它是“无界”的,对于任意类型X,List都是List<?>的子类型。但List<?>不能add,get出来也是Object类型。它同时具有协变和逆变的两种性质,上界是Object,但不能调用add方法。

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

List可以调用add方法,但List<?>不能。 List<?>可以协变,上界是Object,但List不能协变。
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 each = iterator();
while (each.hasNext())
{
if (filter.test(each.next()))
{
each.remove();
removed = true;
}
}
return removed; }
1
add和addAll
首先我们来看add和addAll方法。下面这段代码:

Collection animals = new LinkedList<>();
animals.add(new Cat());
animals.add(new Animal());
Collection cats = new LinkedList<>();
Collection objects = new LinkedList<>();
animals.addAll(cats);
// 以下代码编译不通过,因为不安全 animals.addAll(objects);
1
为什么这段代码可以编译通过且运行时安全?对于animals,它的泛型是,根据里氏替换原则,add方法可以添加Animal及其子类对象。

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

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

而我们看源码可以发现,这里的参数传进来之后,是只读的,也就是只有消费场景,所以可以使用协变。而如果是allAll(Collection 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 animals = new LinkedList<>();
Collection cats = new LinkedList<>();
Collection objects = new LinkedList<>();
animals.containsAll(cats);
animals.containsAll(objects);
1
为什么containsAll的方法参数是Collection<?> c呢?

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

然后,为什么不能像allAll方法那样,用协变Collection<? extends E> c呢?因为我们知道,containsAll方法对Collection没有副作用,而addAll有。所以我们不能animals.addAll(objects),但可以animals.containsAll(objects)。在此我向大家推荐一个架构学习交流圈。交流学习指导伪鑫:1253431195(里面有大量的面试题及答案)里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

最后,为什么又不能用逆变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 animals = new LinkedList<>();
Predicate catPredicate = cat -> true;
// 因为removeIf逆变的限制,所以下面这行代码编译不通过 animals.removeIf(catPredicate);
1
对我们日常工作有什么用?
看到这里,可能有的朋友已经开始吐槽了,我有必要了解这些吗?面试造火箭,工作拧螺丝?

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

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

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值