kotlin修炼指南7之泛型

2022-05-17 09:24:41 浏览数 (1)

Kotlin在Java的基础上,同样对泛型语法进行了拓展,所以很多Kotlin开发者,看着源码中的一堆in、out和*,感觉非常不知所措。其实,只要了解了Java泛型,那么Kotlin泛型就迎刃而解了。

首先,我们来想想,我们为什么需要泛型。

泛型是面向对象编程的一个非常重要的方面,它的出现,是多态的核心实现,简单的说,就是可以在不同的对象类型之间,使用相同的代码逻辑,从而实现复用。

为了充分了解泛型,以及泛型的实例场景,我们下面来构建一个面向对象的例子。

代码语言:javascript复制
abstract class Person(open val name: String) {
    abstract fun talk(): String
}

class Parent(override val name: String) : Person(name) {
    override fun talk(): String = name
}

class Son : Person("ryan") {
    override fun talk(): String = "hahaha"
}

fun doTalk(family: MutableList<Person>) {
    family.forEach { println(it.talk()) }
}

这里定义了一个Person类,作为基类,他的子类——Father、Son,就是具体的实例,新建一个方法doTalk,用来输出具体的实现。

代码语言:javascript复制
fun main() {
    val family = arrayListOf<Person>()
    family.add(Parent("Father"))
    family.add(Son())

    doTalk(family)
}

这样,我们就可以构建一个family的List,指定List的类型为Person,这样Father、Son,这些子类就都可以加入到这个List。

上面就是一个比较简单的泛型的使用实例。

泛型不变性

Father和Son都可以作为子类,加入到Person的List中,这就是泛型,但是让我们再看下下面的代码。

代码语言:javascript复制
val parents = mutableListOf<Parent>()
parents.add(Parent("Father"))
parents.add(Parent("Mother"))
doTalk(parents)

创建一个类型为Parent的List,再传入doTalk函数,这时候,编译器报错了。

为什么呢?从编译器来看,doTalk需要的是一个List类型的参数,但是传入的是List类型,确实类型不一致,但是,Parent是Person的子类,从语义上来说,doTalk函数也是可以接受Parent类型的List的。

这就是泛型的不变性。即使参数中的类型是父子关系,但是编译器依然不能识别,它只能识别具体的类型。

泛型的型变

正是由于存在泛型的不变性,所以我们在支持某些场景的泛型参数时,就需要通过「泛型的型变」来拓展「泛型的不变性」。

Kotlin,或者说Java的泛型,实际上是一种伪泛型,即泛型只在申明时检查泛型是否有效,在编译时,泛型类型会被擦除,这是因为Java的历史原因所导致的,由于它为了兼容没有泛型的老Java版本,从而做出的妥协。

❝不管是如何型变,它们的作用都是扩大泛型参数的类型范围。 ❞

协变

泛型的协变,是泛型型变的一种方式。

协变的使用很简单,我们给参数加上out前缀即可,代码如下。

代码语言:javascript复制
fun doTalk(family: MutableList<out Person>) {
    family.forEach { println(it.talk()) }
}

加上out关键字之后,参数类型就变成了「Person类及其子类」,也就是说,只要是Person的子类,都可以作为参数传进来。

那么这样处理之后,上面的方法就可以执行了。但是,协变之后的泛型,就变成可读而不可写类型了。

例如我们在协变泛型参数上进行写操作,代码如下。

代码语言:javascript复制
fun doTalk(family: MutableList<out Person>) {
    family.add(Son()) // Error
    family.forEach { println(it.talk()) }
}

这样就会报错,因为被out修饰之后,参数失去了写属性,变为只读属性了,这就是协变的副作用。

那么原因是什么呢?

我们来思考下,为什么它是可读的,通过out修饰之后,我们能保证,加入List的数据都是Person的子类,所以,List读取出来的实例类型,不管是哪个子类,都可以转为Person,也就是基类,所以可以通过它来调用基类的函数。

如果把参数写成Java的方式,可能更好理解一些。

代码语言:javascript复制
void doTalk(List<? extends Person> family) {}

可以发现,泛型的协变,实际上是控制了类型的上限,但返回的具体类型,是不确定的(?代表未知类型),这就是为什么在协变后的参数中,无法执行写指令的原因,因为参数的类型,可能是List,也可能是List,所以无法确定是哪一种类型,自然无法写入。

逆变

逆变是泛型型变的第二种方式,与协变类似,逆变也是将某一个泛型类型,拓展了其父类类型,例如下面这个方法。

代码语言:javascript复制
fun work(worker: MutableList<Son>) {
    worker.forEach { println(it.talk()) }
}

这个方法接收一个List类型的参数,那么假如我们要传递一个List类型的参数,就会报错,原因跟协变是一样的。

这个时候,就需要使用逆变关键字in,将参数类型拓展为「Son类及其父类」。

代码语言:javascript复制
fun work(family: MutableList<in Son>) {
    family.forEach { println(it.talk()) }
}


val family = arrayListOf<Person>()
family.add(Parent("Father"))
family.add(Son())
work(family)

这样参数就可以传进去了,但是,逆变的副作用,是会导致泛型参数失去读属性,而只能使用写属性。

代码语言:javascript复制
fun work(family: MutableList<in Son>) {
    family.add(Son())
    family.forEach { println(it.talk()) } // Error
}

同样的,我们将它转化为Java中的代码,这样更好理解一些。

代码语言:javascript复制
void work(List<? super Son> family) {}

