教你如何攻克Kotlin中泛型型变的难点(下篇)

5,101 阅读24分钟

简述: 前几天我们一起为Kotlin中的泛型型变做了一个很好的铺垫,深入分析下类型和类,子类型和子类之间的关系、什么是子类型化关系以及型变存在的意义。那么今天将会讲点更刺激的东西,也就是Kotlin泛型型变中最为难理解的地方,那就是Kotlin中的协变、逆变、不变。虽然很难理解,但是有了上篇文章基础教你如何攻克Kotlin中泛型型变的难点(上篇)理解起来还是相对比较轻松。如果你是初学者不建议直接看这篇文章,还是建议把该系列的上篇理解下。

扯会皮,这几天我一直在思考一个问题,因为官方给出的结论太过于正式化,而且估计好点的开发者只是记住官方的结论和它的使用规则,但是并没有真正去了解为什么是这样的,这样设计的意义何在呢?

废话不多说,继续上本篇文章的思维导图

一、泛型协变-保留子类型化关系

1、协变基本定义和介绍

还记得上篇的子类型化关系吗?协变实际上就是保留子类型化关系,首先,我们需要去明确一下这里所说的保留子类型化关系是针对谁而言的呢?

  • 基本介绍

来看个例子,StringString?的子类型,我们知道基础类型List<out E>是协变的,那么List<String>也就是List<String?>的子类型的。很明显这里针对的角色就是List<String>List<String?>,是它们保留了StringString?的子类型化关系。或者换句话说两个具有相同的基础类型的泛型协变类型,如果类型实参具有子类型化关系,那么这个泛型类型具有一致方向的子类型化关系。那么具有子类型化关系实际上子类型的值能在任何时候任何地方替代超类型的值。

  • 基本定义
interface Producer<out T> {//在泛型类型形参前面指定out修饰符
   val something: T
   fun produce(): T
}

2、什么是out协变点

从上面定义的基本结构来看,实际上协变点就是上面produce函数返回值的T的位置,Kotlin中规定一个泛型协变类,在泛型形参前面加上out修饰后,那么修饰这个泛型形参在函数内部使用范围将受到限制只能作为函数的返回值或者修饰只读权限的属性。

interface Producer<out T> {//在泛型类型形参前面指定out修饰符
   val something: T//T作为只读属性的类型,这里T的位置也是out协变点
   fun produce(): T//T作为函数的返回值输出给外部,这里T的位置就是out协变点
}

以上协变点都是标准的T类型,实际上以下这种方式其实也是协变点,请注意体会协变点含义:

interface Producer<out T> {
   val something: List<T>//即使T不是单个的类型,但是它作为一个泛型类型修饰只读属性,所以它所处位置还是out协变点
   
   fun produce(): List<Map<String,T>>//即使T不是单个的类型,但是它作为泛型类型的类型实参修饰返回值,所以它所处位置还是out协变点
}

3、out协变点基本特征

协变点基本特征: 如果一个泛型类声明成协变的,用out修饰的那个类型形参,在函数内部出现的位置只能在只读属性的类型或者函数的返回值类型。相对于外部而言协变是生产泛型参数的角色,生产者向外输出out

4、协变-List<out E>的源码分析

我们在上篇文章中就说过Kotlin中的List并不是Java中的List,因为Kotlin中的List是个只读的List不具备修改集合中元素的操作方法。Java的List实际上相当于Kotlin中的MutableList具有各种读和写的操作方法。

Kotlin中的List<out E>实际上就是协变的例子,用它来说明分析协变最好不过了,还记得上篇文章说过的学习泛型步骤二吗,就是通过分析源码来验证自己的理解和结论。通过以下源码均可验证我们上述所说的结论。

//通过泛型类定义可以看出使用out修饰符 修饰泛型类型形参E
public interface List<out E> : Collection<E> {
    override val size: Int
    override fun isEmpty(): Boolean
    override fun contains(element: @UnsafeVariance E): Boolean//咦! 咦! 咦! 和说的不一样啊,为什么还能出现在这个位置,还出来了个@UnsafeVariance 这个是什么鬼? 告诉你,稳住,先不要急,请听我在后面慢慢说来,先暂时保留神秘感
    override fun iterator(): Iterator<E>//这里明显能看出来E处于out协变点位置,而且还是泛型类型Iterator<E>出现的,正好验证我们上述所说的协变的变种类型(E为类型实参的泛型类型)

