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

相关推荐
hnlgzb12 分钟前
安卓中,kotlin如何写app界面?
android·开发语言·kotlin
jzlhll12341 分钟前
deepseek kotlin flow快生产者和慢消费者解决策略
android·kotlin
wxson72821 小时前
【用androidx.camera拍摄景深合成照片】
kotlin·android jetpack·androidx
jzlhll1231 小时前
deepseek Kotlin Flow 全面详解
android·kotlin·flow
heeheeai2 小时前
kotlin图算法
算法·kotlin·图论
用户094 小时前
Android面试基础篇(一):基础架构与核心组件深度剖析
android·面试·kotlin
Kapaseker9 小时前
每个Kotlin开发者应该掌握的最佳实践,最后一趴
android·kotlin
alexhilton20 小时前
灵活、现代的Android应用架构:完整分步指南
android·kotlin·android jetpack
雨白1 天前
初识协程: 为什么需要它以及如何启动第一个协程
android·kotlin
heeheeai1 天前
Kotlinx Serialization 指南
kotlin·序列化