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

5,287

简述: 这是泛型型变最后一篇文章了,也是泛型介绍的最后一篇文章。顺便再扯点别的,上周去北京参加了JetBrains 2018开发者日,主要是参加Kotlin专场。个人感觉收获还是挺多的,bennyHuo和彦伟老师精彩演讲确实传递很多干货啊,当然还有Hali布道师大佬带来了的Kotlin1.3版本的新特性以及Google中国技术推广负责人钟辉老师带来的Coroutines在Android开发中的应用。所以准备整理如下几篇文章为后续发布:

  • 1、Kotlin中1.3版本新特性都有哪些?
  • 2、Kotlin中的Coroutine(协程)在Android上应用(协程学前班篇)
  • 3、Ktor异步框架初体验(Ktor学前班篇)
  • 4、Kotlin中data class的使用(benny大佬在大会上讲的很清楚了,也很全面。主要讲下个人之前踩过的坑,特别是用于后端开发坑更多)

那么今天这篇文章主要是为了给上篇型变文章两个尾巴以及泛型型变是如何被应用到实际开发中的去。并且我会用上篇博客如何去选择相应型变的方法一步步确定最终我们该使用协变、逆变、还是不变,我会用一个实际例子来说明。这篇文章比较简单主要就以下四点:

  • 1、Kotlin声明点变型与Java中的使用点变型进行对比
  • 2、如何使用Kotlin中的使用点变型
  • 3、Kotlin泛型中的星投影
  • 4、使用泛型型变实现可用于实际开发中的Boolean扩展

一、Kotlin声明点变型与Java中的使用点变型进行对比

1、声明点变型和使用点变型定义区别

首先,解释下什么是声明点变型和使用点变型,声明点变型顾名思义就是在定义声明泛型类的时候指明型变类型(协变、逆变、不变),在Kotlin上表现形式就是在声明泛型类时候在泛型形参前面加in或out修饰。使用点变型就是在每次使用该泛型类的时候都要去明确指出型变关系,如果你对Java中型变熟悉的话,Java就是使用了使用点变型.

2、两者优点对比

声明点变型:

  • 有个明显优点就是只需要在泛型类声明时定义一次型变对应关系就可以了,那么之后不管在任何地方使用它都不用显示指定型变对应关系,而使用点变型就是每处使用的地方都得重复定义一遍特别麻烦(又找到一处Kotlin优于Java的地方)。

使用点变型:

  • 实际上使用点变型也是有使用场景的,可以使用的更加灵活;所以Kotlin并没有完全摒弃这个语法点,下面会专门介绍它的使用场景。

3、使用对比

刚刚说使用点变型特别麻烦,一起来看看到底有多麻烦。这里就是以Java为代表,我们都知道Java中要使用型变,是利用?通配符加(super/extends)来达到目的,例如: Function<? super T, ? extends E>, 其中的? extends E就是对应了协变,而? super T对应的是逆变。这里以Stream API中的flatMap函数源码为例

@FunctionalInterface
public interface Function<T, R> {//声明处就不用指定型变关系
    ...
}

//可以看到使用点变型非常麻烦,定义一个mapper的Function泛型类参数时,还需要指明后面一大串Function<? super T, ? extends Stream<? extends R>>
  <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);

声明点变型到底有多方便,这里就以Kotlin为例,Kotlin使用in, out来实现型变对应规则。这里以Sequences API中的flapMap函数源码为例


public interface Sequence<out T> {//Sequence定义处声明了out协变
    /**
     * Returns an [Iterator] that returns the values from the sequence.
     *
     * Throws an exception if the sequence is constrained to be iterated once and `iterator` is invoked the second time.
     */
    public operator fun iterator(): Iterator<T>
}

public fun <T, R> Sequence<T>.flatMap(transform: (T) -> Sequence<R>): Sequence<R> {//可以看到由于Sequence声明了协变,所以flatMap函数Sequence中的泛型实参R就不用再次指明型变类型了
    return FlatteningSequence(this, transform, { it.iterator() })
}

通过以上源码对比,明显看出Kotlin中的声明点变型要比Java中的使用点变型要简单得多吧。但是呢使用点变型并不是一无是处,它在Kotlin中还是有一定的使用场景的。下面即将揭晓

