协变和逆变

本文同步发表于我的微信公众号,在微信搜索 OpenCV or Android 即可关注。

协变、逆变

概念

许多程序设计语言的类型系统支持子类型。例如,如果Cat是Animal的子类型,那么Cat类型的表达式可用于任何出现Animal类型表达式的地方。所谓的变型(variance)是指如何根据组成类型之间的子类型关系,来确定更复杂的类型之间(例如Cat列表之于Animal列表,回传Cat的函数之于回传Animal的函数…等等)的子类型关系。当我们用类型构造出更复杂的类型,原本类型的子类型性质可能被保持、反转、或忽略───取决于类型构造器的变型性质

协变与逆变用来描述类型转换(type transformation)后的继承关系:A、B表示类型,f表示类型转换,A≦ B表示A为B的子类,那么则存在:

  • 当A ≦ B时,如果有f(A) ≦ f(B),那么f叫做协变;
  • 当A ≦ B时,如果有f(B) ≦ f(A),那么f叫做逆变;
  • 如果上面两种关系都不成立则叫做不可变。

具象化

定义Cat、Animal两个类型,且Cat是Animal的子类,类型构造器采用数组的形式:

  • 协变(covariant):一个Cat[]也是一个Animal[]
  • 逆变(contravariant):一个Animal[]也是一个Cat[]
  • 不变(invariant):以上两种均不满足。

总结:假想程序设计语言中的类型为输入,数组、列表、泛型等类型构造器为函数,当函数值与输入正相关时为协变,当函数值与输入负相关时为逆变。父子类关系代表输入的大小。

语言场景

Java

Java语言中,数组支持协变,泛型类原生既不支持协变,也不支持逆变。

定义类Dog,类Animal,且Dog为Animal的子类

public static void main(String[] args) {
    Dog[] dogs = new Dog[5];
    Animal[] animals = dogs; // Java数组支持协变

    ArrayList<Dog> dogList = new ArrayList<>();
    ArrayList<Animal> animalList = dogList; // 编译报错,Java泛型直接使用不支持协变
}

显然,Dog ≦ Animal,Dog[] ≦ Animal[],Java数组支持协变,不支持逆变,也就是说子类数组对象可以赋值给父类数组申明,但是父类数组对象不能赋值给子类数组申明。而针对Java泛型,直接使用既不支持协变也不支持逆变。逆变不支持好理解,为啥Java泛型连协变也不支持呢?

因为类型擦除。泛型虽然是 Java 1.5 版本引进的概念,但是,泛型代码能够很好地和旧版本代码兼容。因为Java为了兼容老版本,将与泛型相关的类型信息进行了擦除。关于类型擦除,有一道经典面试题:

public static void typeEraseSample() {
    ArrayList<Integer> intList = new ArrayList<>();
    ArrayList<String> strList = new ArrayList<>();
    boolean isSameClass = intList.getClass() == strList.getClass()
    System.out.printf(String.valueOf(isSameClass));
}

如上代码最后输入为何?终端输出:true。何解?查看字节码,一目了然。

类型擦除

利用类型擦除,我们可以采用反射方式向Java列表对象中添加任何类型的对象。

public static void hackTypeErase() {
    ArrayList<Integer> intList = new ArrayList<>();
    intList.add(23);
    try {
        Method method = intList.getClass().getDeclaredMethod("add", Object.class);
        method.invoke(intList, new Dog());
        method.invoke(intList, "yidong");
    } catch (Exception e) {
        e.printStackTrace();
    }
    for (Object object : intList) {
        System.out.println(object);
    }
}

反射操作结果

举例只是为了说明问题,并不是推荐大家这样操作。继续回到协变和逆变,Java泛型直接使用不支持协变和逆变,但是通过Java提供的泛型通配符,我们可以做到返回值协变和参数逆变。

泛型通配符

PECS原则:Producer extends,Consumer super。

上界通配符:? extends T

// 泛型协变
public static void covariantGeneric() {
    List<? extends Animal> objList = new ArrayList<Dog>() {{
        add(new Dog());
        add(new Dog());
    }};
    Animal animal = objList.get(0);//编译通过
    Dog dog = objList.get(0);      //编译报错
    objList.add(new Animal());     //编译报错
    objList.add(new Dog());        //编译报错
}

通俗理解:? extends Animal,代表的是Animal及其子类,所以Animal是类型上界,由此来理解“上界通配符”这个名称。针对返回值是泛型的方法(示例中get方法),由于子类对象可以赋值给父类引用,所以必须用Animal或者其父类引用来接收,体现协变的转型一致性。针对参数是泛型的方法(示例中add方法),为了保持确定性,不允许执行该类操作,因为无法确定程序传入的子类对象类型,倘若允许此类操作,在获取列表元素时,就会存在明显的类型不安全。

