Kotlin 泛型方差实战:out、in、星投影与类型擦除

很多人记得"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;双向流动,通常保持不变。

参考资料与延伸阅读

相关推荐
Code_Solitude1 小时前
c语言:二分法的完整补充
c语言·开发语言·算法
智鸟科技GemeOpen开发者智能设备1 小时前
MQTT智能通断器GSCW1M2P · 开发者实战指南
开发语言·物联网·智能家居
青柠之夏cc2 小时前
前端拖拽功能原生实现,不引入拖拽库完成业务
开发语言·前端·python
ym hyd 1112 小时前
题库建立与考试系统源码 Java+SpringBoot+Vue3 前后分离
java·开发语言·vue.js·spring boot·毕设
OKkankan2 小时前
Python 高阶语法(一):高阶函数、闭包、lambda 与 functools——从底层真正理解装饰器
开发语言·python
geovindu3 小时前
rust: Facade Pattern
开发语言·后端·设计模式·rust·外观模式
福大大架构师每日一题3 小时前
Rust 1.99.0发布:C 可变参数、裸函数、Cargo 配置、Rustdoc 性能与大量兼容性调整全解析
c语言·开发语言·rust
智鸟科技GemeOpen开发者智能设备3 小时前
平安校园 AI 智能实时告警系统 智鸟科技·GemeOpen + 谷华科技·融合创新方案白皮书
java·开发语言·python·物联网·智能家居
传奇开心果编程3 小时前
【Compose Multiplatform 跨端开发学与练】第5课 网络与数据层
android·网络·学习·ui·ios·kotlin·composer