    override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
    public operator fun get(index: Int): E//函数返回值的类型E,这里明显能看出来E处于out协变点位置,正好验证我们上述所说的协变的标准类型(E直接为返回值的类型)
    public fun indexOf(element: @UnsafeVariance E): Int

    public fun lastIndexOf(element: @UnsafeVariance E): Int

    public fun listIterator(): ListIterator<E>//(E为类型实参的泛型类型),为out协变点

    public fun listIterator(index: Int): ListIterator<E>//(E为类型实参的泛型类型),为out协变点
    public fun subList(fromIndex: Int, toIndex: Int): List<E>//(E为类型实参的泛型类型),为out协变点
}

源码分析完了,是不是感觉还是有点迷惑啊?就是E为啥还能在其他的位置上,还有@UnsafeVariance是个什么鬼? 这些疑问先放一放,但是上述至少证明了泛型协变out协变的位置是返回值的类型以及只读属性的类型(这点源码中没有表现出来,但是实际上却是如此啊,这里可以自行查阅其他例子)

二、泛型逆变-反转子类型化关系

1、逆变基本定义和介绍

  • 基本介绍

逆变实际上就是和协变子类型化关系正好相反,它是反转子类型化关系

来个例子说明下,我们知道StringString?的子类型,Comparable<in T>是逆变的,那么Comparable<String>Comparable<String?>实际上是反转了StringString?的子类型化关系,也就是和StringString?的子类型化关系相反,那么Comparable<String?>就是Comparable<String>子类型, Comparable<String>类型值出现的地方都可用Comparable<String?>类型值来替代。

换句话说就是:两个具有相同的基础类型的泛型逆变类型,如果类型实参具有子类型化关系,那么这个泛型类型具有相反方向的子类型化关系

  • 基本定义
interface Consumer<in T>{//在泛型类型形参前面指定in修饰符
   fun consume(value: T)
}

2、什么是in逆变点

从上面定义的基本结构来看,实际上逆变点就是上面consume函数接收函数形参的T的位置,Kotlin中规定一个泛型协变类,在泛型形参前面加上out修饰后,那么修饰这个泛型形参在函数内部使用范围将受到限制只能作为函数的返回值或者修饰只读权限的属性。

interface Consumer<in T>{//在泛型类型形参前面指定in修饰符
   var something: T //T作为可变属性的类型,这里T的位置也是in逆变点
   fun consume(value: T)//T作为函数形参类型,这里T的位置也就是in逆变点
}

和协变类似,逆变也存在那种泛型类型处于逆变点的位置,这些我们都可以把当做逆变点:

interface Consumer<in T>{
   var something: B<T>//这里虽然是泛型类型但是T所在位置依然是修饰可变属性类型,所以仍处于逆变点
   fun consume(value: A<T>)//这里虽然是泛型类型但是T所在位置依然是函数形参类型,所以仍处于逆变点
}

3、in逆变点基本特征

逆变点基本特征: 如果一个泛型类声明成逆变的,用in修饰泛型类的类型形参,在函数内部出现的位置只能是作为可变属性的类型或者函数的形参类型。相对于外部而言逆变是消费泛型参数的角色,消费者请求外部输入in

4、逆变-Comparable<in T>的源码分析

在Kotlin中其实最简单的泛型逆变的例子就是Comparable<in T>

public interface Comparable<in T> {//泛型逆变使用in关键字修饰
    /**
     * Compares this object with the specified object for order. Returns zero if this object is equal
     * to the specified [other] object, a negative number if it's less than [other], or a positive number
     * if it's greater than [other].
     */
    public operator fun compareTo(other: T): Int//因为是逆变的,所以T在函数内部出现的位置作为compareTo函数的形参类型,可以看出它是属于消费泛型参数的
}

三、泛型不变-无子类型化关系

不变基本定义和介绍

  • 基本介绍

