Kotlin泛型进阶:协变,逆变,以及点变形

0.405字数 4346阅读 1543

众所周知,由于Java在发布之初是没有泛型的,即使在JDK 1.5加入泛型这个新特性后,Java的泛型充其量也只能算是伪泛型,即泛型只存在与编译期,而在运行时泛型根本不存在,所以被很多人所诟病;Kotlin作为一门JVM编程语言在运行时和Java没有什么不同,所以Kotlin即使有了看似很多的新的泛型语法和概念,但实际上都只是在编译期做做文章。但即使如此泛型的众多概念也是既抽象又难理解,特别是很多人在想编写高复用性的泛型代码的时候无从下手或在阅读他人的代码时对大量的"in","out",“*”等关键字感到无所适从,所以我觉得写一篇文章,彻底梳理和讲解一下泛型的高级概念还是非常有必要的。

如何声明一个泛型类或泛型函数,这样的基础概念想必大家都不陌生,我们直接跳过;我们以声明泛型的上界和Kotlin的泛型实化作为开胃菜,而协变,逆变,点变形等概念作为正餐来逐渐深入泛型的概念核心,最后再以一个例子来综合上面的所有知识点从而达到总结以及练习的作用。

开胃菜:上界,实化

上界

先来抛砖引玉,我们先定义一个泛型类:

class Zoom<T>(t: T)

这样的泛型类拥有一个泛型参数T,由于我们没有给T加入任何界定,所以T可以是任意类型。我们可以随意的传入泛型参数:

val zoom = Zoom(Any())

我们再来定义几个类:

class Animal

class Cat : Animal()

class Dog : Animal()

我们来给Zoom加一个上界:

class Zoom<T : Animal>(t: T)

我们再来初始化一下Zoom的对象:

// 错误,因为Any不是继承自Animal的类
val zoom = Zoom(Any())
// 正确
val zoom = Zoom(Cat())
// 正确
val zoom = Zoom(Dog())

我们可以看到,在声明Zoom的时候,如果给它的泛型参数声明了上界,那传入的所有泛型参数都必须是这个上界的子类,否则就会报编译期错误。上界看起来是个很简单的知识点,但是和后面的协变,逆变等知识息息相关,所以这里简单提一下。

实化

泛型实化在Java中是个不存在的概念,属于Kotlin的新特性;它能在运行时保留泛型信息,这听起来违反了JVM的机制,但是它确实可以做到,而且原理很简单。在不使用实化的情况下,下面的代码是不能通过编译的。

fun <T> create(): T = mRetrofit.create(T::class.java)

因为T在运行时不存在,所以没法通过T拿到T的class对象。

但是加上实化以后:

inline fun <reified T> create(): T = mRetrofit.create(T::class.java)

这个方法不仅可以被合法声明,而且在调用时也会非常优雅。

val service = create<NetworkService>()

create()方法不接收任何对象作为参数,而是只是传入了一个类型参数,就可以根据传入类型的不同返回我们需要的对象。

原理很简单,create()方法被声明称inline了,学过高阶函数的应该已经了解了,任何被声明称inline的函数都会把函数体内的所有代码直接复制到每一个被调用的地方,而由于泛型参数的不同,所以每一个调用inline函数的位置都会因为泛型的不同而有所不同,这样做其实就是间接的保留了泛型的运行时信息,看起来这似乎还是对伪泛型的投机取巧,但实际上非常有用。

开胃菜到此结束,没什么难理解的,现在上正餐。

正餐:协变,逆变,点变形,星号投影

协变

问题:在开胃菜里,Dog是Animal的子类,那Zoom<Dog>是不是Zoom<Animal>的子类型?

答案:Zoom<Dog>和Zoom<Animal>没有任何继承关系。

看起来有点不可思议,但是想想现实中的情况:如果我想要一只动物作为宠物,我对你到底送给我哪一种动物实际上无所谓,哪怕是大象狮子,或是蓝鲸大白鲨都可以,这时,你给了我一条狗,狗当然是动物的一种,所以我将狗作为宠物这个行为和我的预期并不冲突。但是Zoom<Dog>和Zoom<Animal>的情况并非如此,Zoom<Animal>不是代表所有类型的动物园的抽象,而是代表那种含有各种动物的动物园,而Zoom<Dog>可能代表一种狗主题的动物园,这是两种完全不同的动物园,如果你只想去动物园看各种类型的狗,那去那种普通的动物园可能并不符合你的预期,因为狗在这种动物园只是很小的一部分,而且还有可能它虽然有各种动物,但是根本就没有狗。

编程实际上比现实更为抽象,所以我们回到编程中来;这里我们要阐述清楚几个容易混淆的概念 —— 类和类型;Dog是一个类,也是一种类型,而Zoom是一个类,但Zoom不是一种类型,因为Zoom是一个泛型类,而Zoom<Animal>或Zoom<Dog>则都是类型,而且是两种没有继承关系的,独立的类型,我们得到了第一个命题:

  • 命题1: 存在两个类Son和Dad,且Son : Dad;存在泛型类A<T>,则类型A<Dad>和类型A<Son>之间没有任何子类型化关系。