二、如何使用Kotlin中的使用点变型

实际上使用点变型在Kotlin中还是有一定的使用场景,想象一下这样一个实际场景,尽管某个泛型类是不变的,也就是具有可读可写的操作,可是有时候在某个函数中,我们一般仅仅只用到只读或只写操作,这时候利用使用点变型它能使一个不变型的缩小型变范围蜕化成协变或逆变的。是不是突然懵逼了,用源码来说话,你就明白了,一起来看个源码中的例子。

Kotlin中的MutableCollection<E>是不变的,一起来看了下它的定义

public interface MutableCollection<E> : Collection<E>, MutableIterable<E> {//没有in和out修饰,说明是不变
    override fun iterator(): MutableIterator<E>
    public fun add(element: E): Boolean
    public fun remove(element: E): Boolean
    public fun addAll(elements: Collection<E>): Boolean
    public fun removeAll(elements: Collection<E>): Boolean
    public fun retainAll(elements: Collection<E>): Boolean
    public fun clear(): Unit
}

然后我们接着看filter和filterTo函数的源码定义

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

//注意: 这里<T, C : MutableCollection<in T>>, MutableCollection<in T>声明成逆变的了,是不是很奇怪啊,之前明明有说它是不变的啊,怎么这里就声明逆变了
public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {
    for (element in this) if (predicate(element)) destination.add(element)
    return destination
}

通过上面的函数是不是发现和MutableCollection不变相违背啊,实际上不是的。这里就是一种典型的使用点变型的使用,我们可以再仔细分析下这个函数,destination在filterTo函数的内部只做了写操作,遍历Iterable中的元素,并把他们add操作到destination集合中,可以验证我们上述的结论了,虽然MutableCollection是不变的,但是在函数内部只涉及到写操作,完全就可以使用 使用点变型将它指定成一个逆变的型变类型,由不变退化成逆变明显不会影响泛型安全所以这里处理是完全合法的。可以再去看其他集合操作API,很多地方都使用了这种方式。

上述关于不变退化到逆变的,这里再讲个不变退化到协变的例子。

//可以看到source集合泛型类型声明成了out协变了???
fun <T> copyList(source: MutableList<out T>, destination: MutableList<T>): MutableList<T>{
    for (element in source) destination.add(element)
}

MutableList<E>就是前面常说的不变的类型,同样具有可读可写操作,但是这里的source的集合泛型类型声明成了out协变,会不会又蒙了。应该不会啊,有了之前逆变的例子,应该大家都猜到为什么了。很简单就是因为在copyList函数中,source集合没有涉及写操作只有读操作,所以可以使用 使用点变型将MutableList的不变型退化成协变型,而且很显然不会引入泛型安全的问题。

所以经过上述例子和以前例子关于如何使用逆变、协变、不变。还是我之前说那句话,不要去死记规则,关键在于使用场景中读写操作是否引入泛型类型安全的问题。如果明确读写操作的场景了完全可以按照上述例子那样灵活运用泛型的型变的,可以程序写得更加完美。

三、Kotlin泛型中的星投影

1、星投影的定义

星投影是一种特殊的星号投影,它一般用来表示不知道关于泛型实参的任何信息,换句话说就是它表示一种特定的类型,但是只是这个类型不知道或者不能被确定而已。

2、MutableList<*>MutableList<Any?>区别

首先我们需要注意和明确的一点就是MutableList<*>MutableList<Any?>是不一样的,MutableList<*>表示包含某种特定类型的集合;而MutableList<Any?>则是包含任意类型的集合。特定类型集合只不过不太确定是哪种类型,任意类型表示包含了多种类型,区别在于特定集合类型一旦确定类型,该集合只能包含一种类型;而任意类型就可以包含多种类型了。

3、MutableList<*>实际上一个out协变投影

MutableList<*>实际上是投影成MutableList<out Any?>类型