对于不变就更简单了,泛型型变中除去协变、逆变就是不变了。其实不变看起来就是我们常用的普通泛型,它既没有in关键字修饰,也没有out关键字修饰。它就是普通的泛型,所以很明显它没有像协变、逆变那样那么多的条条框框,它很自由既可读又可写,既可以作为函数的返回值类型也可以作为函数形参类型,既可以声明成只读属性的类型又可以声明可变属性。但是注意了:不变型就是没有子类型化关系,所以它会有一个局限性就是如果以它作为函数形参类型,外部传入只能是和它相同的类型,因为它根本就不存在子类型化关系说法,那也就是没有任何类型值能够替换它,除了它自己本身的类型 例如MutableList<String>和MutableList<String?>是完全两种不一样的类型,尽管StringString?子类型,但是基础泛型MutableList<E>是不变型的,所以MutableList<String>和MutableList<String?>根本没关系。

  • 基本定义

interface MutableList<E>{//没有in和out修饰
   fun add(element: E)//E可以作为函数形参类型处于逆变点,输入消费E
   fun subList(fromIndex: Int, toIndex: Int): MutableList<E>//E又可以作为函数返回值类型处于协变点,生产输出E
}

四、由协变、逆变、不变的规则引发一些思考

思考一:

协变泛型类的泛型形参类型T一定就只能out协变点位置吗?能不能在in逆变点位置呢?

解惑一: 可以在逆变点,但是必须在函数内部保证该泛型参数T不存在写操作行为,只能有读操作

出现的场景: 声明了协变的泛型类,但是有时候需要从外部传入一个该类型形参的函数参数,那么这个形参类型就处于in逆变点的位置了,但是函数内部能够保证不会对泛型参数存在写操作的行为。常见例子就是List<out E>源码,就是上面大家一脸懵逼的地方,就是那个为什么定义成协变的泛型T跑到了函数形参类型上去。 如下面部分代码所示:

  override fun contains(element: @UnsafeVariance E): Boolean//咦! 咦! 咦! 和说的不一样啊,为什么还能出现在这个位置,还出来了个@UnsafeVariance 这个是什么鬼? 现在回答你就是可能会出现在这,但是只要保证函数不会写操作即可

上述的List中的contains函数形参就是泛型形参E,它是协变的出现在逆变点,但是只要保证函数内部不会对它有写操作即可

思考二:

逆变泛型类的泛型形参类型T就一定只能在in逆变点位置吗?能不能在out协变点位置呢?

解惑二: 同理,也可以在协变点位置

思考三:

能在其他的位置吗? 比如构造函数

解惑三: 可以在构造器函数中,因为这是个比较特殊的位置,既不在in位置也不在out位置


class ClassMates<out T: Student>(vararg students: T){//可以看到虽然定义成了协变,但是这里的T不是在out协变点的位置,这种声明依然是合法的
   ...
}

注意: 这里就是很特殊的场景了,所以开头就说过了如果把这些规则,用法只是死记硬背下来,碰到这种场景的时候就开始怀疑人生了,规则中不是这样的啊,规则中定义协变点就是只读属性类型和函数返回值类型的位置啊,这个位置不上不下的该怎么解释呢?所以解决问题还是需要抓住问题的关键才是最主要的。

其实解释这个问题也不难,回到型变的目的和初衷上去,型变是为了解决类型安全问题,是防止更加泛化的实例调用某些存在危险操作的方法。构造函数很特殊一般创建后实例对象后,在该对象基础上构造函数是不能再被调用的,所以这里T放在这里是安全的。

思考四

为了安全,我是不是只要把所有泛型类全都定义成协变或逆变或不变一种就可以了呢?

解惑四: 不行,这样不安全,按照实际场景需求出发,一味定义成协变或逆变实际上限制了该泛型类对该类型形参使用的可能性,因为out只能是作为生产者,协变点位置有限制,而in只能是消费者逆变点的位置也有限制。那索性全都定义成不变型,那就在另一层面丧失了灵活性,就是它失去了子类型化关系, 就是把它作为函数参数类型,外部只能传入和它相同的类型,不可能存在子类型化关系的保留和反转了

五、由思考领悟到协变点、逆变点的本质

由上面的思考明白了一点,使用协变、逆变的时候并不是那么死的按照协变点,逆变点规则来,可以更加灵活点,关键是不能违背协变、逆变根本宗旨。协变宗旨就是定义的泛型类内部不能存在写操作的行为,对于逆变根本宗旨一般都是只写的。那Kotlin中List<out E>的源码来说都不是真正规则上说的那样协变,泛型形参E并不都是在协变点out上,但是List<out E>内部能够保证不会存在写操作危险行为所以这种定义也是合法。实际上真正开发过程,很难做到协变泛型类中的泛型类型形参都是在out协变点上,因为有时候需求需要确实需要从外部传入一个该类型形参的一个函数形参。