总结:? extends T表示所存储类型都是T及其子类,但是获取元素所使用的引用类型只能是T或者其父类。使用上限通配符实现向上转型,但是会失去存储对象的能力,上限通配符为集合的协变表示。适用于只使用,不修改的场景,也就是生产者角色。

下界通配符:? super T

// 泛型逆变
public static void contravariantGeneric() {
    List<? super Dog> objList = new ArrayList<Animal>() {{
        add(new Animal());
        add(new Animal());
    }};
    Animal animal = objList.get(0);//编译报错
    Dog dog = objList.get(0);      //编译报错
    Object object = objList.get(0);//编译正常
    objList.add(new Animal());     //编译报错
    objList.add(new Dog());        //编译通过
}

通俗理解:? super Dog,代表的是Dog及其父类,所以Dog是类型下界,由此来理解“下界通配符”这个名称。针对返回值是泛型的方法,由于子类对象只能赋值给自己或者父类引用,但是我们并不能保证返回的对象的继承关系比引用类型低,所以除了用Object引用接受,其他的类型接受均是不被允许的。而针对参数是泛型的方法(示例中add方法),由于Dog是继承关系的最底层,所以传入Dog或者其子类对象,列表元素引用是必然可以接收的,所以该操作是被允许的。

总结:下限通配符 ? super T表示 所存储类型为T及其父类,但是添加的元素类型只能为T及其子类,而获取元素所使用的类型只能是Object,因为Object为所有类的父类。下限通配符为集合的逆变表示。适用于只修改,不使用的场景,也就是消费者角色。

无界通配符:?

只使用类型无关的方法时可采用无界通配符。

public static int getLength(List<?> list) {
    return list.size();
}

Kotlin

Kotlin泛型直接使用不支持协变,也不支持逆变。由于Kotlin数组也是采用泛型的形式实现的,所以也不支持协变和逆变。与Java语言对应,Kotlin也可以使用关键字in和out来打开协变和逆变的限制,但是使用过程中同样存在和Java一样的限制。

  • in关键字对应? super
  • out关键字对应? extends

Kotlin和Java关键字名称很好的诠释了PECS法则:Producer exends, Consumer super

为了方便记忆,可以合并一下:Producer (out) exends, Consumer (in) super

完整示例如下:

// 类型擦除
fun typeEraseSample() {
    val intList = ArrayList<Int>()
    val strList = ArrayList<String>()
    val isSameClass = intList.javaClass == strList.javaClass
    System.out.printf(isSameClass.toString())
}

// 利用反射完成填充操作
fun hackTypeErase() {
    val intList = ArrayList<Int>()
    intList.add(23)
    try {
        val method = intList.javaClass.getDeclaredMethod("add", Any::class.java)
        method.invoke(intList, Dog())
        method.invoke(intList, "yidong")
    } catch (e: Exception) {
        e.printStackTrace()
    }
    for (obj in intList) {
        println(obj)
    }
}

// 泛型协变
fun covariantGeneric() {
    val objList: MutableList<out Animal> = MutableList(5) { Dog() }
    val animal = objList[0] //编译通过
    val dog: Dog = objList[0] //编译报错
    objList.add(Animal()) //编译报错
    objList.add(Dog()) //编译报错
}

// 泛型逆变
fun contravariantGeneric() {
    val objList: MutableList<in Dog> = MutableList(5) { Animal() }
    val animal: Animal = objList[0] // 编译报错
    val dog: Dog = objList[0] // 编译报错
    val obj: Any? = objList[0] // 编译正常
    objList.add(Animal()) //编译报错
    objList.add(Dog()) //编译通过
}

// 获取列表长度
fun getLength(list: List<*>): Int {
    return list.size
}

最后介绍一个Java里没有的内容,具体化类型参数【reified】。在Java语言中,类型参数并不是一个真正的类型,而只是一个代号,我们无法把它当成一个普通类型使用,比如无法调用instanceof函数。但是在Kotlin中,我们可以通过reified关键字来具体化类型参数,但是只能在内联方法中使用,因为 Kotlin 编译器会把内联函数的代码插入到调用者的地方,所以可以在编译期就确定泛型的类型。

下面这个简单的方法很好的体现了reified的作用:

inline fun <reified R> isInstanceOf(t: Any) = t is R

总结

为了方便记忆,首尾呼应一下:

协变

逆变

参考链接:

https://baike.baidu.com/item/%E5%8D%8F%E5%8F%98/10963814?fr=aladdin
https://blog.csdn.net/zy_jibai/article/details/90082239
https://www.zybuluo.com/zhanjindong/note/34147
https://www.bilibili.com/video/BV1T441117u8

onlyloveyd CSDN认证博客专家 Android Kotlin OpenCV
个人公众号【OpenCV or Android】,热爱Android、Kotlin、Flutter和OpenCV。毕业于华中科技大学计算机专业,曾就职于华为武汉研究所。目前在三线小城市生活,专注Android、OpenCV、Kotlin、Flutter等有趣的技术。
已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 博客之星2020 设计师:CY__ 返回首页