Kotlin 的泛型系统里,协变和逆变是绕不开的一道坎。我见过太多人死磕了一周,翻了几篇博客,最后还是在编译器报错面前一脸懵。其实这俩概念没有想象中那么玄乎,核心就一句话:协变让你能用子类型当父类型用(读出来安全),逆变让你能用父类型当子类型用(写进去安全)。这篇我把自己踩过的坑、理清的思路和实际项目里的用法整理成文,适合已经写过一段时间 Kotlin、但对泛型变异性(variance)只有模糊感觉的开发者。读完你会发现,那些奇奇怪怪的out、in并不是语法糖,而是 Kotlin 类型系统为了“类型安全”设计的一整套精妙机制。
1. 从一道编译报错说起:为什么需要协变和逆变
1.1 Java 数组的“历史包袱”与 Kotlin 的坚持
如果你从 Java 转过来,一定经历过这种困惑:Java 里String[]可以赋值给Object[],因为数组是协变的。但这个协变是运行时的——数组会在运行时检查类型,如果往Object[]里塞了一个非 String 元素,编译能过,运行直接ArrayStoreException。
Kotlin 直接把数组设计成了不变(invariant)的:Array<String>和Array<Any>之间没有任何子类型关系。这带来的第一个直观感受是:写 Kotlin 时你很少遇到 Java 里那种“类型擦除 + 数组协变”的诡异运行时报错,编译器把问题提前拦在了编译期。
但问题来了:如果我们有一个函数fun printAll(items: List<Any>),想传入一个List<String>,在 Kotlin 里会直接报Type mismatch。这合理吗?从只读角度来说完全合理——因为函数内部只是遍历并打印,根本不会写入任何东西,List<String>里的元素本来就是Any的子类型,为什么不允许传?
这就是协变要解决的核心矛盾:“能当只读数据源使用的子类型集合,为什么不能被当作父类型的只读集合?”只读场景下,子类型集合完全具备父类型集合的所有读取能力。
1.2 类型安全的本质:读与写的分野
想理解协变和逆变,必须先把“类型安全”落到操作层面。一个泛型容器Box<T>,如果允许外部通过 API 往里面写入T的实例,那么当T的边界发生变化时,写入的类型约束必须锁死。如果允许读取T的实例,读取者拿到的是什么类型同样需要锁死。
一句话:协变锁读取方向,逆变锁写入方向,不变锁双向。
为什么这么设计?想象一个MutableList<Any>和MutableList<String>。如果允许前者被当作后者使用(即逆变),那么函数就能往“String 的列表”里塞进任意类型,取出来的地方还是按 String 处理,运行时会炸。如果允许前者被当作后者使用(即协变),那只是多了一个“只读”的视角,永远不会出问题。关键在于写入能力导致了“不安全”。所以 Kotlin 标准库干脆拆出了两个接口:List<out E>只读,协变;MutableList<E>可变,不变。
这个原则是理解后面所有细节的地基。你先记住:只读安全、写入危险。所有的out和in,都是在告诉编译器“我这个类型参数只会在安全的方向上被使用”。
2. 协变:out 关键字与 Producer 语义
2.1 声明处协变的正确姿势
给类型参数加上out关键字,就声明了这个类是协变的。但有个硬性约束:协变的类型参数只能出现在“输出”位置——返回值、只读属性、val的类型。一旦你写了一个接收该类型参数的方法参数,编译器直接红波浪线。
看一个最典型的例子,我们定义一个生产者接口:
interface Producer<out T> { fun produce(): T } class StringProducer : Producer<String> { override fun produce(): String = "hello" } fun showProduce(p: Producer<Any>) { println(p.produce()) } fun main() { val sp: Producer<String> = StringProducer() showProduce(sp) // 编译通过:Producer<String> 是 Producer<Any> 的子类型 }String是Any的子类型,加上out后,Producer<String>自动成为Producer<Any>的子类型,所以可以安全地传给接收父类型的函数。为什么这么赋值是安全的?因为produce()只会把类型参数作为返回值交给你,你拿到的Any实际上就是那个String,多态正常运作,没有任何风险。
换成不安全的写法试试:
interface BadProducer<out T> { fun set(item: T) // 报错:Type parameter T is declared as 'out' but occurs in 'in' position }这个报错信息一定要理解透:out类型参数处于“输入位置”(参数位置),编译器拒绝。为什么?假设这个类有个set(item: T)方法,我们拿Producer<String>当作Producer<Any>使用,就能调用set(123)——但是实际存的是一个StringProducer,一个Int塞进去,类型就碎了。
2.2 协变的典型实际案例:List、Iterable、Result
Kotlin 标准库是协变用得最频繁的地方。List<out E>是协变的,Iterable<out T>是协变的,Result<out T>也是协变的。这意味着你可以把List<RecyclerView.ViewHolder>传给一个接收List<Any>的函数(虽然一般你不会这么干),也可以用List<String>去初始化一个List<Any>类型的变量。
这带来的实际价值是:泛型类型在继承链上自动“接轨”。你写一个通用的日志函数fun logItems(items: List<Any>),不管是List<String>、List<Int>还是List<User>,都能直接传入,完全不需要额外的类型转换或者泛型约束。没有out,你就只能写fun <T> logItems(items: List<T>),让编译器推导每个具体调用点的类型,代码会啰嗦得多。
协变的另一层好处还体现在函数式编程里。比如 Kotlin 的Flow<out T>是协变的,Flow<String>就能直接赋给Flow<Any>类型的变量。这在写中间件或者抽象层时非常顺手:你定义了一个Flow<BaseEvent>的数据总线,所有子类型的Flow<LoginEvent>、Flow<OrderEvent>都能直接往里面塞,而MutableSharedFlow<T>特意保持了不变,因为它是生产者和消费者的混合体。
2.3 使用处协变:List 的补充
协变有两种声明位置。声明处协变是在类定义时写interface Producer<out T>,这是 Kotlin 最推荐的方式。但有时候你接手的是别人的代码,或者你在封装某个第三方的可变容器,没法改类定义的源码,这时候就要用使用处协变(use-site variance),也就是 Java 通配符? extends T的 Kotlin 写法out T。
举一个真实场景:某个旧接口返回MutableList<String>,你要把它传给一个只读参数。直接在函数签名上写:
fun readOnlyView(list: MutableList<out String>) { val item: String = list[0] // 允许读 // list.add("another string") // 报错:Out-projected type prohibits the use of fun add(element: E) } fun main() { val mutable = mutableListOf("a", "b") readOnlyView(mutable) }MutableList<out String>这句话的意思是:我只需要从这个可变列表里“读”出 String,请把我的传入参数临时约束为协变视角。编译器基于这个约束,禁止你在这个函数内部调用任何接收类型参数的写方法。这样一来,我们既能把可变列表安全传入,又保证了函数内部不会产生破坏类型安全的写入。
很多人在实际项目中遇到“为什么函数参数只能读不能写”的问题,其实就是因为在函数签名里用了使用处协变。这是好事,也是潜在的坑——你明明拿的是一个MutableList,想在里面 add 一个元素却被拒了,就是因为你把它投影成了只读视角。遇到这种情况,要么改成接收MutableList<String>,要么重新考虑这个函数的职责边界。
3. 逆变:in 关键字与 Consumer 语义
3.1 声明处逆变到底“逆”在哪
逆变是协变的镜像,但理解起来往往更难。给类型参数加上in,声明这个类是逆变的:Super<T>反而比Sub<T>更适合作为泛型参数。约束是:逆变类型参数只能出现在“输入”位置——方法参数、可变属性 setter 等。
看一个典型的消费者接口:
interface Consumer<in T> { fun consume(item: T) } class AnyConsumer : Consumer<Any> { override fun consume(item: Any) { println("consumed $item") } } fun feedConsumer(c: Consumer<String>) { c.consume("hello") } fun main() { val ac: Consumer<Any> = AnyConsumer() feedConsumer(ac) // 编译通过:Consumer<Any> 是 Consumer<String> 的子类型 }这里面的关系完全和直觉反着来:Consumer<Any>竟然能传给期望Consumer<String>的地方。为什么是安全的?因为AnyConsumer.consume(item: Any)能接收任何类型的参数,String 当然是Any的子类,所以往里传 String 完全没问题。
反过来就炸了。如果有一个Consumer<String>传给期望Consumer<Any>的函数,函数内部可能调用c.consume(123)——但实际的消费者只定义了对 String 的处理逻辑,一个 Int 塞进去要么类型转换错,要么逻辑直接崩。所以逆变的安全性在于:父类型消费者天生具备处理所有子类型的能力。
3.2 标准库里的逆变代表:Comparable 与 Comparator
Kotlin 标准库逆变的经典例子是Comparable<in T>和Comparator<in T>。
Comparator<in T>意味着:一个Comparator<Any>可以用在任何元素的比较场景中。比如String的天然排序规则按字典序,而Any没有可比性,但你可以写一个能对任意类型做某种兜底比较的Comparator<Any>,然后把这个比较器传给一个期望Comparator<String>的排序函数。因为“能比较任意Any的比较器”,当然也能比较 String。
这里背后的逻辑值得多想一步:比较器是“消费数据”的一方,它读取并判断两个元素的关系。它吃掉类型,所以逆变是合理的。在生产环境里,我遇到过给多类型列表做统一排序的需求——一个Comparator<in T>逆变化的设计就能让同一个比较器平滑适配多种子类型列表。
Comparable<in T>可能更让人困惑。翻开 Kotlin 源码,Comparable的声明是interface Comparable<in T>,但String : Comparable<String>是具体类。这里到底逆不逆变?看用法:
val anyComparator: Comparator<Any> = Comparator { a, b -> a.hashCode().compareTo(b.hashCode()) } val stringList = listOf("banana", "apple", "cherry") val sorted = stringList.sortedWith(anyComparator) // sortedWith 期望 Comparator<in String>你拿一个Comparator<Any>传给需要Comparator<in String>的排序函数,一切都对。“能比较任意对象的比较器”用于比较字符串子集,本来就没有任何风险。
3.3 逆变的思维卡点与几何反转
每次讲逆变,总有人陷入“子类型关系为什么反转了”的困惑。我的建议是把继承关系和函数参数兼容性分开画。类型系统的本质不是“谁是谁的子类”,而是“哪些赋值表达式是安全的”。
用生活类比:都说“会英语的人不一定会日语”,但“会所有语言的翻译”一定能翻译日语。所以AllLanguageTranslator可以出现在需要JapaneseTranslator的位置。在逆变的世界里,能力范围更广的“父类型”可以充当能力范围更窄的“子类型”角色。类型参数在输入位置时,越通用越安全。
为什么 Kotlin 选了“通用能当专用用”而不是反过来?因为调用方(consume)只需要保证“你真的有能力处理我交给你的这类型”,你能力越强越保险。逆变不是父子关系的反转,而是“能力覆盖关系”赋予了兼容性。这个视角一旦建立,大多数关于逆变的心智负担都可以卸掉。
4. 不变、星投影与变异性的完整图谱
4.1 MutableList 为什么必须是不变的
很多初学者爱追问:“Kotlin 能不能让MutableList<String>也协变?”答案是:理论上可以,但前提是必须禁止一切写入操作——那不就成只读列表了吗?所以MutableList这种既有读又有写的类型,只能是不变(invariant)的。
我们分解一下MutableList<E>的声明:
public interface MutableList<E> : List<E>, MutableCollection<E> { override fun add(element: E): Boolean override fun set(index: Int, element: E): Boolean ... }E既出现在add、set的参数位置,又出现在get(index): E、iterator(): MutableIterator<E>的返回位置。如果它协变,那么MutableList<String>就能作为MutableList<Any>传给某个函数,函数内部调用add(123),String 列表就被塞进了 Int——运行时灾难。如果它逆变,那么MutableList<Any>能作为MutableList<String>传出去,调用方get到的东西声明成了 String,实际可能包含别的类型——同样是灾难。
可变类型既当生产者又当消费者,任何方向的偏斜都会导致类型漏洞。所以 Kotlin 的标准库选择让可变容器保持不变,而把只读视角独立成List<out E>接口。这是整个设计思路里最值得品的一环:不靠运行时检查,而是靠类型系统本身封死错误路径。
4.2 星投影:List<*> 给未知类型留一扇门
遇到不确定泛型参数的场景,Kotlin 提供了星投影(star projection):List<*>、MutableList<*>、Map<String, *>。List<*>表示“某种类型的只读列表,具体是什么类型这里不关心”。
行为规则很特殊:
- 对于协变的
List<out T>,List<*>等价于List<out Any?>——读出元素的静态类型是Any?。 - 对于逆变的
Consumer<in T>,Consumer<*>等价于Consumer<in Nothing>——你不能往里传任何非 Nothing 的值,因为你不知道它到底接受什么类型。 - 对于不变的
MutableList<T>,MutableList<*>读出的元素是Any?,写入则被禁止。
星投影的核心价值在于它是类型安全的逃生门,你不能在投影类型上做任何需要具体类型的操作。实际开发中,最常见的是在序列化框架、数据库解析层里——你面对一个来自不同版本的 JSON 结构,字段列表可能是任意类型,用List<*>接收后做模式匹配,然后再逐个 cast 成需要的类型。
4.3 声明处变异与使用处变异的取舍策略
Kotlin 提供了两套变异性机制:声明处变异(declaration-site variance)在类定义时用out/in,使用处变异(use-site variance)在使用泛型时直接List<out T>、Comparator<in T>。
一线开发的经验法则是:
- 如果这个“泛型角色”本来就是你的 API 的稳定属性(比如只读接口、纯消费者),优先在类声明处标注
out或in。这样每个使用点都自动继承变异性,不需要调用者做任何投影操作。 - 如果类型来自外部依赖,或者你只是在这个函数内部需要临时限制读写方向,使用处变异更合适。它在“调用点”做局部约束,不改源码,覆盖到第三方类。
举个实际例子,很多团队自己封装事件总线时,会把事件定义成接口:
sealed interface Event data class LoginSuccess(val user: String) : Event data class OrderPlaced(val orderId: Long) : Event class Bus { private val _events = MutableSharedFlow<Event>() val events: SharedFlow<out Event> = _events // 对外暴露只读投影 }对外只暴露协变的只读流,内部持有可变实现。这样外部只能订阅读取,不能主动发射事件,这是使用处协变在封装层面最典型、最实用的场景。
5. 协变和逆变的组合应用与一线实战心得
5.1 集合拷贝:最经典的组合拳
如果你理解了读完上面的内容,现在可以上一道经典的“Kotlin 泛型集合复制”综合题。假设我们要实现一个函数,把源列表内容拷贝到目标列表:
fun <T> copyList(source: List<T>, dest: MutableList<T>) { for (item in source) { dest.add(item) } }这样写 Function 能跑,但不够灵活。如果我有List<String>和MutableList<Any>,想要把前者复制到后者,上面这个函数无法工作——List<String>不是List<T>,MutableList<Any>也不是MutableList<T>,类型系统找不到统一推导。
用变异声明改写:
fun <T> copyList(source: List<out T>, dest: MutableList<in T>) { for (item in source) { dest.add(item) } }现在source被约束为“只读视角的 T”,dest被约束为“接受 T 或其父类写入的可变列表”。当调用copyList(listOf("a"), mutableListOf<Any>())时,编译器把T推导成String,source是List<String>(可以),dest是MutableList<in String>(MutableList<Any>可以,因为Any是String的超类型)。
这段代码深刻体现了协变和逆变的配合:源只读 → 协变安全,目标写入 → 逆变兜底。大家在业务代码里看到的fun <T : R, R> List<T>.toMutableListTo(dest: MutableList<in R>)之类的高级封装,底层逻辑都逃不出这个模型。
5.2 泛型函数与方法参数的变异性
泛型参数也可以出现在函数的类型参数位置,这会和变异性产生微妙互动。看一个实际例子:
fun <T> fill(list: MutableList<in T>, value: T) { list.add(value) } fun main() { val anyList = mutableListOf<Any>() fill(anyList, "text") // T 推导为 String,MutableList<in String> 允许 Any 列表 }如果MutableList<in T>和T推导成功,编译器会保证传入list.add(value)的类型是兼容的。这里注意:T的值来自value参数,它也会被参与推导。如果 value 的类型是String,那T可以推导为String,MutableList<Any>作为MutableList<in String>完全合法。
反过来有协变的函数式参数也很常见:
fun <T> printAll(items: List<T>) { // ... } fun main() { val strings: List<String> = listOf("a") val anyItems: List<Any> = strings // 因为 List<out E>,所以这步成立 printAll(anyItems) }这里的“建立”步骤借助了标准库的声明处协变。我们在自己的泛型函数里不写out也能用,是因为List<T>在函数内部的读取操作天然安全,Kotlin 编译器允许你隐式利用List的协变关系。
5.3 常见报错速查:从错误信息倒推设计问题
最后整理一份实战中最常撞见的编译报错和对应的解决路径。这些问题我几乎每周都会在看到一次,把它们背下来,至少能少走一半弯路。
| 报错信息 | 出现场景 | 核心解法 |
|---|---|---|
Type parameter T is declared as 'out' but occurs in 'in' position | 协变类中给方法写了一个接收 T 的参数 | 要么删掉out,要么把接收 T 的方法移到另一个不变类型里 |
Out-projected type prohibits the use of fun add(element: E) | 使用处MutableList<out T>后尝试 add 元素 | 需要写入,就不要做使用处协变投影,直接接收MutableList<T> |
Type mismatch: inferred type is List<String> but List<Any> was expected | 忘写out或者传的是数组 | 检查是不是自定义类没加out;数组请转toList()或使用Array<out T> |
Type parameter T is declared as 'in' but occurs in 'out' position | 逆变类中写了返回 T 的 getter | 把读取操作分离到另一个协变或不变接口中 |
Cannot use 'T' as reified type parameter | 内联函数中重新指定类型参数 | 添加reified,同时注意实际类型参数的变异性约束 |
还有一个隐藏很深的坑:在使用处逆变时读取元素会得到Any?。写过这种代码的人应该都有印象:
fun printFirst(list: MutableList<in String>) { val item = list[0] // 静态类型是 Any?,不是 String }因为MutableList<in String>被投影成了“可能接受 String 的父类型的容器”,get 回来的东西存在不确定性,所以编译器给你最保守的类型Any?。如果这里你期望拿到 String 后再做业务处理,就会意识到设计上用逆变接收可变列表并不合适——应该接收List<String>或者使用处协变。
5.4 设计自己 API 时的三条经验法则
经过多个项目沉淀,我在设计自己的泛型类或接口时,基本上会按照下面三条准则自查,你也可以直接抄作业:
第一条:接口只用读时,敢标out不敢标就是错失良机。
比如数据源抽象:
interface DataSource<out T> { fun load(): T fun observe(): Flow<T> }下游依赖DataSource<User>的地方,都能直接用DataSource<BaseUser>实现类。抽象层如果忽略out,上层为了兼容子类型就得写各种泛型约束和白模板代码,无形中增加了不必要的复杂度。
第二条:如果是“塞数据”的接口,优先in,不能同时要读又要写。
接收器、事件投递器、命令处理器这类组件,天然适合in:
interface EventSink<in T> { fun post(event: T) }你把EventSink<Any>的必要实现当作EventSink<LoginEvent>使用时,任何子类型事件都能塞进去。如果 EventSink 还需要读回到某个缓存,那它就变成一个混合型工具,此时应拆成EventLog<T>保持不变,再暴露view(): List<out T>和write(item: T)两个拆开的视角。
第三条:对外永远优先暴露只读/窄化接口。
Java 到 Kotlin 迁移的老项目最容易犯这种错——直接暴露了内部MutableList<T>,导致协变和逆变全都失效,调用方被强制接受不变类型。内部维护MutableList<T>没问题,但对外返回时投影成List<out T>,入参接收时用List<out T>。如果需要在公开 API 里传可变列表,就用MutableList<in T>让调用者有一定的退还空间。
我强烈建议你用这这套思路去读一遍 Kotlin 标准库的源码声明。List、MutableList、Collection、MutableCollection、Sequence、Flow、Result、Comparable、Comparator、Function,挨个看它们的变异标注。每看懂一个接口的变异性设计,你对 Kotlin 类型系统的理解就往前深了一大截——这比刷十篇解析博客都有用。
最后分享一个亲身经历:我之前在重构一个事件驱动模块时,错把事件流直接暴露成MutableSharedFlow<BaseEvent>,导致调用方既能订阅又能发射,还引发了多个协变冲突的编译错误。后来改成对外SharedFlow<out BaseEvent>、对内MutableSharedFlow<BaseEvent>,编译得意,也顺手解决了模块间职责不清晰的问题。类型系统不会替你设计架构,但它会通过一个个报错,逼你把读写边界想清楚——这就是协变和逆变最大的价值所在。