Kotlin泛型的优势?

170 阅读6分钟

续接上文,你已经学会了 Java 中的泛型。我在学习 Kotlin 时发现,它的泛型设计比 Java 更优,但这些差异你都清楚吗?本文将围绕「声明处变型」「new T()」和「类型擦除」三大核心问题,带你深入理解 Kotlin 泛型的真正优势。


Kotlin 声明处变型

在 Java 中,泛型默认是不变的(invariant):即使 StringObject 的子类型,List<String> 也不是 List<Object> 的子类型。

// Java 代码
List<String> strings = Arrays.asList("Hello", "World");
List<Object> objects = strings; // 编译错误!

这很严格,但也很安全——防止你在 List<Object> 中误插入非 String 类型,破坏类型一致性。

但如果你只想读取数据(不修改),你其实希望 List<String> 能当作“只读的 List<Object>”来用。为此,Java 提供了 使用处变型(use-site variance)

List<? extends Object> objects = strings; //  编译通过
Object o = objects.get(0); // 可以读
// objects.add("x");       //  不允许写入(类型不安全)

这里的 ? extends Object 是一个“通配符”,它告诉编译器:“我接受任何 Object 子类型的列表”,从而实现协变。但这种写法必须在每次使用时显式声明,啰嗦且重复。


Kotlin 的优雅解法:声明处变型(Declaration-site Variance)

Kotlin 换了个思路:在定义泛型类时就声明它是否支持协变或逆变,而不是每次使用都写一遍。

看 Kotlin 中 List 的定义:

public interface List<out E> : Collection<E>

关键就在 out E —— 它表示:

E 只作为输出类型(如返回值),不作为输入(如参数)”,因此可以安全协变。”

这意味着,只要 StringAny 的子类型(Kotlin 中的 ObjectAny),List<String> 就天然可以赋值给 List<Any>

val strings: List<String> = listOf("Hello", "World")
val anyList: List<Any> = strings // 编译通过!
val first: Any = anyList.first() // 安全读取
// anyList.add("x") //  编译错误:List 是只读接口

这就是“声明处变型”的威力:一次声明,处处受益。


为什么可以这样?类型安全如何保证?

Kotlin 编译器会检查:所有带 out 的类型参数,只能出现在“输出位置”

比如 List<out E> 中:

interface List<out E> {
    operator fun get(index: Int): E  //  允许:E 是返回值(输出)
    // fun add(element: E): Boolean //  编译错误:E 是参数(输入)
}

因为 E 只能“出”,不能“进”,所以从 List<String>List<Any> 的转换是类型安全的——你只能读,不能写。

这正是协变的核心前提:只读容器可以协变


逆变呢?Kotlin 也支持!

Kotlin 还提供了 in 关键字,用于逆变(contravariance) ,适用于“只写”场景。

例如,一个“消费者”接口:

interface Consumer<in T> {
    fun consume(item: T) // T 只作为输入
}

fun processStrings(consumer: Consumer<String>) {
    consumer.consume("Hello")
}

val objectConsumer: Consumer<Any> = object : Consumer<Any> {
    override fun consume(item: Any) { println(item) }
}

// 可以传给需要 Consumer<String> 的函数
processStrings(objectConsumer) // 因为 Consumer<Any) 是 Consumer<String> 的父类型(逆变)

in T 表示:T 只作为输入,因此可以安全地接受更“宽泛”的类型。


小结

  • Java 的使用处变型灵活但啰嗦,适合已有 API 的临时适配。
  • Kotlin 的声明处变型更符合“设计即契约”的理念,让泛型更自然、更安全。
  • out 用于生产者(只读),in 用于消费者(只写),不变用于可读可写。

理解这一点,你就掌握了 Kotlin 泛型设计的精髓。


Kotlin 如何实现 new T()?—— 实化类型参数(Reified Generics)

在 Java 中,你一定遇到过这样的无奈:

// Java 代码
public <T> T create() {
    // return new T(); //  编译错误!泛型被擦除,无法实例化
}

为什么不行?因为 类型擦除(Type Erasure)

回顾:Java 的类型擦除

Java 的泛型只存在于编译期,运行时所有泛型信息都会被“擦除”:

List<String> 和 List<Integer>
// 在运行时都变成了 List(原始类型)

所以 new T() 在运行时等价于 new Object(),失去了类型意义,因此编译器直接禁止。