首先,我们来分析下为什么会这样投影,我们知道MutableList<*>只包含某种特定类型的集合,可能是String、Int或者其他类型中的一种,可想而知对于该集合操作需要禁止写操作,不能往该集合中写入数据,因为无法确定该集合的特定类型,写操作很可能引入一个不匹配类型到集合中,这是一件很危险的事。但是反过来想下,如果该集合存在只读操作,读出数据元素类型虽然不知道,但是始终是安全的。只存在读操作那么说明是协变,协变就会存在保留子类型化关系,也就是读出数据元素类型是不确定类型子类型,那么可想而知它只替换Any?类型的超类型,因为Any?是所有类型的超类型,那么保留型化关系,所以MutableList<*>实际上就是MutableList<out Any?>的子类型了。

四、使用泛型型变实现可用于实际开发中的Boolean扩展

关于Boolean扩展的实现,主要来源于看了BennyHuo大佬写的一些代码中发现的,原来可以这么方便的写if-else,于是乎就去看了下它的实现 可能很多人都知道了它的实现,为什么要讲这个因为这是Kotlin泛型协变实际应用一个非常不错的例子。

1、为什么开发一个Boolean扩展

给出一个例子场景,判断一堆数集合中是否全是奇数,如果全是返回输出"奇数集合",如果不是请输出"不是奇数集合"

首先问下大家是否写过一下类似下面代码

//java版写法

public void isOddList(){
    int count = 0;
    for(int i = 0; i < numberList.size(); i++){
        if(numberList[i] % 2 == 1){
            count++;
        }
    }
    if(count == numberList.size()){
       System.out.println("奇数集合");
       return;
    }
    System.out.println("不是奇数集合");
}

//kotlin版写法

fun isOddList() = println(if(numberList.filter{ it % 2 == 1}.count().equals(numberList.size)){"奇数集合"} else {"不是奇数集合"})
//Boolean扩展版本写法
fun isOddList() = println(numberList
          .filter{ it % 2 == 1 }
          .count()
          .equals(numberList.size)
          .yes{"奇数集合"}
          .otherwise{"不是奇数集合"})//有没有发现Boolean扩展这种链式调用更加丝滑

对比发现,虽然Kotlin中的if-else表达式自带返回值的,但是if-else的结构会打断链式调用,但是如果使用Boolean扩展,完全可以使你的链式调用更加丝滑顺畅一路调用到底。

2、Boolean扩展使用场景

Boolean扩展的使用场景个人认为有两个:

  • 配合函数式API一起使用,遇到if-else判断的时候建议使用Boolean扩展,因为它不会像if-else结构一样会打断链式调用的结构。
  • 另一场景就是if的判断条件组合很多,如果在外层再包裹一个if代码显得更加臃肿了,此时使用Boolean会使代码更简洁。

3、Boolean代码实现

通过观察上述Boolean扩展的使用,我们首先需要明确几点:

  • 第一点:我们知道yes、otherwise实际上就是两个函数,为什么能链式链接起来说明中间肯定有一个类似桥梁作用的中间类型作为函数的返回值类型。
  • 第二点:yes、otherwise函数的作用域是带返回值的,例如上述例子它能直接返回字符串类型的数据。
  • 第三点: yes、oterwise函数的都是一个lamba表达式,并且这个lambda表达式将最后表达式中的值返回
  • 第四点: yes函数是在Boolean类型调用,所以需要基于Boolean类型的实现扩展函数

那么根据以上得出几点特征基本可以把这个扩展的简单版本写出来了(暂时不支持带返回值的)

//作为中间类型,实现链式链接
sealed class BooleanExt 
object Otherwise : BooleanExt()
object TransferData : BooleanExt()

fun Boolean.yes(block: () -> Unit): BooleanExt = when {
    this -> {
        block.invoke()
        TransferData//由于返回值是BooleanExt,所以此处也需要返回一个BooleanExt对象或其子类对象,故暂且定义TransferData object继承BooleanExt
    }
    else -> {//此处为else,那么需要链接起来,所以需要返回一个BooleanExt对象或其子类对象,故定义Otherwise object继承BooleanExt
        Otherwise
    }
}

//为了链接起otherwise方法操作所以需要写一个BooleanExt类的扩展
fun BooleanExt.otherwise(block: () -> Unit) = when (this) {
    is Otherwise -> block.invoke()//判断此时子类,如果是Otherwise子类执行block
    else -> Unit//不是,则直接返回一个Unit即可
}


