Kotlin 泛型进阶:攻克型变与实化,直击痛点

上一篇博客中,我们知道了泛型这个"万能魔法盒"的基础用法。现在我们接着深入,来讲解泛型的实化(reified型变(协变与逆变)

别怕,我会带你理清这些看着很高端、很复杂的概念。

泛型实化:打破"类型擦除"的封印

在了解泛型实化之前,我们先来回顾一下 JVM 的一个历史遗留问题:类型擦除

一开始 Java 没有泛型,容器可以存放任意类型的对象。如果要取用,需要手动进行强制类型转换。如果稍微没处理好,程序运行时就会抛出类型转换异常 ClassCastException

而泛型是在 JDK 1.5 开始引入的。为了兼容旧版本代码,JVM 让泛型的类型约束只存在于编译期,为我们带来了编译期的类型安全。在运行时,JVM 完全识别不出我们为泛型指定的具体类型。

比如 List<String>List<Int> 在运行时,JVM 会认为他俩是同一个类型(List)。因此你写出类似 a is TT::class.java 这样的代码会报错,T 这个具体的类型在实际运行时,早就被擦除了。

魔法指令:inline 与 reified

不过,Kotlin 相比 Java 更近了一步,它通过内联函数 (inline) 机制打破了这个封印。

内联函数中的代码会在编译时被"复制粘贴"到调用处,相关代码都拷过来了,编译器在调用处自然知道泛型的实际类型。

只要配合上 reified 关键字,Kotlin 就能让泛型进行实化,在运行时获取到泛型的具体类型。

实战场景1:优雅地启动 Activity

在启动 Activity 时,我们经常会传递目标 Activity 的 Class 对象,我们是这样做的:

kotlin 复制代码
val intent = Intent(context, TargetActivity::class.java)
context.startActivity(intent)

有了泛型实化后,就可以去掉繁琐的 ::class.java

kotlin 复制代码
// inline + reified: 让编译器固化 T 的类型到字节码中
inline fun <reified T : Activity> Context.startActivity() {
    val intent = Intent(this@startActivity, T::class.java)
    startActivity(intent)
}

仅需一行代码即可启动 Activity:startActivity<TargetActivity>()

实战场景2:Gson 解析告别 TypeToken

当使用 Gson 解析对象时,我们也需要传递类的 Class 对象,如果解析的是列表(如 List<User>),还需要写一长串的类型获取代码:

kotlin 复制代码
val user = Gson().fromJson(jsonString, User::class.java)
val type = object : TypeToken<List<User>>() {}.type
val userList: List<User> = Gson().fromJson(jsonArrayString, type)

使用实化后:

kotlin 复制代码
inline fun <reified T> Gson.fromJson(json: String): T {
    return fromJson(json, object : TypeToken<T>() {}.type)
}

val user = Gson().fromJson<User>(jsonString)
val userList = Gson().fromJson<List<User>>(jsonArrayString)

为什么我们需要型变?

"型变"描述的是泛型类型之间的子类型关系,这是什么意思?

假设有一个继承关系:CatDog 都继承自 Animal

kotlin 复制代码
open class Animal(val name: String)

class Dog(name: String) : Animal(name)

class Cat(name: String) : Animal(name)

那么 List<Cat> 是不是 List<Animal> 的子类?换句话说,List<Animal> 类型的参数 / 变量能接收一个 List<Cat> 实例吗?

答案是不能。编译器会直接报错!为什么?

如果编译器允许这样的话,会导致一个问题:

kotlin 复制代码
val cats: MutableList<Cat> = mutableListOf(Cat("Tom"), Cat("Kitty"))
// 假设这里编译不报错
val animals: MutableList<Animal> = cats // 把猫的集合赋值给动物的集合

animals.add(Dog("Snoopy")) // 往动物集合里塞进了一只狗,这是合法的

// 但当你再次从 cats 集合里拿数据时,拿到的却是一只狗!
val myCat: Cat = cats[2] // ClassCastException

所以为了杜绝这种因写入操作导致的类型安全问题(把狗塞进猫窝),编译器硬性规定:泛型集合之间没有任何继承关系,即上述的 MutableList<Cat> 不是 MutableList<Animal> 的子类。

这也舍弃了把 List<Cat> 当作 List<Animal> 传递的能力。但如果说绝对不往里面写入数据,只读取数据,不就行了吗?

为了解决这个痛点,Kotlin 引入了协变(out)逆变(in)

协变 (out) ------ "只出不进"的自动售货机

在泛型类中,如果一个泛型类型 T 只在函数的返回值或只读属性(val 声明)中出现,我们就称 T 处于 out 位置。

你可以把它想象成一台自动售货机(生产者)

如果我需要一台"能吐出动物的售货机",你给我一台"吐出猫的售货机",完全没问题,因为猫也是动物。只要这台机器只能往外吐数据(out),不接收外部塞入的数据,就是绝对安全的。

在 Kotlin 中,只要在泛型的前面加上 out 关键字,就可以声明协变:

kotlin 复制代码
// out 约束了 T 只能用于返回值或 val 属性,这就是协变
class VendingMachine<out T>(private val item: T) {
    fun get(): T { // 只读不写,绝对安全
        return item
    }
}

fun main() {
    val catMachine: VendingMachine<Cat> = VendingMachine(Cat("Tom"))

    // 现在 VendingMachine<Cat> 成为了 VendingMachine<Animal> 的子类
    val animalMachine: VendingMachine<Animal> = catMachine

    val animal: Animal = animalMachine.get() // 拿出来绝对是动物
    val cat: Cat = catMachine.get() // 不可能拿到狗
}

记住一点:out 对应生产者,只生产(输出)数据,子类的生产者可以替代父类的生产者。

逆变 (in) ------ "只进不出"的垃圾桶

逆变则与协变完全相反:如果 T 只在函数的参数类型中出现,我们就称 T 处于 in 位置。

你可以把它想象成一个垃圾桶(消费者)

如果手上有一把废纸,可以扔到专门用来扔废纸的废纸篓,也完全可以扔到可以接收任何垃圾的垃圾桶里。因为能处理所有垃圾的垃圾桶,肯定也能装废纸。

在 Kotlin 中,我们在泛型前加上 in 关键字来声明逆变:

kotlin 复制代码
open class Trash(val name: String)
class Paper(name: String) : Trash(name)

// in 约束了 T 只能用于函数参数,这就是逆变
interface GarbageCan<in T> {
    fun put(item: T)
}

fun main() {
    // 综合垃圾桶
    val generalCan = object : GarbageCan<Trash> {
        override fun put(item: Trash) {
            println("接收了一块垃圾 [${item.name}]")
        }
    }

    // 逆变使得 GarbageCan<Trash> 成为了 GarbageCan<Paper> 的子类型
    throwPaper(generalCan)
}

// 这个方法需要一个废纸篓,我们实际传入了一个综合垃圾桶
fun throwPaper(can: GarbageCan<Paper>) {
    can.put(Paper("旧报纸"))
}

Kotlin 标准库中逆变最经典的例子就是 Comparable<in T> 接口:一个能比较任何 Trash 垃圾体积大小的逻辑,也一定能完全用来比较 Paper 废纸。

为什么使用 in 修饰的泛型,不允许作为返回值呢?假设编译器允许我们这样做,会发生什么:

kotlin 复制代码
// 增加一种新垃圾:碎玻璃
class Glass(name: String) : Trash(name)

class BadGarbageCan<in T> {
    private var item: Any? = null

    // 扔垃圾(接收数据)
    fun put(item: T) {
        this.item = item
    }

    // 往外拿数据
    @Suppress("UNCHECKED_CAST")
    fun get(): T = item as T
}

fun main() {
    // 往垃圾桶扔玻璃,完全没问题
    val generalCan = BadGarbageCan<Trash>()
    generalCan.put(Glass("碎玻璃"))

    // 逆变
    val paperCan: BadGarbageCan<Paper> = generalCan

    // 当我们期望从废纸篓里拿出一张废纸时,实际上它底层是垃圾桶,里面还藏着一块碎玻璃
    val paper: Paper = paperCan.get() // ClassCastException 玻璃不能强转成废纸
}

同样,因为读取时无法保证拿到的是什么类型的垃圾,所以 in 被限制为只能消费,不能产出。

记住一点:in 对应消费者,只消费(接收)数据。父类的消费者可以替代子类的消费者。

强行突破型变限制:@UnsafeVariance

前面我们说了:out 只能作为返回值,in 只能作为参数。但有时,当我们能保证类型转换绝对安全时,可以加上 @UnsafeVariance 注解,来抑制编译器的报错。

比如标准库中的 List,虽然泛型 E 声明为了协变,但它还是出现在了函数参数:

kotlin 复制代码
actual override fun contains(element: @UnsafeVariance E): Boolean

这是因为该方法并不会修改集合内容,只是会读取比较,所以不用担心类型转换安全。

注意:虽然 @UnsafeVariance 注解强行突破了协变和逆变的规则,但同时也产生了风险。如果用它来遮盖了真实的写入操作,仍然会出现上述的类型转换异常。

声明处型变 vs 使用处型变

像上面这样,在定义类或接口时就写好了 inout,叫做声明处型变

有些类(如 MutableList)既要读又要写,无法在定义时指定型变。在函数参数上加 in/out,就叫做使用处型变:

kotlin 复制代码
// from 被限制为只读,to 被限制为只写
fun copyAnimals(from: MutableList<out Animal>, to: MutableList<in Animal>) {
    for (item in from) {
        to.add(item)
    }
}

到底什么时候该用 in 和 out?

逆变和协变在真实开发中,应该怎么应用?

  • 当你设计了一个数据结构,它的作用是向外提供数据,这时就可以用 out。就比如 Kotlin 的集合分为了只读的 List 和可读写的 MutableList,其中 List 就被声明为了协变。

  • 用于处理、消费数据的类,这时可以用 in,比如回调接口、比较器。一个能比较 Animal 所有属性的比较器,完全可以去比对 Cat 列表。

相关推荐
alexhilton5 小时前
架构测试为何需要双视图
android·kotlin·android jetpack
事圆则缓5 小时前
Jetpack Compose 快速上手
android·kotlin
事圆则缓5 小时前
MVC、MVP、MVVM、MVI 的区别与实现原理
android·kotlin·mvc
事圆则缓6 小时前
Android组件化指南
android·kotlin
事圆则缓8 小时前
Jetpack Compose Effect 完全指南
android·kotlin
我命由我123451 天前
Android Jetpack Compose 开发问题:Unresolved reference ‘Icons‘.
android·java-ee·kotlin·android studio·android jetpack·android-studio·android runtime
坚果的博客1 天前
CPF-KMP-CMP:在鸿蒙上用 Kotlin 写跨平台应用
华为·kotlin·harmonyos
事圆则缓1 天前
Kotlin 高阶工程化与 Android 深入实践
android·开发语言·kotlin
pengyu2 天前
【Kotlin 协程修仙录 · 渡劫境 · 中阶】 | 造化神兵:自定义 CoroutineDispatcher 与调度器的终极定制
android·kotlin