您当前的位置:首页 > 电脑百科 > 程序开发 > 语言 > JAVA

Java泛型的协变与逆变

时间:2020-12-29 12:54:01  来源:  作者:


从面向对象说起

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是如何设计泛型和协变/逆变的,敬请期待~



Tags:Java泛型   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,如有任何标注错误或版权侵犯请与我们联系(Email:2595517585@qq.com),我们将及时更正、删除,谢谢。
▌相关推荐
从面向对象说起Java作为一门面相对象的语言,当然是支持面相对象的三大基本特性的,反手就蹦出三个词:封装、继承、多态。我们假设有三个类,动物、猫、狗。父类是动物Animal,有两...【详细内容】
2020-12-29  Tags: Java泛型  点击:(141)  评论:(0)  加入收藏
在学习泛型之前我们先回顾下Java的数据类型以及涉及到的一些概念。Java数据类型Java的两大数据类型分为基础类型和引用类型。基本类型的数值不是对象,不能调用对象的toString...【详细内容】
2020-08-20  Tags: Java泛型  点击:(57)  评论:(0)  加入收藏
引言 泛型是Java中一个非常重要的知识点,在Java集合类框架中泛型被广泛应用。本文我们将从零开始来看一下Java泛型的设计,将会涉及到通配符处理,以及让人苦恼的类型擦除。作...【详细内容】
2019-08-07  Tags: Java泛型  点击:(245)  评论:(0)  加入收藏
▌简易百科推荐
一、Redis使用过程中一些小的注意点1、不要把Redis当成数据库来使用二、Arrays.asList常见失误需求:把数组转成list集合去处理。方法:Arrays.asList 或者 Java8的stream流式处...【详细内容】
2021-12-27  CF07    Tags:Java   点击:(3)  评论:(0)  加入收藏
文章目录 如何理解面向对象编程? JDK 和 JRE 有什么区别? 如何理解Java中封装,继承、多态特性? 如何理解Java中的字节码对象? 你是如何理解Java中的泛型的? 说说泛型应用...【详细内容】
2021-12-24  Java架构师之路    Tags:JAVA   点击:(5)  评论:(0)  加入收藏
大家好!我是老码农,一个喜欢技术、爱分享的同学,从今天开始和大家持续分享JVM调优方面的经验。JVM调优是个大话题,涉及的知识点很庞大 Java内存模型 垃圾回收机制 各种工具使用 ...【详细内容】
2021-12-23  小码匠和老码农    Tags:JVM调优   点击:(11)  评论:(0)  加入收藏
前言JDBC访问Postgresql的jsonb类型字段当然可以使用Postgresql jdbc驱动中提供的PGobject,但是这样在需要兼容多种数据库的系统开发中显得不那么通用,需要特殊处理。本文介绍...【详细内容】
2021-12-23  dingle    Tags:JDBC   点击:(12)  评论:(0)  加入收藏
Java与Lua相互调用案例比较少,因此项目使用需要做详细的性能测试,本内容只做粗略测试。目前已完成初版Lua-Java调用框架开发,后期有时间准备把框架进行抽象,并开源出来,感兴趣的...【详细内容】
2021-12-23  JAVA小白    Tags:Java   点击:(10)  评论:(0)  加入收藏
Java从版本5开始,在 java.util.concurrent.locks包内给我们提供了除了synchronized关键字以外的几个新的锁功能的实现,ReentrantLock就是其中的一个。但是这并不意味着我们可...【详细内容】
2021-12-17  小西学JAVA    Tags:JAVA并发   点击:(10)  评论:(0)  加入收藏
一、概述final是Java关键字中最常见之一,表示“最终的,不可更改”之意,在Java中也正是这个意思。有final修饰的内容,就会变得与众不同,它们会变成终极存在,其内容成为固定的存在。...【详细内容】
2021-12-15  唯一浩哥    Tags:Java基础   点击:(14)  评论:(0)  加入收藏
1、问题描述关于java中的日志管理logback,去年写过关于logback介绍的文章,这次项目中又优化了下,记录下,希望能帮到需要的朋友。2、解决方案这次其实是碰到了一个问题,一般的情况...【详细内容】
2021-12-15  软件老王    Tags:logback   点击:(17)  评论:(0)  加入收藏
本篇文章我们以AtomicInteger为例子,主要讲解下CAS(Compare And Swap)功能是如何在AtomicInteger中使用的,以及提供CAS功能的Unsafe对象。我们先从一个例子开始吧。假设现在我们...【详细内容】
2021-12-14  小西学JAVA    Tags:JAVA   点击:(21)  评论:(0)  加入收藏
一、概述观察者模式,又可以称之为发布-订阅模式,观察者,顾名思义,就是一个监听者,类似监听器的存在,一旦被观察/监听的目标发生的情况,就会被监听者发现,这么想来目标发生情况到观察...【详细内容】
2021-12-13  唯一浩哥    Tags:Java   点击:(16)  评论:(0)  加入收藏
最新更新
栏目热门
栏目头条