续接上文,你已经学会了 Java 中的泛型。我在学习 Kotlin 时发现,它的泛型设计比 Java 更优,但这些差异你都清楚吗?本文将围绕「声明处变型」「new T()」和「类型擦除」三大核心问题,带你深入理解 Kotlin 泛型的真正优势。
Kotlin 声明处变型
在 Java 中,泛型默认是不变的(invariant):即使 String 是 Object 的子类型,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只作为输出类型(如返回值),不作为输入(如参数)”,因此可以安全协变。”
这意味着,只要 String 是 Any 的子类型(Kotlin 中的 Object 是 Any),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 配合inline和reified能更优雅地封装。
小结
- 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 已经被替换成了 String 和 Int,所以 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、通配符等繁琐细节。