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

5,566

简述: Kotlin中泛型相关的文章也几乎接近尾声,但到后面也是泛型的难点和重点。相信有很多初学者对Kotlin中的泛型型变都是一知半解,比如我刚开始接触就是一脸懵逼,概念太多了,而且每个概念和后面都是相关的,只要前面有一个地方未理解后面的难点更是越来越看不懂。Kotlin的泛型比Java中的泛型多了一些新的概念,比如子类型化关系、逆变、协变、星投影的。个人认为学好Kotlin的泛型主要有这么几个步骤:

  • 第一,深入理解泛型中每个小概念和结论,最好能用自己的话表述出来;
  • 第二,通过分析Kotlin中的相关源码验证你的理解和结论;
  • 第三,就是通过实际的例子巩固你的理解;

由于泛型型变涉及的内容比较多,所以将它分为上下两篇,废话不多说请看以下导图:

一、为什么会存在型变?

首先,我们需要明确两个名词概念: 基础类型和实参类型。例如对于List<String>, List就是基础类型而这里的String就是实参类型

然后,我们需要明确一下,这里的型变到底指的是什么?

可以先大概描述一下,它反映的是一种特殊类型的对应关系规则。是不是很抽象?那就先来看个例子,例如List<String>和List<Any>他们拥有相同的基础类型,实参类型StringAny存在父子关系,那么是不是List<String>List<Any>是否存在某种对应关系呢? 实际上,我们讨论的型变也就是围绕着这种场景展开的。

有了上面的认识,进入正题为什么需要这种型变关系呢?来看对比的例子,我们需要向一个函数中传递参数。

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<Any>,函数内部是不知道外部传入是List<Int>还是List<String>,全部当做List<Any>处理
    list.forEach {
        println(it)
    }
}

上述操作是合法的,运行结果如下

如果我们上述的函数形参List<Any>换成MutableList<Any>会变成什么样呢?

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

我们来试想下,利用反证法验证下,假如上述代码编译通过了,会发生什么,就会发生下面的可能出现类似的危险操作. 就会出现一个Int或者String的集合中引入其他的非法数据类型,所以肯定是有问题的,故编译不通过。因为我们说过在函数的形参类型MutableList<Any> 在函数内部它只知道是该类型也不知道外部给它传了个啥,所以它只能在内部按照这个类型规则来,所以在函数内部list.add(3.0f)这行代码时编译通过的,向一个MutableList<Any>集合加入一个Float类型明显说得过去的。

总结: 通过对比上面两个例子,大家有没有思考一个问题就是为什么List<String>、List<Int>替换List<Any>可以,而MutableList<String>、MutableList<Int>替换MutableList<Any>不可以呢?实际上问题所说的类型替换其实就是型变,那大家到这就明白了为什么会存在型变了,型变更为了泛型接口更加安全,假如没有型变,就会出现上述危险问题。

那另一问题来了为什么有的型变关系可以,有的不可以呢?对于传入集合内部不会存在修改添加其元素的操作(只读),是可以支持外部传入更加具体类型实参是安全的,而对于集合内部存在修改元素的操作(写操作)是不安全的,所以编译器不允许。 以上面例子分析,List<Any>实际上一个只读集合(注意: 它和Java中的List完全不是一个东西,注意区分),它内部不存在add,remove操作方法,不信的可以看下它的源码,所以以它为形参的函数就可以敞开大门大胆接收外部参数,因为不存在修改元素操作所以是安全的,所以第一个例子是编译OK的;而对于MutableList<Any>在Kotlin中它是一个可读可写的集合,相当于Java中的List,所以它的内部存在着修改、删除、添加元素的危险操作方法,所以对于外部传入的函数形参它需要做严格检查必须是MutableList<Any>类型。

为了帮助理解和记忆,自己绘制了一张独具风趣的漫画图帮助理解,这张图很重要以致于后面的协变、逆变、不变都可以从它获得理解。后面也会不断把它拿出来分析

最后为了彻底把这个问题分析透彻可以给大家看下List<E>MutableList<E>的部分源码

public interface List<out E> : Collection<E> {
    // Query Operations
    override val size: Int

    override fun isEmpty(): Boolean
    override fun contains(element: @UnsafeVariance E): Boolean
    override fun iterator(): Iterator<E>

    // Bulk Operations
    override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
    
    ...
  }

public interface MutableList<E> : List<E>, MutableCollection<E> {
    // Modification Operations
    override fun add(element: E): Boolean

    override fun remove(element: E): Boolean

    // Bulk Modification Operations
    override fun addAll(elements: Collection<E>): Boolean
    ...
 }

仔细对比下List<out E>MutableList<E>泛型定义是不一样的,他们分别对应了协变不变,至于什么是协变什么是逆变什么不变,我们后面会详细讲。

二、类、类型、子类、子类型、超类型概念梳理

看到标题可能大家会有点纳闷, 类和类型不是一个东西吗?我平时都是把它们当做一个东西来用的啊。实际上是不一样的,在这里我们需要去一一扣概念去理解,以便后面更好理解型变关系。那么我们一起看下它们到底有哪些不一样的?

我们可以把Kotlin中的类可分为两大类: 泛型类非泛型类

  • 非泛型类

