很多人记得"out 是生产者,in 是消费者",但一遇到 MutableList<Dog>、Array<Dog> 或 MutableList<*> 就又要靠猜。方差真正解决的是一个安全问题:当子类型关系穿过泛型容器时,哪些赋值不会让我们读错或写错类型?
本文从最小反例开始,逐步讲清默认不变、协变、逆变、类型投影和 JVM 类型擦除。示例中的 Dog、Cat 都继承 Animal。
一、为什么 MutableList<Dog> 不是 MutableList<Animal>?
kotlin
open class Animal
class Dog : Animal()
class Cat : Animal()
val dogs: MutableList<Dog> = mutableListOf(Dog())
// val animals: MutableList<Animal> = dogs // 编译错误
假设最后一行能通过,调用方就可以执行 animals.add(Cat()),而原来的 dogs 列表里会出现一只猫。这破坏了 MutableList<Dog> 的承诺。Kotlin 的 MutableList<T> 既读又写,因此对 T 不变。
对比只读接口:
kotlin
val dogView: List<Dog> = dogs
val animalView: List<Animal> = dogView // 可以:只从中读取 Animal
List<out E> 在类型声明上协变,因为它不会通过这个接口接收一个新 E。但 Kotlin 的只读接口不保证底层集合物理上不可变;其他持有 dogs 的代码仍可能改它。
二、out T:我只负责产出 T
kotlin
interface Producer<out T> {
fun next(): T
}
val dogProducer: Producer<Dog> = object : Producer<Dog> {
override fun next(): Dog = Dog()
}
val animalProducer: Producer<Animal> = dogProducer
val animal: Animal = animalProducer.next()
Dog 是 Animal 的子类型,能产出 Dog 的对象当然也能被当成"产出 Animal"使用。out 限制类型参数主要出现在输出位置,换来 Producer<Dog> 可以安全赋给 Producer<Animal>。
实际 API 中,读取缓存、查询只读数据和事件源都可能是"生产者"。如果一个类型还要接收 T 作为参数,就不能简单把整个类型标成 out T;要重新审视接口职责,而不是用类型转换压掉编译器。
三、in T:我只负责消费 T
kotlin
interface Consumer<in T> {
fun accept(value: T)
}
val animalConsumer: Consumer<Animal> = object : Consumer<Animal> {
override fun accept(value: Animal) { println(value) }
}
val dogConsumer: Consumer<Dog> = animalConsumer
dogConsumer.accept(Dog())
一个能处理任意 Animal 的消费者,自然也能处理 Dog。赋值方向和 out 正好相反:Consumer<Animal> 可以当作 Consumer<Dog>。排序比较器、回调参数接收器常体现这种思路。
可以把规则浓缩为:
| 声明 | 主要允许的方向 | 安全赋值示例 |
|---|---|---|
Producer<out T> |
向外给出 T |
Producer<Dog> → Producer<Animal> |
Consumer<in T> |
向内接收 T |
Consumer<Animal> → Consumer<Dog> |
MutableList<T> |
既读又写 | 默认不能沿继承关系直接赋值 |
四、类本身不变时,用使用处类型投影
Array<T> 可以读也可以写,因此它是不变的。有时某个函数只需要从数组读,或只需要向数组写,可以在使用这个类型的地方表达限制:
kotlin
fun <T> copyItems(from: Array<out T>, to: Array<in T>) {
require(from.size <= to.size)
for (index in from.indices) {
to[index] = from[index]
}
}
val from: Array<Dog> = arrayOf(Dog())
val to: Array<Animal> = arrayOf(Animal())
copyItems(from, to)
在这个函数里,from 被承诺为只提供 T,to 被承诺为可接收 T;因此不必把 Array<Dog> 整体伪装成 Array<Animal>。类型投影是在限制当前视角,不是改变原对象的真实类型。
* 星投影并非"完全没类型"
List<*> 表示元素具体类型未知,但可以安全地把读出的元素当成 Any?。对于 MutableList<*>,你不知道它实际存的是 Dog 还是 String,所以不能安全添加任意非空对象。星投影适合只做检查、遍历或转交的边界代码;进入业务层后,最好尽快恢复明确类型。
五、类型擦除与 reified 的边界
在 JVM 上,普通泛型类型参数大多会被擦除。下面的普通泛型函数不能直接判断 value is T:
kotlin
// fun <T> isValue(value: Any): Boolean = value is T // 编译错误
inline fun <reified T> isValue(value: Any): Boolean = value is T
println(isValue<Dog>(Dog())) // true
reified 依赖内联,把实际类型参数信息带到调用点,因此可以写 is T、T::class 等。但它不是"所有泛型都完全保留到运行时":例如 List<String> 的元素类型通常仍不能靠一次简单的运行时检查证明。要验证集合内容,仍需逐个检查元素或借助携带完整类型信息的序列化机制。
不要在不能内联的普通函数里强行通过 as T 声称"类型安全"。未经检查的强转只会把问题延后到运行时。
六、Android 项目里怎样用这些规则设计接口?
考虑 Repository 对外只暴露读取结果:
kotlin
interface ReadOnlyStore<out T> {
suspend fun load(): T
}
interface Writer<in T> {
suspend fun save(value: T)
}
职责分开后,方差方向一眼就能看懂,也方便替换实现。若一个 Repository 同时 load(): T 和 save(T),保持不变通常更诚实;不要为了让某个赋值编译通过就随意加 out 或 @UnsafeVariance。
Java SDK 边界还可能带来原始类型与平台类型。读到 List<*> 时,不要立刻把它断言成 List<User>;先解析或验证元素,再把明确的领域类型交给上层。泛型的目标是把错误尽量前移到编译期,而不是给强转换一套更复杂的写法。
七、面试高频问答
Q1:为什么 List<Dog> 可以赋给 List<Animal>,MutableList<Dog> 不行?
前者只读、声明为协变;后者可写,若允许赋值就可能把 Cat 写入狗列表。
Q2:out 和 in 分别意味着什么?
out 主要生产值,保留子类型到父类型的方向;in 主要消费值,赋值方向相反。
Q3:Array<Dog> 是 Array<Animal> 的子类型吗?
不是。Kotlin 的 Array<T> 既可读又可写,默认不变;可在函数参数处用 Array<out T> / Array<in T> 投影。
Q4:MutableList<*> 能不能 add(Dog())?
不能安全添加任意非空类型,因为实际元素类型未知。
Q5:reified 是否让 List<String> 的每个元素都自动可验证?
不是。内联能让某些类型检查在调用点成立,但嵌套泛型的元素信息仍需要额外验证。
Q6:什么时候不该为了协变拆 API?
当对象本来就同时读写同一种 T,维持不变更准确;设计应先表达真实能力,再考虑赋值便利。
记忆方差时,与其背"协变逆变"两个词,不如画一条数据流:数据从对象流出来,用 out;数据流进对象,用 in;双向流动,通常保持不变。