fun main(args: Array<String>) {
    val numberList: List<Int> = listOf(1, 2, 3)
    //使用定义好的扩展
    (numberList.size == 3).yes {
        println("true")
    }.otherwise {
        println("false")
    }
}

上述的简单版基本上把扩展的架子搭出来但是呢,唯一没有实现返回值的功能,加上返回值的功能,这个最终版本的Boolean扩展就实现了。

现在来改造一下原来的版本,要实现返回值那么block函数不能再返回Unit类型,应该要返回一个泛型类型,还有就是TransferData不能使用object对象表达式类型,因为需要利用构造器传入泛型类型的参数,所以TransferData用普通类替代就好了。

关于是定义成协变、逆变还是不变型,我们可以借鉴上篇文章使用到流程选择图和对比表格

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

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

  • 第一步:首先根据类型形参存在位置以及表现特征确定
sealed class BooleanExt<T>

object Otherwise : BooleanExt<Any?>()

class TransferData<T>(val data: T) : BooleanExt<T>()//val修饰data

inline fun <T> Boolean.yes(block: () -> T): BooleanExt<T> = when {//T处于函数返回值位置
    this -> {
        TransferData(block.invoke())
    }
    else -> Otherwise//注意: 此处是编译不通过的
}

inline fun <T> BooleanExt<T>.otherwise(block: () -> T): T = when (this) {//T处于函数返回值位置
    is Otherwise ->
        block()
    is TransferData ->
        this.data
}

通过以上代码我们可以基本确定是协变或者不变,

  • 第二步:判断是否存在子类型化关系

由于yes函数else分支返回的是Otherwise编译不通过,很明显此处不是不变的,因为上述代码就是按照不变方式来写的。所以基本确定就是协变。

然后接着改,首先将sealed class BooleanExt<T>改为sealed class BooleanExt<out T>协变声明,然后发现Otherwise还是报错,为什么报错啊,报错原因是因为yes函数要求返回一个BooleanExt<T>类型,而此时返回Otherwise是个BooleanExt<Any?>(),反证法,假如上述是合理,那么也就是BooleanExt<Any?>要替代BooleanExt<T>出现的地方,BooleanExt<Any?>BooleanExt<T>子类型,由于BooleanExt<T>协变的,保留子类型型化关系也就是Any?T子类型,明显不对吧,我们都知道Any?是所有类型的超类型。所以原假设明显不成立,所以编译错误很正常,那么逆向思考下,我是不是只要把Any?位置用所有的类型的子类型Nothing来替换不就符合了吗,那么我们自然而然就想到Nothing,在Kotlin中Nothing是所有类型的子类型。所以最终版本Boolean扩展代码如下

sealed class BooleanExt<out T>//定义成协变

object Otherwise : BooleanExt<Nothing>()//Nothing是所有类型的子类型,协变的类继承关系和泛型参数类型继承关系一致

class TransferData<T>(val data: T) : BooleanExt<T>()//data只涉及到了只读的操作

//声明成inline函数
inline fun <T> Boolean.yes(block: () -> T): BooleanExt<T> = when {
    this -> {
        TransferData(block.invoke())
    }
    else -> Otherwise
}

inline fun <T> BooleanExt<T>.otherwise(block: () -> T): T = when (this) {
    is Otherwise ->
        block()
    is TransferData ->
        this.data
}

五、结语

到这里Kotlin中有关泛型的所有文章就结束,当然泛型很重要深入于实际开发各个地方,特别是开发一些框架东西比较多,可以看到上述Boolean实现就是按照上篇文章教你如何攻克Kotlin中泛型型变的难点(下篇)规则来决定使用哪种型变类型以及稍加分析下就出来了。总的来说有了那张图做指导还是很方便的。其实关于泛型型变,还是得需要多理解,不能死记规则,只有这样才能更加灵活运用。最后非常感谢bennyHuo大佬提供的Boolean扩展实现。

Kotlin系列文章,欢迎查看:

原创系列:

翻译系列:

实战系列:

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