Kotlin 泛型:从类型擦除到实化、协变与逆变

前言

之前我们讲了 Kotlin 泛型的基本用法,也就是和 Java 中相同的部分。但 Kotlin 为泛型提供了一些特有的进阶功能,能提升代码的简洁性和安全性,我们一起来看看吧。

泛型实化

在了解泛型实化之前,我们先要理解 Java 的泛型擦除机制。

在 JDK 1.5 之前,Java 是没有泛型的,容器(如 ArrayList)可以存放任意类型的对象。当从容器取出对象后,需要手动进行类型转换。这不仅麻烦,还很危险。要是转成错误的类型,程序就会在运行时抛出类型转换异常 ClassCastException

JDK 1.5 中引入的泛型解决了这个问题,给我们带来了编译期的类型安全。

然而这种安全是通过类型擦除 机制实现的。就是泛型的类型约束只在编译时期存在,在运行时,还是按照之前的机制来运行,JVM 识别不出我们为泛型指定的具体类型。例如有 List<String>List<Integer>,JVM 会认为是同一个 List。所有基于 JVM 的语言,泛型都是通过类型擦除机制实现的,包括 Kotlin。

这使得我们不能使用像 a is TT::class.java 这样的写法,因为泛型 T 的具体类型在运行时已经被擦除了。

但 Kotlin 提供了内联函数。内联函数中的代码会在编译时被"复制"到调用处。编译器在内联时就会知道泛型的实际类型,这就意味着,Kotlin 中可以让泛型进行实化。

那该怎么实现呢?

首先,函数要声明为内联函数;其次在声明泛型时,需要在前面加上 reified 关键字,表示对泛型进行实化。

例如:

kotlin 复制代码
// reified 关键字告诉编译器,在编译时将 T 的具体类型固化到字节码中
inline fun <reified T> getGenericType(): Class<T> {
    return T::class.java 
}

上述的泛型 T 就是一个被实化的泛型。那么它能实现什么效果呢?其实从函数名就可以看出:获取泛型的具体类型。

测试一下:

kotlin 复制代码
fun main() {
    val type1 = getGenericType<String>()
    val type2 = getGenericType<Int>()
    println("type1 is $type1")
    println("type2 is class java.lang.Integer: ${type2 == java.lang.Integer::class.java}")
}

运行结果:

kotlin 复制代码
type1 is class java.lang.String
type2 is class java.lang.Integer: true

泛型实化的应用

在 Android 中,启动一个 Activity 需要传递 Context 和目标 Activity 的 Class 对象:

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

借助泛型实化,我们可以简化这种写法。

首先创建 reified.kt 文件,在其中定义一个 ContextstartActivity() 扩展函数:

kotlin 复制代码
inline fun <reified T> startActivity(context: Context) {
    val intent = Intent(context, T::class.java)
    context.startActivity(intent)
}

现在,我们要启动 TestActivity,只需要这样写:

kotlin 复制代码
// 在 Activity 或 Fragment 中
startActivity<TestActivity>(context)

Kotlin 能够识别出泛型的具体类型,并且启动对应的 Activity

另外,启动 Activity 通常会传递数据,我们可以使用高阶函数来完成。添加一个函数重载:

kotlin 复制代码
// 定义为 Context 的扩展函数,方便调用
inline fun <reified T : Activity> Context.startActivity(
    // 使用带接收者的 Lambda,可以直接在 block 中调用 Intent 的方法
    block: Intent.() -> Unit = {},
) {
    val intent = Intent(this, T::class.java)
    intent.block()
    this.startActivity(intent)
}

这样,就可以在启动 TestActivity 的同时传递数据了:

kotlin 复制代码
// 在 Activity 或 Fragment 中
startActivity<TestActivity>(context) {
    putExtra("param1", "data")
    putExtra("param2", 123)
}

型变:协变与逆变

Kotlin 泛型的另一个强大特性是型变 ,它描述了泛型类型之间的子类型关系。要理解型变,先要了解 inout 位置。

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

你也可以记为:out 对应生产者(Producer),只生产(输出)数据;in 对应消费者(Consumer),只消费(接收)数据。

知道了这个后,我们继续。

协变(out)

请你看看下面的这段代码:

kotlin 复制代码
open class Person(val name: String, val age: Int)
class Student(name: String, age: Int) : Person(name, age)
class Teacher(name: String, age: Int) : Person(name, age)

我们知道,如果一个接收 Person 类型参数的方法,我们可以传入 Student 对象。那么,如果一个接收 List<Person> 类型参数的方法,可以传入 List<Student> 对象吗?

在 Java 中是不行的,因为 List<Student> 并不是 List<Person> 的子类型,可能会导致类型安全问题。

假设可以的话,请看下面的例子:

kotlin 复制代码
class SimpleData<T> {
    private var data: T? = null

    fun set(t: T?) {
        data = t
    }

    fun get(): T? {
        return data
    }
}

fun main() {
    val student = Student("Tom", 19)
    val data = SimpleData<Student>()
    data.set(student)

    handleSimpleData(data) // 实际上这行代码会报错,但我们假设它能编译通过

    val studentData: Student? = data.get() 
}

fun handleSimpleData(data: SimpleData<Person>) {
    val teacher = Teacher("Jack", 35)
    data.set(teacher)
}

handleSimpleData() 方法中,我们将一个 Teacher 实例存放到了 SimpleData<Person> 对象的 data 属性中,这是完全没问题的。但在 main() 函数中,data 变量的类型是 SimpleData<Student>,我们调用 get() 方法期望获取它内部封装的 Student 对象,但实际上得到的却是一个 Teacher 对象,会导致 ClassCastException 类型转换异常。

为了杜绝这种因写入操作导致的类型安全问题,Kotlin 规定,如果泛型类是可读可写的,那么 SimpleData<Student>SimpleData<Person> 之间没有任何继承关系,SimpleData<Student> 并不是 SimpleData<Person> 的子类型。

不过,如果我们保证泛型类是只读的,就没有这种问题了。这就是协变的作用。

泛型协变的概念是:对于泛型类 MyClass<T>,如果 AB 的子类,同时 MyClass<A> 又是 MyClass<B> 的子类,那么 MyClass 在泛型 T 上是协变的。

如何实现呢?需要使用 out 关键字,并确保泛型 T 只能出现在 out 位置,在上述例子中,就是让 data 为只读属性。

我们来对 SimpleData 类进行改造:

kotlin 复制代码
class SimpleData<out T>(private val data: T?) {
    fun get(): T? {
        return data
    }
}

我们在泛型 T 的声明前加上了 out 关键字,并且将 data 属性设置了构造函数的只读参数,移除了 set() 方法。

kotlin 复制代码
fun main() {
    val student = Student("Tom", 19)
    val data = SimpleData<Student>(student)

    // 现在这行代码可以正常编译了
    handleMyData(data)
}

fun handleMyData(data: SimpleData<Person>) {
    // 因为 SimpleData 是只读的,所以我们能安全地取出数据
    val personData: Person? = data.get()
    println(personData?.name)
}

现在,能够向 handleMyData() 方法传递 SimpleData<Student> 对象了。

我们前面说过:"在 Java 中,如果某个方法接收一个 List<Person> 类型的参数,我们是不允许传入 List<Student> 实例的。"但在 Kotlin 中是允许的,你并没有猜错,因为 Kotlin 给很多内置的 API 加上了协变的声明,就比如 List<E> 接口。

我们来看看 List 的源码:

kotlin 复制代码
public interface List<out E> : Collection<E> {
    override val size: Int
    
    override fun isEmpty(): Boolean
    
    override fun contains(element: @UnsafeVariance E): Boolean
    
    override fun iterator(): Iterator<E>

    override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean

    public operator fun get(index: Int): E

    public fun indexOf(element: @UnsafeVariance E): Int

    public fun lastIndexOf(element: @UnsafeVariance E): Int

    public fun listIterator(): ListIterator<E>

    public fun listIterator(index: Int): ListIterator<E>

    public fun subList(fromIndex: Int, toIndex: Int): List<E>
}

你会发现,尽管 List 在泛型 E 上是协变的(使用了 out 关键字声明),但泛型 E 还是出现在了函数类型中(in 位置),例如 contains 方法。

这是因为这些方法并不会修改集合内容,所以这些操作是类型安全的。为了告诉编译器我们的用法是安全的,我们在泛型 E 前加上了 @UnsafeVariance 注解。

