型变(variance)是 Kotlin 泛型里最容易"用过但说不清"的一题。它的核心只有一条规则——型变位置约束:out 类型只出现在返回位,in 类型只出现在参数位——但从这一条能推导出协变/逆变哪些签名合法、哪些非法,以及为什么 List<out T> 不能接受 add。答得全的人,都是从这条规则推出来的。
先把结论放在前面:Kotlin 用声明处型变(Java 用使用处型变,即通配符 ? extends / ? super)。out T 表示协变——List<Sub> 是 List<Super> 的子类型,T 只出现在返回位置;in T 表示逆变——Consumer<Super> 可以传给 Consumer<Sub>,T 只出现在参数位置。in + out 同时标注(in out T)在 Kotlin 里是声明不变但两次投影的变体,含义是"写时投影、读时投影",用在既读又写的场景(比如 MutableList<in out T> 实际等价于 MutableList<T>)。位置推断:有 in 的是逆变容器,有 out 的是协变容器,in out 是固定容器。
机制拆解
先看类型安全的根基——泛型参数在类型系统里是"不变"(invariant)的。Java 里 List<String> 与 List<Object> 完全无关,不能互相赋值。Kotlin 默认也是不变(MutableList<String> 不是 MutableList<Any> 的子类型)。型变是让程序员显式声明"这个泛型参数在这几个位置上应该允许子类型替换",编译器据此做类型检查。
out 的合法性来自"只读不写"这个事实:如果 T 只出现在返回位,那么 List<Sub> 里的元素被读出来当作 Super 使用是安全的(子类就是父类);但如果允许写入(add),把一个 Super 加进原本声明为 Sub 的容器就是错的。所以 List<out T> 在 Kotlin 里只有只读操作——get、size、iterator、contains 可用,add/remove 根本不存在(编译器不生成这些方法)。这是 Kotlin 相对 Java 更严格的地方:Java 的 List<? extends Number> 仍能调用 add(只是语义危险),Kotlin 直接不给你这个方法。
in 的合法性对称:如果 T 只出现在参数位,那么把 Consumer<Super> 传给期望 Consumer<Sub> 的地方是安全的(消费 Super 的函数当然能消费 Sub)。所以 Comparable<in T> 能接收任何 T 的比较器——compare(a: T, b: T) 是参数位。
in out 的存在是为了"读写都要但分别投影"的场景,典型是 Kotlin 的 MutableList<in out T>。它的意义是:MutableList<in Nothing> 可以当作 MutableList<String> 读(Nothing 是所有类型的子类型,投影后返回 Nothing 向下兼容),MutableList<out Any?> 可以当 MutableList<String> 写。在协程与序列化库里能看到这种写法。真实项目里更常见的是 MutableList<in Number> 这类"只写入特定子类型"的用法。
这些坑的正确绕法
最常见的坑是把 out 类型参数同时用在参数位,编译报错后乱加 @Suppress 掩盖设计问题。 表现是给一个方法写了 fun <T> setItem(item: T, index: Int),然后把类声明成 class Box<out T>,编译报 "Type parameter T is declared as 'out' but occurs in 'in' position"。如果加 @Suppress("UNCHECKED_CAST") 或去掉 out,类型安全就丢了:某处向 Box<Sub> 传了个 Super 实例,运行期 get 出来的 Sub 实际是 Super,ClassCastException。修法是回头改设计:容器本身只读就不标 out;若既要写又要保证读的类型,就分成"写入方法用 in"与"读取方法用 out"两个方法;最稳的是不变容器 + 在参数位用 in(fun <T> putAll(from: Collection<in T>))。
其次是把 List<T> 直接当 List<Any> 用(期望协变但没声明),整条链路被迫加 unchecked cast。 表现是工具函数里写 (list as List<Any>).firstOrNull() as? T,四周都是类型不安全的味道。根因是默认不变。修法是声明处标注 out T(只读场景)或用带投影的参数 Collection<in T>(只消费场景),让类型系统替你说清意图。Android 上典型场景是"把一个 List<Item> 传给只读的工具函数"——如果工具函数签名写成 fun <T> firstOf(list: List<T>),那它其实只需要读,写成 fun <T> firstOf(list: List<out T>) 语义更准确。
还有一个更隐蔽的坑:泛型型变与 Java 互操作时,List<? extends T> 的位置搞错导致运行时崩溃。 表现是 Kotlin 侧传 MutableList<Sub> 给一个 Java 方法 void addAll(Collection<? extends Super> c),编译通过但运行抛 ClassCastException。原因是 Java 的通配符在字节码里是"签名擦除 + Signature 属性",Kotlin 侧检查了但擦除后仍需运行期保证。修法是跨语言边界用不变容器(Collection<Super>),或者在 Kotlin 侧显式 copy 一份(list.toMutableList())。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 1) out:T 只在返回位 —— 只读容器
class Producer<out T>(private val item: T) {
fun get(): T = item // ✅ T 在返回位
fun getOrNull(): T? = item // ✅
// fun add(x: T) {
} // ❌ T 在参数位,编译报错
}
val p: Producer<Any> = Producer<String>("s") // ✅ 协变赋值
// val q: Producer<String> = Producer<Any>(1) // ❌ 反向不行
// 2) in:T 只在参数位 —— 只消费容器
class Consumer<in T> {
fun accept(item: T) {
Log.d("t", "$item") } // ✅ T 在参数位
// fun last(): T = null // ❌ T 在返回位,编译报错
}
val c: Consumer<Int> = Consumer<Any>() // ✅ 逆变赋值
// 3) 投影修饰:只声明用得到的那一侧
fun <T> countFirst(items: List<out T>): Int? = items.size // 只读 → out
fun <T> append(target: MutableList<in T>, item: T) {
target.add(item) } // 只写 → in
fun <T> firstOf(items: List<T>): T? = items.firstOrNull() // 消费任意 T,in 更准
fun <T> sameSize(a: Collection<out T>, b: Collection<*>): Boolean = a.size == b.size
// 4) in out:读写分别投影
class Buffer<in out T> {
fun put(x: T) {
} // in 位置
fun get(): Any? = null // out 位置(T 擦成 Any)
}
// 实际用法:只写不读
val buf = Buffer<Number>(); buf.put(1)
// MutableList<in out T> 等价于 MutableList<T>(in out 同标 = 不变)
// 5) 协变接口的经典例子
interface Source<out T> {
fun next(): T }
fun drain(s: Source<Any>) {
repeat(3) {
s.next() } } // Source<String> 可传入
// Comparable 是 in:a.compareTo(b) 是参数位
fun <T : Comparable<T>> maxOf(a: T, b: T): T = if (a > b) a else b
val nums = listOf(3, 9, 1)
val top = nums.maxOfOrNull {
it } // T=Int,Comparable<Int> 逆变接收比较器
// 6) Java 互操作:通配符 vs 投影
// Java: void addAll(Collection<? extends Super> c)
// Kotlin 侧安全写法:传入不变容器或明确投影
fun javaInterop(list: MutableList<out Any>) {
/* 只读消费,安全 */ }
fun javaInteropSafe(list: MutableList<Any>) {
/* 不变容器,语义最清晰 */ }
这段代码值得盯三处:第一处,第 1、2 段用注释明确标出 // ❌ 的两处非法位置(add 与 last),让"位置约束"这条规则直接可见;第二处,第 3 段把"只读用 out / 只写用 in"两种投影修饰并列,给出可执行的选型规则;第三处,第 4 段用注释说明 in out 同标等价于不变,防止读者误以为它是"双向协变"。
这题在面试里怎么问、怎么答
"Kotlin 用声明处型变,Java 用使用处型变,哪个更好?"答:声明处型变的优势是一次声明、全局生效——List<out T> 声明一次,所有接收 List<T> 的函数自动都接受协变用法,不需要每个调用点写通配符;而且 Kotlin 的 out 在编译期就不生成修改方法,从根上杜绝了"传 List<Animal> 却塞进 Dog"这类错误。Java 的通配符更灵活(不写 ? extends 就不变),但容易在代码库里退化成"到处 ? extends"。Kotlin 的选择更保守也更安全。
"为什么 out T 的类不能有 add 方法?"答:因为 add 让 T 出现在参数位,违反 out 的位置约束。Kotlin 编译器在声明时就拒绝生成这样的方法,而不是像 Java 那样允许你写出语义危险的操作。这是 Kotlin 相对 Java 的一处类型安全提升。
"in out T 的实际意义是什么?"答:写操作按 in 投影(能接受 T 的任意子类型实例),读操作按 out 投影(读出来当 Any?)。它适用于"往容器里塞不同子类型、只按基类或 Any 读出来"的场景。Kotlin 的 MutableList<in out T> 语义上等价于 MutableList<T>(不变),写法上常见于泛型封装的库代码。
"使用中遇到过什么问题?"案例一:一个只读工具函数写 fun <T> firstOf(list: List<T>),导致传 List<String> 时想当 List<Any> 用必须 unchecked cast;修复为把参数改成 List<out T>。案例二:给 Cache<out T> 加了一个 put(key: String, value: T) 方法,编译报错后有人去掉了 out,结果某处向 Cache<Sub> 传了 Super,运行期 ClassCastException;修复为把 put 移到另一个 in 的接口里。
再补一个工程上值得讲清的一点:在协程与流式 API 里,型变直接决定了 API 的可用性。 协程的 Deferred<out T>、Flow<out T> 都是协变的——因为它们只产出不消费;Continuation<in T> 是逆变的——因为它接收结果。这些声明不是随手加的,而是流式 API 能否组合的关键:Flow<Dog> 可以传给期望 Flow<Animal> 的函数,所以不同类型的流能在同一套操作符(map/filter/combine)里自由组合。写自定义流式 API 时,如果把返回类型声明成不变,调用方就要到处加投影或 copy,API 会显得很难用。Android 项目里做 Repository 层抽象时,接口的泛型参数该用 out 还是 in 直接决定了调用端的顺畅度。 这是型变在实际工程里最值钱的一处。
给正在准备面试的你
把这题画成一张"位置约束表":横轴是四种位置(返回类型、方法参数、属性类型、可变位置 out T 字段),纵轴是两种型变(out 标注 / in 标注)。在"标注 out"的行里,把"返回类型"格打勾(合法),把"方法参数"格打叉(非法),并画一条大字标"越界即编译报错"。第二行反之。图右侧再画一个"选型速查"三行清单:只读返回用 out、只写入用 in、读写都要就别标(或 in out)。这张图覆盖了本篇所有考点。
再补工程案例与踩坑——应用落点是审查项目里所有泛型接口,标注"是否只读/只写"并补上 out/in;把为了绕过报错而加的 @Suppress("UNCHECKED_CAST") 找出来改成正确的投影;给协程/流式接口确认协变声明;跨 Java 边界的地方用不变容器显式表达。
复习时别孤立刷题:函数类型与 typealias——(T) -> R 里的 T 在参数位、R 在返回位,函数类型天生就是"参数逆变、返回协变"的组合,这是型变最自然的应用场景。
划两句重点:out 只在返回位、in 只在参数位,越界编译报错;Kotlin 的 out 类根本不生成修改方法(比 Java 更安全);in out 同标等价于不变;投影修饰只在声明的位置生效;协程的 Flow/Deferred 协变、Continuation 逆变是 API 可组合的前提。
下一篇聊函数类型与 typealias:回调接口的新写法——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:类委托-by:装饰器模式的一行实现
下一篇预告:函数类型与-typealias:回调接口的新写法
有任何问题欢迎在评论区留言交流。