在上一篇博客中,我们知道了泛型这个"万能魔法盒"的基础用法。现在我们接着深入,来讲解泛型的实化(reified) 和 型变(协变与逆变)。
别怕,我会带你理清这些看着很高端、很复杂的概念。
泛型实化:打破"类型擦除"的封印
在了解泛型实化之前,我们先来回顾一下 JVM 的一个历史遗留问题:类型擦除。
一开始 Java 没有泛型,容器可以存放任意类型的对象。如果要取用,需要手动进行强制类型转换。如果稍微没处理好,程序运行时就会抛出类型转换异常 ClassCastException。
而泛型是在 JDK 1.5 开始引入的。为了兼容旧版本代码,JVM 让泛型的类型约束只存在于编译期,为我们带来了编译期的类型安全。在运行时,JVM 完全识别不出我们为泛型指定的具体类型。
比如 List<String> 和 List<Int> 在运行时,JVM 会认为他俩是同一个类型(List)。因此你写出类似 a is T、T::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)
为什么我们需要型变?
"型变"描述的是泛型类型之间的子类型关系,这是什么意思?
假设有一个继承关系:Cat 和 Dog 都继承自 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 使用处型变
像上面这样,在定义类或接口时就写好了 in 或 out,叫做声明处型变。
有些类(如 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列表。