逆变(in)

逆变则与协变完全相反,其定义为:**对于泛型类 MyClass<T>,如果 AB 的子类,但 MyClass<B> 反而被看作 MyClass<A> 的子类,那么 MyClass 在泛型 T 上是逆变的。

你可能会觉得:MyClass<A>MyClass<B> 的子类型我能理解,但MyClass<B>MyClass<A> 的子类型又是怎么回事?别急,我们来看一个实际例子。

假设有一个 Transformer 转换器接口,用于将一个对象转为字符串。

kotlin 复制代码
interface Transformer<in T> {
    fun transform(t: T): String
}

其中的 in 关键字约束了 T 只能出现在 in 位置,并声明了 Transformer 在泛型 T 上是逆变的。我们来看下面的用法:

kotlin 复制代码
open class Person(val name: String, val age: Int)
class Student(name: String, age: Int) : Person(name, age)
class Teacher(name: String, age: Int) : Person(name, age)


fun main() {
    // 这个转换器可以处理任何 Person
    val personTransformer = object : Transformer<Person> {
        override fun transform(t: Person): String {
            return "Name: ${t.name}, Age: ${t.age}"
        }
    }
    
    handleStudent(personTransformer)
}

fun handleStudent(trans: Transformer<Student>) {
    val student = Student("Tom", 19)
    val result = trans.transform(student)
    println(result)
}

现在编译可正常通过,为什么 handleStudent() 方法可以接收 Transformer<Person> 类型的参数?

因为逆变,此时,Transformer<Person> 成为了 Transformer<Student> 的子类型。换一种说法,一个能处理 Person 类的转换器,也一定能处理其子类 Student

当然,你可以再思考一下,如果逆变允许泛型 T 出现在 out 位置,会发生什么安全问题?

修改 Transformer 接口,并使用 @UnsafeVariance 注解强行让它通过编译:

kotlin 复制代码
interface Transformer<in T> {
    fun transform(name: String, age: Int): @UnsafeVariance T
}

然后是示例代码:

kotlin 复制代码
fun main() {
    // 实现转换器,返回一个 Teacher 实例
    val trans = object : Transformer<Person> {
        override fun transform(name: String, age: Int): Person {
            return Teacher(name, age)
        }
    }
    handleTransformer(trans)
}

fun handleTransformer(trans: Transformer<Student>) {
    // 希望获得一个 Student 实例
    val result: Student = trans.transform("Tom", 19)
}

在上述代码中,handleTransformer() 方法期望得到的是一个 Student 对象,但 trans.transform() 方法实际返回的是一个 Teacher 实例,那么在赋值的时候,就会出现 ClassCastException 类型转换异常

Kotlin 在提供协变和逆变时,就已经把各种潜在的类型转换安全隐患全部考虑到了。只要我们严格按照其语法规则,让泛型在协变时只出现在 out 位置,逆变时只出现在 in 位置,就不会存在类型转换异常的风险。

虽然 @UnsafeVariance 注解可以强行突破这个规则,但同时也产生了风险。

最后介绍一下逆变的应用场景,比如用于比较大小的 Comparable 接口,其源码如下:

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

ComparableT 这个泛型上就是逆变的,因为一个能和 Person 比较大小的逻辑,也一定能用来比较 Student

相关推荐
Devil枫2 小时前
Kotlin扩展函数与属性
开发语言·python·kotlin
菠萝加点糖2 小时前
Kotlin Data包含ByteArray类型
android·开发语言·kotlin
续天续地10 天前
开箱即用的Kotlin Multiplatform 跨平台开发模板:覆盖网络/存储/UI/DI/CI工具链
ios·kotlin
移动开发者1号10 天前
Android使用Zip4j实现加密压缩
android·kotlin
移动开发者1号10 天前
解析 MMKV:高性能 KV 存储原理与实战指南
android·kotlin
Kapaseker11 天前
Jetpack Compose的副作用一览
android·kotlin
移动开发者1号11 天前
Android数据库连接泄露检测:解析与实战
android·kotlin
移动开发者1号11 天前
SQLite FTS4全文搜索实战指南:从入门到优化
android·kotlin
androidwork12 天前
Kotlinx序列化多平台兼容性详解
android·java·kotlin