跳到主要内容

Kotlin 泛型型变

上篇文章我们一起为 Kotlin 中的泛型型变做了一个很好的铺垫,深入分析下类型和类,子类型和子类之间的关系、什么是子类型化关系以及型变存在的意义。那么今天将会讲点更有挑战性的东西,也就是 Kotlin 泛型型变中最为难理解的地方,那就是 Kotlin 中的协变、逆变、不变。

1. 泛型协变 (保留子类型化关系)

1.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
}

1.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协变点
}

1.3 out 协变点基本特征

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

1.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 协变的位置是返回值的类型以及只读属性的类型 (这点源码中没有表现出来,但是实际上却是如此啊,这里可以自行查阅其他例子)。

2. 泛型逆变 (反转子类型化关系)

2.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.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所在位置依然是函数形参类型,所以仍处于逆变点
}

2.3 in 逆变点基本特征

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

2.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函数的形参类型,可以看出它是属于消费泛型参数的
}

3. 泛型不变 - 无子类型化关系

3.1 不变基本定义和介绍

基本介绍

对于不变就更简单了,泛型型变中除去协变、逆变就是不变了。其实不变看起来就是我们常用的普通泛型,它既没有 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
}

4. 协变、逆变、不变的规则引出几个问题

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

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

出现的场景:

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

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

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

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

解答 2:同理,也可以在协变点位置。

思答 3:能在其他的位置吗?比如构造函数?

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


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

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

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

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

解答 4:不行,这样不安全,按照实际场景需求出发,一味定义成协变或逆变实际上限制了该泛型类对该类型形参使用的可能性,因为 out 只能是作为生产者,协变点位置有限制,而 in 只能是消费者逆变点的位置也有限制。

那索性全都定义成不变型,那就在另一层面丧失了灵活性,就是它失去了子类型化关系,就是把它作为函数参数类型,外部只能传入和它相同的类型,不可能存在子类型化关系的保留和反转了

5. 协变点、逆变点的本质

由上面的思考明白了一点,使用协变、逆变的时候并不是那么死的按照协变点,逆变点规则来,可以更加灵活点,关键是不能违背协变、逆变根本宗旨。协变宗旨就是定义的泛型类内部不能存在写操作的行为,对于逆变根本宗旨一般都是只写的。

那 Kotlin 中 List<out E> 的源码来说都不是真正规则上说的那样协变,泛型形参 E 并不都是在协变点 out 上,但是 List<out E> 内部能够保证不会存在写操作危险行为所以这种定义也是合法。实际上真正开发过程,很难做到协变泛型类中的泛型类型形参都是在 out 协变点上,因为有时候需求需要确实需要从外部传入一个该类型形参的一个函数形参。

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

6. UnSafeVariance 注解在开发中的应用

由上面的本质区别分析,严格按照协变点、逆变点规则来是不能完全满足我们真实开发需求场景的,所以有时候需要一道后门,那就要用特殊方式告诉它。那就是使用 UnSafeVariance 注解。所以 UnSafeVariance 注解作用很简单: 通过 @UnSafeVariance 告诉编译器该处安全性自己能够把控,让它放你编译通过即可,如果不加编译器认为这是不合法的

注解的意思就是不安全的型变,例如在协变泛型类中有个函数是以传入一个该泛型形参的函数形参的,通过 UnSafeVariance 注解让编译器闭嘴,然后把它放置在逆变点实际上是增加一层危险性,相当于把这层危险交给了开发者,只要开发者能保证内部不存在危险性操作肯定就是安全的。

7. 协变、逆变、不变对比分析总结

7.1 分析对比

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

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

7.2 使用对比

实际上就是要明确什么时候该使用协变、什么时候该使用逆变、什么时候该使用不变。

实际上通过上述分析对比的表格可以得出结论:

首先,表格有很多个条件特征,到底是先哪个开始判定条件好呢?实际上这里面还是需要选择一下的。

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

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

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

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

来张图理解下:

图片描述

7.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>,因为一旦传入和它不一样的具体类型就会存在危险操作,出现不安全的问题。

8. 总结

到这里有关 Kotlin 中泛型型变的核心概念就阐述完毕了,文章有点长可以需要好好消化,重在理解以及一些实际的场景上应用。其实对于 Kotlin 协变和不变倒是很好理解,可能大家对于逆变还是需要好好理解。下篇文章将是泛型型变的一些应用和一些其他概念研究比如星投影之类的。