泛型的逆变,实际上是控制了类型的下限,即Son及其父类。对List进行add操作时,新实例son一定符合条件,但是get时,只会获取到Any或者Object类型,所以,拿到Object类型后,你可以根据业务来进行强转。

星型投影

星型投影,其实就是Java中的「?」通配符,用于在泛型的使用中,去除泛型的依赖,这么说有点抽象,简单的说,就是当你不关心具体的泛型类型时,就可以使用「?」或者「*」来忽略泛型的约束。下面举个例子。

代码语言:javascript复制
class Push<T> {
    fun pushMsg(msg: String): T {}
}

fun <T> getPush(): Push<T> {}

这是泛型版本的方法,我们可以获取指定泛型的Push,同时,你也可以用out来做泛型协变,让它可以返回子类。

那么这个时候,如果我不关心泛型的类型呢?

代码语言:javascript复制
fun getPush(): Push<*> {}

fun main() {
    val push = getPush()
    val pushMsg = push.pushMsg("xys")
}

通过「*」,我们就可以不用指定泛型的具体类型,因为我不关心泛型类型,不过要注意的是,星型投影之后返回的类型,就成了「Any?」或者「Object」,因为泛型类型已经没有了。

❝但是我们依然可以使用协变来限制投影的上限,当我们加上上限后,就可以限制返回数据的上限类型了——out T : CommonPush ❞

实际使用

我们在设计泛型API时,通常会有两种使用方式,一种是将泛型作为参数,另一种是将泛型作为返回值,这两种模式,实际上就对应「生产者-消费者」模型。下面我们就借助这个模型,来完整的演示下。

❝官方文档中的说法是——Consumer in, Producer out ! ❞

生产者

首先,设计一个生产者。

代码语言:javascript复制
class Producer<T> {
    fun produceSth(): T {
        // TODO
    }
}

fun main() {
    val producer: Producer<Person> = Producer()
    val sth: Person = producer.produceSth()
}

这是我们泛型最基本的使用,创建Person类型的生产者,它生产出来的东西,全是Person类型。

下面,我们来对泛型协变,这样就可以创建Son类型的生产者。

代码语言:javascript复制
fun main() {
    val producer: Producer<out Person> = Producer<Son>()
    val sth: Person = producer.produceSth()
}

但是协变之后,生产出来的类型,依然是Person类型。

那么在Kotlin中,可以将这种在使用时的协变,变为申明时的协变,代码如下。

代码语言:javascript复制
class Producer<out T> {
    fun produceSth(): T {
        // TODO
    }
}

fun main() {
    val producer = Producer<Son>()
    val sth: Person = producer.produceSth()
}

在申明时标记协变,这样后续在使用时,就不用再标记了,你可以创建子类的生产者,生产基类的对象。

在Kotlin中,集合类大量使用了协变,如下所示。

代码语言:javascript复制
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

    // Positional Access Operations
    /**
     * Returns the element at the specified index in the list.
     */
    public operator fun get(index: Int): E

这里泛型就是作为返回值传入。

消费者

同样的,我们创建一个消费者。

代码语言:javascript复制
class Consumer<T> {
    fun consumeSth(t: T) {
        // TODO
    }
}

fun main() {
    val consumer: Consumer<Son> = Consumer<Person>()
    consumer.consumeSth(Son())
}

同理,创建一个Person类型的消费者,它只能消费Son类型的参数。

我们再给它增加逆变,让它可以接受Son的基类。

代码语言:javascript复制
fun main() {
    val consumer: Consumer<in Son> = Consumer<Person>()
    consumer.consumeSth(Son())
}

但是逆变之后,同样只能接受Son类型的参数,但是可以创建Person类型的消费者。

类似的,逆变也可以在申明处标记。

代码语言:javascript复制
fun main() {
    val consumer = Consumer<Person>()
    consumer.consumeSth(Son())
}

class Consumer<in T> {
    fun consumeSth(t: T) {
        // TODO
    }
}

那么逆变,在实际代码中的例子,我们可以参考下Comparable接口的设计。

代码语言:javascript复制
public interface Comparable<in T> {
    public operator fun compareTo(other: T): Int
}

这里的泛型就是作为参数传递,所以使用了逆变。

泛型的实例化

由于Java会在编译期进行泛型擦除,所以我们无法对泛型来做类型判断,比如下面的代码。

代码语言:javascript复制
fun <T> test(param: Int) {
    if (param is T) {// Error
    }
}

T是无法进行类型判断的,因为它已经被擦除了,这和在Java中使用instanceof判断是一样的,在Java中,我们通常会再传入一个Class类型的参数来处理这个问题。而在Kotlin中,有更简单的方法来处理,那就是通过inline配合reified关键字来处理。

代码语言:javascript复制
inline fun <reified T> test(param: Int) {
    if (param is T) {
    }
}

这样T就可以当做正常的类型来处理了,不过这种实例化的方式是有限制的。

  • 函数必须是内联函数,因为只有内联函数才会在编译时进行替换
  • 加上reified关键字让编译器在该泛型使用时进行实例化

在实战中,我们就可以利用泛型来进一步简化代码,例如:

代码语言:javascript复制
inline fun <reified T> startActivity(context: Context) {
    context.startActivity(Intent(context, T::class.java))
}

向大家推荐下我的网站 https://xuyisheng.top/

专注 Android-Kotlin-Flutter 欢迎大家访问

本文原创公众号:群英传,授权转载请联系微信(Tomcat_xu),授权后,请在原创发表24小时后转载。

< END >

作者:徐宜生

更文不易,点个“三连”支持一下

0 人点赞