逆变、协变
概述
协变,逆变,抗变等概念是从数学中来的,在编程语言Java/Kotlin/C#中主要在泛型中使用。其描述的是两个类型集合之间的继承关系。
定义
假设两个类,Animal 和 Dog, Dog是Animal的子类
class Animal {}
class Dog extends Animal {}
还有两个集合 List<Animal> 和 List<Dog>。
现在问题来了,由于Dog是Animal的子类,那么List<Dog>就是List<Animal>的子类这句话在Java中对吗?
答案是否定的。我们这里要说的协变,逆变,抗变就是描述上面两个类型集合的关系的。
- 协变(Covariance):List 是List的子类型
- 逆变(Contravariance): List 是List的子类型
- 抗变(Invariant): List 与List没有任何继承关系
A subtype must accept at least the same range of types as its supertype declares. A subtype must return at most the same range of types as its supertype declares.
抗变
Java中泛型是抗变的,那就意味着List不是List的子类型。因为如果不这样的话就会产生类型不安全问题。例如下面代码可以通过编译的话,就会在运行时抛出异常
List<String> strs = new ArrayList<String>();
List<Object> objs = strs;
objs.add(1);
// 尝试将Integer 转换为String,发生运行时异常 ClassCastException: Cannot cast Integer to String
String s = strs.get(0);
所以上面的代码在编译时就会报错,这就保证了类型安全。
但值得注意的是Java中的数组是协变的,所以数组真的会遇到上面的问题,编译可以正常通过,但会发生运行时异常,所以在Java中要优先使用泛型集合。
String[] strs= new String[]{"ss007"};
Object[] objs= strs;
objs[0] = 1;
协变抗变性会严重制约程序的灵活性,例如有如下方法copyAll,将一个 String 集合的内容copy到一个Object集合中,这是顺理成章的事。
void copyAll(Collection<Object> to, Collection<String> from) {
to.addAll(from);
}
但是如果Collection中的addAll方法签名如下的话,copyAll方法就通不过编译,因为通过上面的讲解,我们知道由于抗变性,Collection不是Collection的子类,所以编译通不过。
boolean addAll(Collection<E> c);
那怎么办呢?Java通过通配符参数(wildcard type argument)来解决, 把addAll的签名改成如下即可:
boolean addAll(Collection<? extends E> c);
? extends E 表示此方法可以接收E或者E的子类的集合。此通配符使得泛型类型协变了。
逆变
同理有时我们需要将Collection传递给Collection就使用? super E,其 表示可以接收E或者E的父类,子类的位置却可以接收父类的实例,这就使得泛型类型发生了逆变
void m (List<? super String){
}
协变与逆变的特性
当使用? extends E 时,只能调用传入参数的读取方法而无法调用其修改方法。 当使用? super E时,可以调用输入参数的修改方法,但调用读取方法的话返回值类型永远是Object,几乎没有用处。
是不是感觉不好理解,确实不好理解!
让我们一起看下code吧,理解了Java的这块,Kotlin的In和out关键字就手到擒来了。例如有如下一接口,其有两个方法,一个修改,一个读取。
interface BoxJ<T> {
T getAnimal();
void putAnimal(T a);
}
下面是两个使用通配符的方法,注意看注释//协变,可以接受BoxJ类型的参数
private Animal getOutAnimalFromBox(BoxJ<? extends Animal> box) {
Animal animal = box.getAnimal();
//无法调用该修改方法,因为当调用此方法时无法确定 ?究竟是一个什么类型,没办法传入
// box.putAnimal(某个类型)
return animal;
}
//逆变,可以接受BoxJ<Animal>类型的参数
private void putAnimalInBox(BoxJ<? super Dog> box){
box.putAnimal(new Dog());
// 虽然可以调用读取方法,但返回的类型却是Object,因为我们只能确定 ?的最顶层基类是Object
Object animal= box.getAnimal();
}
PECS 原则
关于Java的通配符如何使用, Effective Java, 3rd Edition 的作者将其总结为:
PECS : stands for Producer-Extends, Consumer-Super.
结合上面代码分析是不是觉得很精辟。
- Producer-Extends 只能调用读取方法,向外提供数据,无法调用修改方法
- Consumer-Super 一般只调用修改方法,消费从外面获取的数据,调用读取方法几乎没什么用,拿到的类型永远是Object
- 生产者用extends --上界 <? extends T>:只能取不能存
// 当你需要从集合中读取数据时
public void readAnimals(List<? extends Animal> animals) {
// 这里的集合是数据的生产者
animals.forEach(Animal::makeSound);
}
- 消费者用super – 下界 <? super T>:只能存不能取(完全取)
// 当你需要往集合中写入数据时
public void addDogs(List<? super Dog> dogs) {
// 这里的集合是数据的消费者
dogs.add(new Dog());
Object obj = dogs.get(0); // ✅ 只能以Object接收
}
- 如果只读取,用extends
- 如果只写入,用super
- 如果既要读又要写,别用通配符!
// 不要这样
public void confusingMethod(List<?> list) {...}
// 要这样
public <T> void clearMethod(List<T> list) {...}
总结
| extends | super | |
|---|---|---|
| 不能存 | 不能存 | 能存(Dog及子类) |
| 能取 | 能取(Animal) | 不能完全取(只能取 Object) |
| 场景 | 用于生产者场景 | 用于消费者场景 |
参考
https://www.zhihu.com/question/4685225699/answer/38577719343
https://zhuanlan.zhihu.com/p/268523581

浙公网安备 33010602011771号