Java泛型的协变与逆变

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

从面向对象说起


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);

也就是说,它是“无界”的,对于任意类型XList<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是如何设计泛型和协变/逆变的,敬请期待~

目录
相关文章
|
3月前
|
安全 Java 编译器
揭秘JAVA深渊:那些让你头大的最晦涩知识点,从泛型迷思到并发陷阱,你敢挑战吗?
【8月更文挑战第22天】Java中的难点常隐藏在其高级特性中,如泛型与类型擦除、并发编程中的内存可见性及指令重排,以及反射与动态代理等。这些特性虽强大却也晦涩,要求开发者深入理解JVM运作机制及计算机底层细节。例如,泛型在编译时检查类型以增强安全性,但在运行时因类型擦除而丢失类型信息,可能导致类型安全问题。并发编程中,内存可见性和指令重排对同步机制提出更高要求,不当处理会导致数据不一致。反射与动态代理虽提供运行时行为定制能力,但也增加了复杂度和性能开销。掌握这些知识需深厚的技术底蕴和实践经验。
76 2
|
21天前
|
Java API
[Java]泛型
本文详细介绍了Java泛型的相关概念和使用方法,包括类型判断、继承泛型类或实现泛型接口、泛型通配符、泛型方法、泛型上下边界、静态方法中使用泛型等内容。作者通过多个示例和测试代码,深入浅出地解释了泛型的原理和应用场景,帮助读者更好地理解和掌握Java泛型的使用技巧。文章还探讨了一些常见的疑惑和误区,如泛型擦除和基本数据类型数组的使用限制。最后,作者强调了泛型在实际开发中的重要性和应用价值。
17 0
[Java]泛型
|
30天前
|
存储 安全 Java
🌱Java零基础 - 泛型详解
【10月更文挑战第7天】本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
11 1
|
1月前
|
Java 语音技术 容器
java数据结构泛型
java数据结构泛型
27 5
|
1月前
|
存储 Java 编译器
Java集合定义其泛型
Java集合定义其泛型
17 1
|
2月前
|
Java 编译器 容器
Java——包装类和泛型
包装类是Java中一种特殊类,用于将基本数据类型(如 `int`、`double`、`char` 等)封装成对象。这样做可以利用对象的特性和方法。Java 提供了八种基本数据类型的包装类:`Integer` (`int`)、`Double` (`double`)、`Byte` (`byte`)、`Short` (`short`)、`Long` (`long`)、`Float` (`float`)、`Character` (`char`) 和 `Boolean` (`boolean`)。包装类可以通过 `valueOf()` 方法或自动装箱/拆箱机制创建。
35 9
Java——包装类和泛型
|
1月前
|
存储 Java 编译器
【用Java学习数据结构系列】初识泛型
【用Java学习数据结构系列】初识泛型
20 2
|
2月前
|
安全 Java API
【Java面试题汇总】Java基础篇——String+集合+泛型+IO+异常+反射(2023版)
String常量池、String、StringBuffer、Stringbuilder有什么区别、List与Set的区别、ArrayList和LinkedList的区别、HashMap底层原理、ConcurrentHashMap、HashMap和Hashtable的区别、泛型擦除、ABA问题、IO多路复用、BIO、NIO、O、异常处理机制、反射
【Java面试题汇总】Java基础篇——String+集合+泛型+IO+异常+反射(2023版)
|
1月前
|
安全 Java 编译器
Java基础-泛型机制
Java基础-泛型机制
15 0
|
2月前
|
存储 安全 搜索推荐
Java中的泛型
【9月更文挑战第15天】在 Java 中,泛型是一种编译时类型检查机制,通过使用类型参数提升代码的安全性和重用性。其主要作用包括类型安全,避免运行时类型转换错误,以及代码重用,允许编写通用逻辑。泛型通过尖括号 `&lt;&gt;` 定义类型参数,并支持上界和下界限定,以及无界和有界通配符。使用泛型需注意类型擦除、无法创建泛型数组及基本数据类型的限制。泛型显著提高了代码的安全性和灵活性。