如果我们一定让Zoom<Dog>被看作是Zoom<Animal>的子类型,也是有办法的,那就是在Zoom声明的时候使用out修饰符:

class Zoom<out T>(t: T)

这就是协变:泛型类的继承关系来源于子类型的继承关系,但这不是没有任何限制的......如果一个泛型类是协变的,那泛型参数类型的对象,只能出现在Zoom类中函数的返回值的位置上,不能出现在函数的参数里;我们来给Zoom加几个方法来说明这一点:

class Zoom<out T>(t: T) {
    //这里只是简单举例,Zoom类的方法的实现就省略了

    //收养,这个方法声明是错的
    fun adoption(t: T)
    //放生
    fun release(): T
}

fun function(zoom: Zoom<Animal>) {
    val animal = Animal()
    zoom.adoption(animal)
}

有趣的问题出现了,函数function接收一个Zoom<Animal>类型的参数,但是由于Animal是协变的,所以我们可以把Zoom<Dog>作为参数传入。但实际上这真的安全吗?

val dogZoom = Zoom(Dog())
function(dogZoom)

我们看看function是如何定义的,它创建了一个Animal类型的对象,并把它收养到Zoom<Animal>中,但是现在参数的是Zoom<Dog>;所以,如果这么做的话,一个狗主题的动物园可能收养了一只非洲雄狮,想想可怜的狗狗们会遭受怎样的命运......所以当Zoom如果是协变的时候,像adoption这种把泛型参数作为方法参数的类型的方法是不应该存在的,因为它类型不安全;我们慢慢继续,先忘掉我们有adoption()方法,现在的Zoom应该是这样的:

class Zoom<out T>(t: T) {
    //放生
    fun release(): T
}

fun function(zoom: Zoom<Animal>) {
    val animal: Animal = zoom.release()
}

val dogZoom = Zoom(Dog())
function(dogZoom)

上面的代码是完全合法的,因为dogZoom调用release方法会返回Dog,而Dog是Animal的子类,所以给animal引用赋值为一个Dog是完全合法的,就像我们平常在Java中总写的代码:

List<String> list = ArrayList<>();

我们总结一下协变的命题;

  • 命题2:存在两个类Son和Dad,且Son : Dad;存在泛型类A<out T>,则类型A<Son>是类型A<Dad>的子类型,且A<out T>中不存在包含T作为参数类型(或作为参数类型的泛型参数)的函数。

逆变

协变的理解可能有点绕,不过如果上一小节你完全看懂了,这一小节就会非常简单,因为你可以看作逆变就是协变完全相反的存在。

我们刚才看到了当Zoom是协变的时候,即Zoom声明成Zoom<out T>时,Zoom<Dog>是Zoom<Animal>的子类型;现在来看看逆变的情况,当Zoom是逆变的时候,即Zoom声明成Zoom<in T>的时候,Zoom<Animal>是Zoom<Dog>的子类型。

看起来也许你又会疑惑,Animal明明是Dog的父类,为什么Zoom<Animal>变成了Zoom<Dog>的子类型?

还记得刚才收养和放生两个方法吗?现在Zoom从协变变成了逆变,所以release()方法变得不是类型安全了,而adoption则变成了标准的合法的方法声明形式。我们来看一个例子:

class Zoom<in T>(t: T) {
    //收养
    fun adoption(t: T)
    //放生,这个方法声明是错的
    fun release(): T
}

fun function(zoom: Zoom<Dog>) {
    val dog = Dog()
    zoom.adoption(dog)
}

val animalZoom = Zoom(Animal())
function(animalZoom)

来看function函数的定义,function接收一个Zoom<Dog>类型的参数,然后创建了一个Dog对象,并把它作为参数,传给了zoom的adoption()方法 —— 一个狗主题的动物园收养了一条狗,非常符合逻辑;现在,由于Zoom是逆变的,所以Zoom<Animal>是Zoom<Dog>的子类型,所以animalZoom可以作为参数传递给函数function,这时候来看看function的执行情况 —— 一个普通的动物园收养了一只狗 —— 这在现实中非常符合逻辑,在编程中也是类型安全的。

现在情况要发生变化,再看下面这个例子:

fun function(zoom: Zoom<Dog>) {
    val dog: Dog = zoom.release()
}

val animalZoom = Zoom(Animal())
function(animalZoom)

function函数仍然接收一个Zoom<Dog>类型的参数,由于Zoom是逆变的,所以Zoom<Animal>是Zoom<Dog>的子类型,所以将animalZoom作为参数传入function是合法的;到目前为止和上面没有什么不同,但是再来看看function的执行情况:一个Dog类型的变量接收了zoom的release()方法的返回值......现在情况发生变化了,animalZoom调用release方法返回的对象,可能是一个任意类型的Animal,比如说Cat,所以这里一定会发生编译错误,因为我们将一个父类型的对象赋值给了子类型的引用,这是完全错误的;想想看,假如你有一只猫,但你却一定要把它当成狗......简直不可思议。

因此,当Zoom类是逆变的时候,它的内部就不能存在一个有包含泛型参数T存在于返回值类型的函数。