所以最终的结论是: 协变点out和逆变点in的位置的规则是一般大体情况下要遵守的,但是需要具体情况具体分析,针对设计的泛型类具体情况,适当地在不违背根本宗旨以及满足需求情况下变下协变点和逆变点的位置规则

六、由本质区别明白UnSafeVariance注解在开发中的应用

由上面的本质区别分析,严格按照协变点、逆变点规则来是不能完全满足我们真实开发需求场景的,所以有时候需要一道后门,那就要用特殊方式告诉它。那就是使用UnSafeVariance注解。所以UnSafeVariance注解作用很简单: 通过@UnSafeVariance告诉编译器该处安全性自己能够把控,让它放你编译通过即可,如果不加编译器认为这是不合法的。注解的意思就是不安全的型变,例如在协变泛型类中有个函数是以传入一个该泛型形参的函数形参的,通过UnSafeVariance注解让编译器闭嘴,然后把它放置在逆变点实际上是增加一层危险性,相当于把这层危险交给了开发者,只要开发者能保证内部不存在危险性操作肯定就是安全的。

七、协变、逆变、不变对比分析、使用和理解

1、分析对比

将从基本结构形式、有无子类型化关系(保留、反转)、有无型变点(协变点out、逆变点in)、角色(生产者输出、消费者输入)、类型形参存在的位置(协变就是修饰只读属性和函数返回值类型;逆变就是修饰可变属性和函数形参类型)、表现特征(只读、可写、可读可写)等方面进行对比

协变 逆变 不变
基本结构 Producer<out E> Consumer<in T> MutableList<T>
子类型化关系 保留子类型化关系 反转子类型化关系 无子类型化关系
有无型变点 协变点out 逆变点in 无型变点
类型形参存在的位置 修饰只读属性类型和函数返回值类型 修饰可变属性类型和函数形参类型 都可以,没有约束
角色 生产者输出为泛型形参类型 消费者输入为泛型形参类型 既是生产者也是消费者
表现特征 内部操作只读 内部操作只写 内部操作可读可写

2、使用对比

实际上就是要明确什么时候该使用协变、什么时候该使用逆变、什么时候该使用不变。 实际上通过上述分析对比的表格可以得出结论: 首先,表格有很多个条件特征,到底是先哪个开始判定条件好呢?实际上这里面还是需要选择一下的。

假设一: 就比如一开始就以有无使用子类型化关系为条件做判定,这样做法是有点问题的,试想下在实际开发中,先是去定义泛型类内部一些方法和属性的,这时候很难知道在外部使用情况下存不存在利用子类型化关系,也就是存不存在用子类型的值替换超类型的值场景,所以在刚刚定义泛型类的时候很难明确的。故还是先从泛型类定义的内部特征着手会更加明确点。

假设二: 比如先根据泛型类内部定义一些方法和属性,由于刚开始定义并不能确定是否是协变out还是逆变in,所以上面的有无型变点不能作为判定条件,最开始还没确定的时候一般当做不变泛型类来定义。

,最直白可以先看看型变点,然后根据型变点基本确定泛型类内部表现特征,

  • 步骤1: 首先,根据类型形参存在的位置初步判定
  • 步骤2: 然后,通过判定表现特征是在泛型类定义内部是不是只涉及到该泛型形参只读操作(协变或不变),还是写操作(逆变或不变),还是既可读又可写(不变)这里只能判断出两种组合情况(协变或不变)、(逆变或不变)中的一种,因为如果只涉及到读操作那就是(协变或不变),如果只涉及写操作(逆变或不变)
  • 步骤3: 最后,再去看是否存在子类型化关系,如果通过步骤2得到是 (协变或不变)外加有子类型化关系最终得到使用协变,如果通过步骤2得到是 (逆变或不变)外加有子类型化关系最终得到使用逆变,如果没有子类型化关系就用不变。

补充一点,如果最终确定是协变的,可是在定义的时候通过步骤1得到类型形参存在的位置处于函数形参位置,那么这时候就可以大胆借助@UnSafeVariance注解告诉编译器使得编译通过,逆变同理。

来张图理解下

3、理解对比

是否还记得上一篇文章开头的那个例子和那幅漫画图

  • 对于协变的理解:

