泛型与型变:in、out、reified 的真相
「in 和 out 到底哪个是哪个」—— 这个问题你大概每隔几个月要重新查一次。原因是大部分人是背下来的,而背的东西会忘。这一章不让你背:型变只有一条原理,推一遍就再也丢不了。然后我们处理 JVM 泛型的原罪 —— 类型擦除,以及 Kotlin 用 reified 完成的那次漂亮越狱(这是本书里 Kotlin 对 Java 最彻底的一次胜利)。
问题从哪来
Dog 是 Animal 的子类。那么 Box<Dog> 是不是 Box<Animal> 的子类?
直觉说「当然是」。但直觉是错的 —— 至少不总是对。看这段致命代码(假设允许协变):
val dogs: MutableList<Dog> = mutableListOf(Dog()) val animals: MutableList<Animal> = dogs // 假设编译器放行 animals.add(Cat()) // 完全合法:Cat 是 Animal val dog: Dog = dogs[1] // 💥 从狗列表里取出一只猫
协变 + 可写 = 类型系统被击穿。所以 Java 和 Kotlin 都默认让泛型不变(invariant):Box<Dog> 和 Box<Animal> 毫无关系,安全但笨拙。
型变机制就是用来在安全的前提下把这个限制松开的。
一条原理
危险来自「写入」,不来自「读取」。
- 一个只往外生产 T 的容器(只读),协变是安全的 —— 从「只会交出狗的盒子」里拿出来的,当然都是动物。标记
out T。 - 一个只往里消费 T 的容器(只写),逆变是安全的 —— 一个「什么动物都能喂」的容器,当然也能当作「专门喂狗的容器」来用。标记
in T。 - 又读又写的,只能不变。
助记:out = 输出 = 生产者 = 协变;in = 输入 = 消费者 = 逆变。名字本身就是答案 —— out 的意思就是「T 只出现在输出位置」。
这就是为什么 List<out E> 能协变而 MutableList<E> 不能:前者只有 get,后者还有 add。也是为什么 Comparator<in T>、Flow<out T>、Deferred<out T> 是现在这个样子 —— 每一个都在标准库里,每一个都符合这条原理。
下面这台机器让你亲手违反它,看编译器怎么骂人。三个型变标注 × 三种成员用法,九种组合,自己点一遍比读十遍规则有用:
声明处型变 vs 使用处型变
Kotlin 有两种打型变标记的地方,这是它和 Java 最大的分歧点。
声明处(Kotlin 的主场)
interface Producer<out T> { // 在类定义时一次性声明
fun produce(): T
}
val p: Producer<Animal> = dogProducer // 所有使用点自动享受协变
声明一次,所有调用点受益。这是更好的设计 —— 型变是类型本身的性质(List 天生就只生产),本来就该写在定义里。
使用处(类型投影)
但有时你控制不了类的定义(比如 MutableList 就是不变的),而某个函数确实只读它:
fun copy(from: MutableList<out Any>, to: MutableList<Any>) {
// from 被「投影」成只读:在这个函数里,你不能对它 add
for (item in from) to.add(item)
// from.add(1) ✗ 编译错误:投影之后 add 被禁用了
}
copy(mutableListOf("a", "b"), mutableListOf()) // ✓ 可以传 MutableList 进来了
MutableList<out Any> 的意思是:「在这里,我只把它当生产者用。」编译器于是禁掉它所有接收 T 的方法,作为交换,允许你传子类型进来。这就是 Java 的 ? extends 换了个写法(PECS:Producer-Extends, Consumer-Super)。
Java 的泛型没有声明处型变。List<T> 在定义里说不了「我是协变的」,于是每一个使用点都要重复写 ? extends T:
void feed(List<? extends Animal> animals) { } // 每个方法都要写一遍
void addAll(Collection<? super T> c) { } // 这行签名你读了几遍才懂?
Java 的通配符是会传染的:一旦某个签名里出现 ? extends,调用它的方法往往也得跟着写通配符,最后满屏都是问号。这是 Java 泛型公认最劝退的部分。
Kotlin 的选择:把型变的决定权交给类型的设计者(他最清楚这个类是生产者还是消费者),使用者什么都不用写。剩下的少数例外,才用类型投影补上。这是一次实实在在的语言设计胜利。
星投影 Box<*>
「我不关心 T 是什么,但我知道它是某个确定的类型」—— 这就是 *:
fun printSize(box: Box<*>) {
println(box.size) // ✓ 与 T 无关的成员,随便用
val item: Any? = box.get(0) // ✓ 读出来的东西,只能当 Any? 用
// box.add(anything) // ✗ 写入被完全禁止 —— 因为你不知道该写什么类型
}
* 不等于 Any:Box<Any> 是「一个能装任何东西的盒子」,而 Box<*> 是「一个装着某种特定东西的盒子,但我不知道是哪种」。所以前者能写,后者不能。
JVM 的原罪:类型擦除
运行时,List<String> 和 List<Int> 是同一个类型。泛型信息在编译后被擦得一干二净 —— 这是 JVM 为了兼容老代码付出的代价。
val a: List<String> = listOf("x")
val b: List<Int> = listOf(1)
a::class == b::class // true —— 运行时它们都只是 List
if (a is List<String>) { } // ✗ 编译错误:Cannot check for instance of erased type
if (a is List<*>) { } // ✓ 只能检查到这个程度
擦除带来三个后果,每一个都会咬人:
- 不能
is T/as T检查泛型参数。(as T能写,但那是个假的转换 —— 编译器只给个 unchecked 警告,真正的类型错误会推迟到你使用那个值的时候才炸,栈顶还离现场十万八千里。) - 不能
T::class。运行时 T 已经不存在了。 - 重载会撞车。
fun f(x: List<String>)和fun f(x: List<Int>)擦除后签名一模一样 —— 编译错误(这时候你需要@JvmName,见第 2 章)。
Java 的解法是那个著名的丑东西:把 Class<T> 当参数传进来,或者用 new TypeToken<List<User>>(){} 这种匿名子类黑魔法(利用「子类会保留父类的泛型实参」这个漏洞)。
越狱:reified
Kotlin 的解法优雅得多。既然 inline 函数会把函数体粘贴到调用点(第 9 章),那在那个位置上,T 是已知的具体类型 —— 编译器只要在粘贴时把 T 替换成真实类型就行了。
inline fun <reified T> Gson.parse(json: String): T =
fromJson(json, T::class.java) // T::class 居然能写!
val user: User = gson.parse(json) // 调用点展开成 fromJson(json, User.class)
这就是 reified(具体化):它没有修好 JVM 的擦除,它只是绕过了擦除 —— 靠在编译期把类型「烧」进每一个调用点。
代价是一条硬限制:reified 只能用在 inline 函数上。因为它依赖「函数体会被粘贴」这件事。你不能在类的泛型参数上 reify,也不能在非 inline 函数上用。
它带来的 API 好用到你可能已经忘了它的存在:
// 你天天在用的这些,全靠 reified:
val list = json.decodeFromString<List<User>>(s) // kotlinx.serialization
val vm: MyViewModel by viewModels() // Android KTX
startActivity<DetailActivity>() // 常见的自定义扩展
val ints = list.filterIsInstance<Int>() // 标准库
// 自己写一个:
inline fun <reified T : Activity> Context.start() =
startActivity(Intent(this, T::class.java))
reified T 拿到的是 T::class,也就是一个 KClass —— 而 KClass 本身是不带泛型实参的。所以:
inline fun <reified T> parse(s: String): T = gson.fromJson(s, T::class.java)
val users = parse<List<User>>(json) // T::class.java 只是 List.class
// 泛型实参 User 依然丢了 —— Gson 会给你一堆 LinkedTreeMap
这就是为什么 Gson 到今天还需要 TypeToken。真正的解法是换一个不依赖运行时反射的序列化库:kotlinx.serialization 用编译器插件在编译期为每个类生成序列化器,泛型信息在编译期就固定了,压根不需要运行时的类型信息 —— 这是「用编译器插件解决语言层面的限制」的典范(第 24 章会展开)。
结论:Android 项目里如果还在用 Gson/Moshi,迁移到 kotlinx.serialization 的收益不只是性能,还有类型安全。
两个实用细节
泛型约束:where
fun <T> sum(items: List<T>): T where T : Number, T : Comparable<T> { ... }
// 单个上界可以直接写:
fun <T : Any> notNull(x: T): T = x // T : Any ⇒ 排除掉可空类型
T : Any 这个约束值得单独记:泛型参数默认的上界是 Any?,也就是默认允许可空类型。你写的 class Cache<K, V>,别人可以拿它存 V = String?。想禁止就加 <V : Any>(第 3 章埋过这个雷)。
Java 互操作:那个你可能没见过的注解
Kotlin 编译成 Java 时,会自动为协变类型生成通配符:List<out T> → List<? extends T>。这通常是好事,但有时 Java 侧会因此变得难用。@JvmSuppressWildcards 让编译器别生成通配符,@JvmWildcard 强制生成。
这两个注解你可能一辈子只会在写 Java 要调的库时用到一次 —— 但当那一次到来时,知道它们存在能省你半天。
1. 打开你项目里的一个泛型类(Repository、Cache、UseCase 之类的基类),问三个问题:它的 T 出现在输出位置吗?输入位置吗?如果只在输出位置,把它标成 out T —— 你会发现调用点立刻多出一批本来就该合法的赋值。
2. 搜项目里的 as T(带 unchecked 警告的那种)。每一处都是一个「推迟到运行时的类型错误」。看看能不能用 reified 改掉。
3. 写一个自己的 reified 扩展函数,比如 inline fun <reified T> Bundle.getOrNull(key: String): T?,然后在项目里替换掉几处手写的类型转换。
4. 如果项目里还在用 Gson:找一处解析泛型集合的代码,看看它是不是靠 TypeToken 撑着。把它当成迁移到 kotlinx.serialization 的第一块试验田。
下一章:value class —— 一个「零开销」的承诺,和它那条很少有人说清的边界。