我们由此得到了逆变的命题;

  • 命题3:存在两个类Son和Dad,且Son : Dad;存在泛型类A<in T>,则类型A<Dad>是类型A<Son>的子类型,且A<in T>中不存在包含T作为返回值类型(或作为返回值类型的泛型参数)的函数。

点变形

事实上,真正的动物园,既需要收养,也需要放生;但是如果我们需要一个既包含adoption,又包含release方法的Zoom,那只能把它声明成不型变的了。但这样的话会给我们的编程带来诸多不便,我们也就不可能写出更具复用性的代码。因此,点变形出现了,看下面的例子:

class Zoom<T>(t: T) {
    //收养
    fun adoption(t: T)
    //放生
    fun release(): T
}

fun function1(zoom: Zoom<out Animal>) {
    val animal: Animal = zoom.release()
}

fun function2(zoom: Zoom<in Dog>) {
    val dog = Dog()
    zoom.adoption(dog)
}

val dogZoom = Zoom(Dog())
function1(dogZoom)

val animalZoom = Zoom(Animal())
function2(animalZoom)

如上所示,我们可以把泛型类声明为不型变的,但是在使用它的时候,加上out或者in,让它在使用的时候产生型变。但是正如上面所示,fuction1函数内,绝对不能调用Zoom的adoption这种泛型参数包含在函数参数内的函数,而function2中也绝对不能调用release这种将泛型参数包含在返回值类型中的函数,理由已经在协变和逆变两个小节中介绍过了。

使用点变形可以让我们的代码更加灵活,特别是对既需要泛型参数作为函数参数类型又需要泛型参数作为函数返回值类型的类而言。

星号投影

这个概念比较简单,如果你不在乎泛型类的泛型参数到底是什么,而是仅仅只想将这个泛型类作为一种类型,我们就可以使用“ * ”代替类型参数,从而表明,类型参数是什么并不重要。最常见的例子就是KClass<T>,它是Java中Class在Kotlin中的实现,举例来说,Dog类的KClass对象的具体类型就是KClass<Dog>,而Cat类的KClass对象的具体类型是KClass<Cat>。

假如我们有一个函数,它的内部使用了动态代理,它需要一个KClass类型的对象,但是我在协变小节刚开始的时候说了KClass只是一个类,而不是一个类型,但是动态代理的精髓在于可以动态生成任何代理对象。这时候我们就使用星号代替类型参数:

//实现省略
fun function(class: KClass<*>)

function(Cat::class)
function(Dog::class)

任何类型的Class对象都可以传入function函数。

总结

这一章节中比较难理解的地方就是协变和逆变,而它们的精髓,已经在文中被我总结成了命题1,2,3;而点变形和星号投影在理解了协变和逆变之后都是相当简单的概念。

我尽可能的阐述清楚了泛型中比较重要的概念,但是实际泛型的思考绝不仅仅只有本文中的内容。如果你想了解Kotlin中和泛型有关的更多信息,可以阅读《Kotlin实战》这本书,这是目前市面是最好的Kotlin书籍没有之一,它里面讨论了更多有关泛型的思考误区的情形;如果你想了解和Java泛型有关的更多知识,我推荐阅读《Java编程思想》中和泛型有关的章节,实际上《Java编程思想》中讨论的情形更加广泛,而Java和Kotlin实际上又是相通的,因此阅读这本书是个好选择。

你以为文章就要结束了吗?别忘了我们还有一个练习没做呢。

餐后甜点:利用泛型特性,实现一个简易的RxBus

EventBus相信很多Android开发者都用过,在任意一个位置发送一个事件,位于同一个进程的任意一个组件都可以收到消息,便于组件和模块之间的通信;我们这个练习就仿制EventBus的API,使用RxJava和泛型的知识来实现一个事件总线库,但是为了简洁,可以省略注解开发,而是使用接口来代替。

设计思想

API逻辑:任何一种对象都应该可以被事件总线当作消息发出,也可以被任意一个订阅了该消息的组件接收;事件的发出者只需要调用一个方法,就可以把任意一个事件发出,而事件的接收者在调用了注册方法后,就可以一直接收该类型的事件,在调用解除注册的方法后则不再接收该事件,同时,事件的接收者还应该拥有一个专门的函数用来处理接收的事件。

被接收者的管理方式:RxBus使用一个HashMap来保存事件接收者,HashMap的Key是事件类型对应的KClass对象,而Value是一条存放所有该类型事件接收者的LinkedList,只要事件接收者调用了注册方法,就会被添加到对应的LinkedList,而调用了解除注册方法,就会从LinkedList中移除。当事件发出者调用发射事件方法发射事件的时候,RxBus会根据事件类型查找到对应的LinedList,并遍历LinkedList,分别调用每一个节点的事件接收者的事件处理方法。

参考答案

RxBus

这是我提供的参考答案,可以完成简单的事件发射和接收要求;它利用了刚才讲到的几乎全部泛型知识:实化,协变,逆变,点变形,星号投影等,但是距离正式项目的实用还有差距,例如没有处理背压等情况,所以仅仅作为一个参考。

推荐阅读更多精彩内容