例子代码如下:

fun main(args: Array<String>) {
    val stringList: List<String> = listOf("a", "b", "c", "d")
    val intList: List<Int> = listOf(1, 2, 3, 4)
    printList(stringList)//向函数传递一个List<String>函数实参,也就是这里List<String>是可以替换List<Any>
    printList(intList)//向函数传递一个List<Int>函数实参,也就是这里List<Int>是可以替换List<Any>
}

fun printList(list: List<Any>) {
//注意:List是协变的,这里函数形参类型是List<Any>,函数内部是不知道外部传入是List<Int>还是List<String>,全部当做List<Any>处理
    list.forEach {
        println(it)
    }
}

理解:

对于printList函数而言,它需要的是List<Any>类型是个相对具体类型更加泛化的类型,且在函数内部的操作不会涉及到修改写操作,然后在外部传入一个更为具体的子类型肯定是满足要求的泛化类型最基本需求。所以外部传入更为具体子类型List<String>、List<Int>的兼容性更好。

  • 对于逆变的理解:

例子代码如下:

class A<in T>{
    fun doAction(t: T){
        ...
    }
}

fun main(args: Array<String>) {

    val intA = A<Int>()
    val anyA = A<Any>()

    doSomething(intA)//不合法,
    doSomething(anyA)//合法
}

fun doSomething(a: A<Number>){//在doSomething外部不能传入比A<Number>更为具体的类型,因为在函数内部涉及写操作.
    ....
}

理解:

对于doSomething,它需要的A<Number>是个相对泛化类型更加具体的类型,由于泛型类A逆变的,函数内部的操作放开写操作权限,试着想下在doSomething函数外部不能传入比他更为具体的比较器对象了,因为只要有比A<Number>更为具体的,就会出问题,利用反证法来理解下,假如传入A<Int>类型是合法的,那么在内部函数还是当做A<Number>,在函数内部写操作时候很有可能把它往里面写入一个Float类型的数据,因为往Number类型写入Float类型是很合法的,但是外部实际上传入的是A<Int>,往A<Int>写Float类型不出问题才怪呢,所以原假设不成立。所以逆变放开了写权限,那么对于外部传入的类型要求就更加严格了。

引出另一个问题,为什么逆变写操作是安全的呢? 细想也是很简单的,对于逆变泛型类型作为函数形参的类型,那么在函数外部的传入实参类型就一定要比函数形参的类型更泛化不能更具体,所以在函数内部操作的最具体的类型也就是函数形参类型,所以肯定可以大胆写操作啊。就比如A<Number>类型形参类型,在doSomething函数中明确知道外部不能比它更为具体,所以在函数内部大胆在A<Number>基础上写操作是可以的。

  • 对于不变的理解 例子代码如下:
fun main(args: Array<String>) {
    val stringList: MutableList<String> = mutableListOf("a", "b", "c", "d")
    val intList: MutableList<Int> = mutableListOf(1, 2, 3, 4)
    printList(stringList)//这里实际上是编译不通过的
    printList(intList)//这里实际上是编译不通过的
}

fun printList(list: MutableList<Any>) {
    list.add(3.0f)//开始引入危险操作dangerous! dangerous! dangerous!
    list.forEach {
        println(it)
    }
}

理解:

不变实际上就更好理解了,因为不存在子类型化关系,没有所谓的子类型A的值在任何地方任何时候可以替换超类型B的值的规则,所以上述例子编译不过,对于printList函数而言必须接收的类型是MutableList<Any>,因为一旦传入和它不一样的具体类型就会存在危险操作,出现不安全的问题。

八、结语

由于篇幅原因,所以星投影和协变、逆变实际例子的应用放到下一篇应用篇去了,但是到这里Kotlin泛型型变重点和难点已经全部讲完,后面一篇也就是实际开发中例子的运用。关于这篇文章还是需要好好消化一下,最后再根据下一篇实际例子就可以更加巩固,下篇将会注重讲开发中的例子实现,不会再扣概念了。下篇敬请关注~~~

Kotlin系列文章,欢迎查看:

原创系列:

翻译系列:

实战系列:

欢迎关注Kotlin开发者联盟,这里有最新Kotlin技术文章,每周会不定期翻译一篇Kotlin国外技术文章。如果你也喜欢Kotlin,欢迎加入我们~~~