• 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 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的子类型。与此同时,Java在编译器层面通过禁止写入的方式,保证了协变下的安全性。

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

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

    // 下面这行代码编译不通过
    // List animals = new LinkedList();
    // 下面都是OK的写法
    // List animals = new LinkedList();
    // List animals = new LinkedList();
    // 等价于上面一行的写法 List animals = new LinkedList<>();
    animals.add(new Cat());
    animals.add(new Dog());
    // 取出来一定是Object Object object = animals.get(0);
    // 这样写是OK的 List cats = new LinkedList();
    1
    逆变(contravariance),也称逆协变,从名字可以看出来,它与协变的性质是相反的。也就是说,List是List的子类型。

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

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

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

    // X可以是Animal及其子类,Animal是X的上界 List animals = new LinkedList(); // X可以是Cat及其父类,Cat是X的下界 List 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 c); boolean contains(Object o); boolean containsAll(Collection c);
    default boolean removeIf(Predicate 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方法来说,因为方法参数声明的是,而这里的E是我们声明Collection用的泛型Animal,所以其实addAll的方法参数类型是Collection

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

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

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

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

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

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

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

    removeIf
    这个方法的参数是一个Predicate。用过Java 8的都知道,这是一个函数式接口。在这里使用了逆变,Predicate 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是如何设计泛型和协变/逆变的,敬请期待~

  • 相关阅读:
    基于webrtc浏览器截图
    【jvm】jvm系统线程
    (附源码)ssm考务管理系统 毕业设计 221504
    会议管理系统SSM记录(二)
    23.09.05内网盲区记录
    从malloc到跑路
    大语言模型基础-Transformer模型详解和训练
    Windows修改中文用户名为英文
    配置Webpack Dev Server 实战操作方法步骤
    3d模型怎么一缩放模型都散了?---模大狮模型网
  • 原文地址:https://blog.csdn.net/m0_54828003/article/details/126198988