Java 的“补救”方案是:手动传入 Class<T> 对象

public <T> T create(Class<T> clazz) throws Exception {
    return clazz.newInstance(); // 通过反射创建
}

虽然能用,但调用方必须多传一个 .class,不够优雅。


Kotlin 的突破:reified 关键字

Kotlin 提供了一种优雅的解决方案:实化类型参数(reified type parameters)

inline fun <reified T> create(): T {
    return T::class.java.newInstance() // 编译通过!
}

// 使用
val string = create<String>() // 直接指定类型,无需传 .class

看起来像魔法?其实原理很清晰。


原理:inline + 编译期展开

reified 只能用于 inline 函数inline 的作用是:在调用处直接展开函数体,而不是生成方法调用。

所以:

val string = create<String>()

在编译后等价于:

val string = String::class.java.newInstance()

T具体类型 String 替换,自然可以获取 String.class 并实例化。

Kotlin 并没有打破 JVM 的类型擦除规则,而是利用 inline 在编译期“绕过”了它。


实际应用:Gson 解析再也不用传 .class

还记得 Java 中解析 JSON 的写法吗?

User user = new Gson().fromJson(json, User.class); // 必须传 .class

在 Kotlin 中,你可以写一个通用的扩展函数:

inline fun <reified T> Gson.fromJson(json: String): T {
    return fromJson(json, T::class.java)
}

// 使用
val user = Gson().fromJson<User>(json) // 类型清晰,代码简洁
val list = Gson().fromJson<List<String>>(json) // 甚至支持泛型嵌套!

注意:对于 List<String> 这种带泛型的类型,Gson 仍需 TypeToken,但 Kotlin 配合 inlinereified 能更优雅地封装。


小结

  • Java 泛型受限于类型擦除,无法直接 new T(),必须依赖 Class<T>
  • Kotlin 通过 inline + reified,在编译期将泛型实化,让 T::class 成为可能。
  • 这不是“打破规则”,而是巧妙利用语言特性,在编译期完成类型还原
  • 结果是:API 更简洁、类型更安全、开发体验大幅提升。

真相只有一个:Kotlin 的类型擦除到底是什么?

1. 核心事实:Kotlin 和 Java 一样,泛型在运行时被擦除

是的,Kotlin 也运行在 JVM 上,它的泛型信息在编译成字节码后,同样会被擦除。

我们来看一个例子:

class Box<T>(val value: T)

fun main() {
    val stringBox = Box("hello")
    val intBox = Box(42)

    println(stringBox.javaClass.genericSuperclass) // null(原始类型)
    println(intBox.javaClass.name)                 // Box
}

输出:

null
Box

你会发现:

  • stringBox 和 intBox 的运行时类名都是 Box
  • 没有 Box<String> 或 Box<Int> 的区分。

这和 Java 完全一致 —— 泛型信息在运行时不存在了


2. 那为什么 reified 能拿到 T::class

这是最关键的误解点。很多人以为 reified “恢复了”类型信息,其实不然。

真相:reified 不是在运行时恢复类型,而是在编译期把 T 替换成具体类型

当你调用:

printType<String>()
printType<Int>()

由于 inline,编译器会把函数体直接展开

// 编译后等价于:
println(String::class)
println(Int::class)

T 已经被替换成了 StringInt,所以 T::class 实际上是 String::class —— 一个具体的类引用,根本不需要运行时泛型信息。

所以 reified 不是“绕过”类型擦除,而是在擦除发生前,就把泛型“填充”为具体类型


3. Kotlin 保留了哪些“额外信息”?元数据(Metadata)

虽然运行时类型被擦除,但 Kotlin 编译器会在 .class 文件中额外写入元数据,供 Kotlin 编译器自己读取。

这些元数据包括:

  • 函数参数的泛型类型(如 List<String>
  • 属性的泛型类型
  • @JvmField@JvmName 等注解信息

但这只对 Kotlin 编译器有效,Java 代码无法直接使用。


小结:Kotlin 的“类型擦除”真相

Kotlin 没有打破 JVM 的类型擦除规则,但它通过:

  • inline + reified(编译期填充)
  • 声明处变型(out/in
  • 额外的编译器元数据

让开发者可以像拥有完整泛型一样编程,而无需手动处理 .class、通配符等繁琐细节。