很多人记得“out是生产者,in是消费者”,但一遇到MutableList<Dog>、Array<Dog>或MutableList<*>就又要靠猜。方差真正解决的是一个安全问题:当子类型关系穿过泛型容器时,哪些赋值不会让我们读错或写错类型?
本文从最小反例开始,逐步讲清默认不变、协变、逆变、类型投影和 JVM 类型擦除。示例中的Dog、Cat都继承Animal。
一、为什么MutableList<Dog>不是MutableList<Animal>?
openclassAnimalclassDog:Animal()classCat:Animal()valdogs:MutableList<Dog>=mutableListOf(Dog())// val animals: MutableList<Animal> = dogs // 编译错误假设最后一行能通过,调用方就可以执行animals.add(Cat()),而原来的dogs列表里会出现一只猫。这破坏了MutableList<Dog>的承诺。Kotlin 的MutableList<T>既读又写,因此对T不变。
对比只读接口:
valdogView:List<Dog>=dogsvalanimalView:List<Animal>=dogView// 可以:只从中读取 AnimalList<out E>在类型声明上协变,因为它不会通过这个接口接收一个新E。但 Kotlin 的只读接口不保证底层集合物理上不可变;其他持有dogs的代码仍可能改它。
二、out T:我只负责产出 T
interfaceProducer<outT>{funnext():T}valdogProducer:Producer<Dog>=object:Producer<Dog>{overridefunnext():Dog=Dog()}valanimalProducer:Producer<Animal>=dogProducervalanimal:Animal=animalProducer.next()Dog是Animal的子类型,能产出Dog的对象当然也能被当成“产出Animal”使用。out限制类型参数主要出现在输出位置,换来Producer<Dog>可以安全赋给Producer<Animal>。
实际 API 中,读取缓存、查询只读数据和事件源都可能是“生产者”。如果一个类型还要接收T作为参数,就不能简单把整个类型标成out T;要重新审视接口职责,而不是用类型转换压掉编译器。
三、in T:我只负责消费 T
interfaceConsumer<inT>{funaccept(value:T)}valanimalConsumer:Consumer<Animal>=object:Consumer<Animal>{overridefunaccept(value:Animal){println(value)}}valdogConsumer: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>可以读也可以写,因此它是不变的。有时某个函数只需要从数组读,或只需要向数组写,可以在使用这个类型的地方表达限制:
fun<T>copyItems(from:Array<outT>,to:Array<inT>){require(from.size<=to.size)for(indexinfrom.indices){to[index]=from[index]}}valfrom:Array<Dog>=arrayOf(Dog())valto: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:
// fun <T> isValue(value: Any): Boolean = value is T // 编译错误inlinefun<reifiedT>isValue(value:Any):Boolean=valueisTprintln(isValue<Dog>(Dog()))// truereified依赖内联,把实际类型参数信息带到调用点,因此可以写is T、T::class等。但它不是“所有泛型都完全保留到运行时”:例如List<String>的元素类型通常仍不能靠一次简单的运行时检查证明。要验证集合内容,仍需逐个检查元素或借助携带完整类型信息的序列化机制。
不要在不能内联的普通函数里强行通过as T声称“类型安全”。未经检查的强转只会把问题延后到运行时。
六、Android 项目里怎样用这些规则设计接口?
考虑 Repository 对外只暴露读取结果:
interfaceReadOnlyStore<outT>{suspendfunload():T}interfaceWriter<inT>{suspendfunsave(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;双向流动,通常保持不变。
参考资料与延伸阅读
- Kotlin 官方:Generics
- Kotlin 官方:Inline functions
- 本系列:Kotlin 入门与面试