先说非泛型类也就是开发中接触最多的一般类,一般的类去定义一个变量的时候,它的实际就是这个变量的类型。例如: var msg: String 这里我们可以说Stringmsg变量的类型是一致的。但是在Kotlin中还有一种特殊的类型那就是可空类型,可以定义为var msg: String?,这里的Stringmsg变量的String?类型就不一样了。所以在Kotlin中一个一般至少对应两种类型. 所以类和类型不是一个东西。

  • 泛型类

泛型类比非泛型类要更加复杂,实际上一个泛型类可以对应无限种类型。为什么这么说,其实很容易理解。我们从前面文章知道,在定义泛型类的时候会定义泛型形参,要想拿到一个合法的泛型类型就需要在外部使用地方传入具体的类型实参替换定义中的类型形参。我们知道在Kotlin中List是一个类,它不是一个类型。由它可以衍生成无限种泛型类型例如List<String>、List<Int>、List<List<String>>、List<Map<String,Int>>

  • 子类、子类型和超类型

我们一般说子类就是派生类,该类一般会继承它的父类(也叫基类)。例如: class Student: Person(),这里的Student一般称为Person的子类

子类型则不一样,我们从上面类和类型区别就知道一个类可以有很多类型,那么子类型不仅仅是想子类那样继承关系那么严格。 子类型定义的规则一般是这样的: 任何时候如果需要的是A类型值的任何地方,都可以使用B类型的值来替换的,那么就可以说B类型是A类型的子类型或者称A类型是B类型的超类型。可以明显看出子类型的规则会比子类规则更为宽松。那么我们可以一起分析下面几个例子:

注意: 某个类型也是它自己本身的子类型,很明显String类型的值任意出现地方,String肯定都是可以替换的。属于子类关系的一般也是子类型关系。像String类型值肯定不能替代Int类型值出现的地方,所以它们不存在子类型关系

再来看个例子,所有类的非空类型都是该类对应的可空类型的子类型,但是反过来说就不行,就比如String非空类型是String?可空类型的子类型,很明显嘛,任何String?可空类型出现值的地方,都可以使用String非空类型的值来替换。其实这些我在开发过程中是可以体会得到的,比如细心的同学就会发现,我们在Kotlin开发过程,如果一个函数接收的是一个可空类型的参数,调用的地方传入一个非空类型的实参进去是合法的。但是如果一个函数接收的是非空类型参数,传入一个可空类型的实参编译器就会提示你,可能存在空指针问题,需要做非空判断。 因为我们知道非空类型比可空类型更安全。来幅图理解下:

三、什么是子类型化关系?

我相信到了这,大家应该自己都能猜出什么是子类型化关系吧?它是实际上就是我们上面所讲的那些。

子类型化关系:

大致概括一下: 如果A类型的值在任何时候任何地方出现都能被B类型的值替换,B类型就是A类型的子类型,那么B类型到A类型之间这种映射替换关系就是子类型化关系

回答最开始的问题

现在我们也能用Kotlin中较为专业的术语子类型化关系来解释最开始那个问题为什么以List<String>,List<Int>类型的函数实参可以传递给List<Any>类型的函数形参,而MutableList<String>,MutableList<Int>类型的函数实参不可以传递给MutableList<Any>类型的函数形参?

因为List<String>,List<Int>类型是List<Any>类型的子类型,所以List<Any>类型值出现的地方都可以使用List<String>,List<Int>类型的值来替换。而MutableList<String>,MutableList<Int>类型不是MutableList<Any>的子类型也不是它的超类型,所以当然就不能替换了。

由上面回答引出一个细节点

仔细分析观察下上面所说的,List<String>,List<Int>类型是List<Any>类型的子类型,然后再细看针对都具有相同的List这个基础类型的泛型参数类型对应关系, 这里的String,Int类型是Any类型的子类型(注意: 我们在泛型中都应该站在类型和子类型的角度来看问题,不要在局限于类和子类继承层面啊,这点很重要,因为List<String>还是List<String?>子类型呢,所以和继承层面子类没有关系),然后List<String>,List<Int>类型也是List<Any>类型的子类型,这种关系叫做保留子类型化关系,也就是所谓的协变。具体我会下篇着重分析。

四、结语

本篇文章可以说是下篇文章的一个概念理解的基础,下篇很多高级的概念和原理都是在这篇文章延伸的,建议好好消化这些概念,这里最后再着重强调几点:

  • 1、一定需要好好理解什么是子类型,它和子类有什么区别。实际上Kotlin中的泛型型变的基础就是子类型化关系啊,一般在这我们都是站在类型和子类型角度分析关系,而不是简单的类和子类继承层面啊。

  • 2、还有就是大家有没有思考过为什么要弄这么一套型变关系啊,其实仔细想想就为了泛型类操作和使用更加安全,避免引入一些存在危险隐患,造成泛型不安全,具体可以看看本文前面画的一张丑陋的漫画。所以也不得不佩服设计出这套规则语言开发者思想所折服啊。

  • 3、最后说下,下篇文章就是泛型中的高级概念了,其实不用害怕,只要把这篇文章概念理解清楚了后面会很简单的。

Kotlin系列文章,欢迎查看:

原创系列:

翻译系列:

实战系列:

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