ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Kotlin协变与逆变:从类型安全到out/in关键字的彻底理解

2026/10/5 7:39:52 拓冰建站 浏览量
Kotlin协变与逆变:从类型安全到out/in关键字的彻底理解 Kotlin高阶特性里协变Covariance和逆变Contravariance属于那种“看着简单一写就错”的知识点。我见过不少同学面试前能把“out 是生产者、in 是消费者”背得滚瓜烂熟回到项目里遇到类型不匹配的编译报错还是要埋头试错大半天。这篇文章我不打算让你背定义而是从类型安全的角度把这两个关键字彻底讲透为什么 Kotlin 非要搞出一套协变和逆变、声明处变型和使用处变型到底有什么区别、从 Java 迁过来时怎么对应以及那些最常见的编译错误到底在告诉你什么。1. 为什么说协变和逆变是理解Kotlin泛型的门槛1.1 从“List 到底是不是 List ”说起很多人在 Java 时代就被泛型折磨过ListString并不是ListObject虽然String是Object的子类但这两个集合类型之间没有任何继承关系。Java 选择让泛型默认不支持协变也就是类型参数方向上的“继承”不会传递给整个泛型类型。这个决定的直接后果是写出来的代码经常要跟编译器斗智斗勇。Java 里最能说明这个问题的是数组Object[]可以接收String[]因为数组在 Java 里天生协变。看起来很方便但代价是运行时才能发现错误。Object[] objects new String[3]; objects[0] 1; // 编译通过运行期抛 ArrayStoreException把一个整数放进一个字符串数组编译期毫无察觉等到程序跑起来才炸。这个例子恰好解释了为什么 Java 泛型后来不敢再做协变如果ListString是ListObject的子类型那么往ListObject里 add 整数时编译器无法阻止你污染原本只能放字符串的集合类型安全就彻底崩了。Kotlin 面临同样的问题但它没有简单地把门焊死而是给了你一套可读性极强的表达方式。这套表达方式就是协变和逆变一种类型在泛型容器里“方向一致”地传递另一种类型在泛型容器里“方向反转”地传递。理解这一层你就理解了整个 Kotlin 泛型体系里最关键的设计取舍。1.2 方向相反的两条“宽松规则”在谈out和in之前先定义清楚问题。假设有两个类型A和B并且A是B的子类型。现在有一个泛型容器BoxT如果BoxA也能被当作BoxB使用也就是子类型关系跟 T 的方向一致这叫协变如果反过来BoxB能被当作BoxA使用也就是子类型关系跟 T 的方向相反这叫逆变如果两者都不行只能BoxT和BoxT互相赋值这叫不变。用生活场景类比更直观。苹果是水果的子类。一个“只负责生产苹果的机器”完全可以当作“生产水果的机器”来用因为你从里面拿到的每一个苹果都是水果——这是协变让人放心的地方。反过来一个“能吃掉任意水果的机器”你让它去处理苹果当然没问题因为它本来就能处理所有水果——这就是逆变的方向范围更大的容器可以去服务范围更小的对象。Java 泛型默认是第三种不变。而 Kotlin 希望你在接口设计阶段就把“这个类型参数是往外吐数据还是往里面收数据”说清楚。一旦说清楚编译器就能帮你推导出大量安全的赋值关系这正是 Kotlin 协变逆变最核心的价值。2. 从声明到使用out、in 关键字的设计逻辑2.1 声明处变型在类型定义时直接打标签Kotlin 最优雅的地方在于它允许你在声明泛型类或接口的时候直接告诉编译器这个类型参数将来会发生什么。用out修饰表示这个 T 只“产出”不“摄入”用in修饰表示这个 T 只“摄入”不“产出”。interface Sourceout T { fun next(): T } interface Sinkin T { fun accept(item: T) }Sourceout T的next()返回值是 T数据从 Source 里“流出来”所以 T 出现在 out 位置。反过来Sinkin T的accept()参数是 T数据从外部“流进去”所以 T 出现在 in 位置。一旦这样声明编译器就会强制检查被out标记的 T 不能出现在函数参数位置被in标记的 T 不能出现在返回类型位置。这种在类或接口声明时就固定住泛型方向的做法叫声明处变型declaration-site variance。它有一个巨大的优势使用方完全不需要关心方向直接赋值就行。val fruitSource: SourceFruit appleSource // 编译通过SourceT 协变 val appleSink: SinkApple fruitSink // 编译通过SinkT 逆变appleSource被声明成SourceApple但因为Source的类型参数是out所以它可以安全地赋给SourceFruit。fruitSink被声明成SinkFruit但因为Sink是in它又可以安全地赋给SinkApple。你不需要写任何额外的通配符out和in两个字母已经把类型系统的扩展规则完全表达出来了。2.2 一句话判断 T 到底该用 out 还是 in很多人到这里开始懵我怎么判断一个类型参数该标成out还是in其实规则非常简单——看 T 在接口里出现了几次以及出现在什么位置。把接口想象成一扇门。T 从门里出去出现在返回值、不可变集合的元素类型、只读属性类型这些地方它就是 out。T 从门外进来出现在函数参数、可变集合的元素类型、构造器入参这些地方它就是 in。一句话往外拿数据是 out往里放数据是 in。拿 Kotlin 标准库来验证List的声明是Listout E因为List只暴露读取方法get()返回 E没有 add 方法E 永远只能往外流。Comparable的声明是Comparablein T因为compareTo()接收一个 T 作为参数T 只能往里进。Function1的声明是Function1in P1, out R第一个类型参数 P1 被消费第二个类型参数 R 被产出所以一个标 in一个标 out。那个经典的函数类型变型规律也跟着自然浮现参数逆变返回协变。假设Fruit是Apple和Banana的父类一个类型为(Fruit) - Unit的函数它能处理任何水果而一个类型为(Apple) - Unit的函数只能处理苹果。如果某个变量声明成(Apple) - Unit你给它传一个(Fruit) - Unit是安全的因为调用方只会往里传苹果而处理水果的函数当然接得住苹果。反过来如果你声明的是(Fruit) - Unit却传了一个(Apple) - Unit一旦调用方扔进来一根香蕉这个函数就不知道怎么处理了。这就是参数位置必须用逆变的原因。返回值的逻辑就更好理解了。一个返回Apple的函数在任何需要返回Fruit的位置都能正常工作因为苹果本来就是水果。所以返回值必须协变。Kotlin 的Function接口把这两条规则直接用in P和out R写进了类型声明标准库本身就成了最好的教学案例。2.3 编译器为什么会毫不留情地拒绝你out和in不是摆设编译器会在编译期严格遵守这两个标记。一旦你违反了约定会得到非常明确的错误提示interface Boxout T { fun set(value: T) // 编译错误 }编译器的报错原文是Type parameter T is declared as out but occurs in in position翻译过来就是你明明把 T 标记成了 outT 却出现在了 in 的位置上。为什么这是非法的因为一旦允许set(value: T)Boxout T就有可能被一个父类型引用接收然后往里塞一个不匹配的子类对象类型安全立刻崩塌。编译器不允许你破坏自己亲手定下的规则所以在编译期就把这条路堵死。同样的如果 T 声明为in它就不允许出现在返回位置。interface Boxin T { fun get(): T // 编译错误 }报错会变成Type parameter T is declared as in but occurs in out position。这里编译器拒绝的逻辑也很清晰Boxin T可以被当作更窄类型的 Box 使用如果你从里面取出一个 T实际返回的可能是比当前更宽泛的类型调用方拿到的对象类型就不可控了。我自己经常跟新手说out就像只出不进的单向阀门in就像只进不出的单向管道。你在这个位置放了不允许的操作编译器当然要拦你。3. 从Java迁移到Kotlin通配符、类型投影与实战改造3.1 Java 的 PECS 和 Kotlin 的对应关系Java 用户对协变逆变最熟悉的记忆是那条著名的PECS法则Producer ExtendsConsumer Super。如果你要往外读数据用? extends T如果你要往里写数据用? super T。Kotlin 的位置反过来了它把 Java 在使用处写的通配符搬到了声明处用out和in表达。用代码来对照感觉会非常直接// Java方法声明处使用通配符 void copy(List? super String dest, List? extends String src) { for (String s : src) { dest.add(s); } }// Kotlin接口声明处固定方向调用处零通配符 fun copy(dest: MutableListin String, src: Listout String) { ... }在 Kotlin 里如果src本身就是一个只读的ListString你甚至不需要写out直接作为ListString传入就行。Java 那边每次写方法都要带通配符Kotlin 只需要在声明处写一次out E或in T后面所有使用方通通省略。这种体验上的差距经历过 Java 泛型折磨的人体会特别深。3.2 类型投影当你改不了第三方接口时怎么办声明处变型虽然优雅但有一个前提你能控制那个泛型类的源代码。现实中经常遇到第三方库的接口人家的泛型参数既没有out也没有in可你在自己代码里只把它当生产者或者只当消费者用。这时候 Kotlin 提供了使用处变型use-site variance也叫类型投影。// Java 第三方库风格interface BoxT { void put(T t); T get(); } // Kotlin 使用处把它投影成 out只看得到 get() val box: Boxout String thirdPartyBox()把Boxout String投影出来之后编译器会假装这个 Box 没有put方法只允许你调用返回 T 的方法。这是一种“编译期视图裁剪”它不会改变对象的真实行为但会阻止你犯错。投影真正的威力体现在函数入参上。比如你要写一个方法把一个MutableListout Number里的元素全部打印出来fun printNumbers(list: MutableListout Number) { for (item in list) { println(item) } }调用方传一个MutableListInt进来是安全的因为你的方法根本不会往 list 里添加任何元素。可如果你在这个方法里试图调用add编译器立刻翻脸报错信息非常经典Out-projected type MutableListout Number prohibits the use of fun add(element: Number)意思是说list 的类型被投影成了只产出 Number禁止调用任何需要传入 Number 参数的 add 方法。这个限制是有道理的MutableListout Number的底层实现可能是MutableListInt你往里面 add 一个Double运行时一定会出问题。编译器根本不给你在这种边缘情况“赌一把”的机会。对应的in投影也有同样的规矩。把某个可变列表投影成MutableListin Number你就会失去get()方法fun addNumbers(list: MutableListin Number) { list.add(3) // 编译通过 val item: Number list[0] // 编译错误 }报错原文是In-projected type MutableListin Number prohibits the use of fun get(index: Int): E理解了这条报错你基本上就理解了 Kotlin 类型投影的所有核心逻辑投影本质上等于告诉编译器“我不需要那部分能力”编译器帮你把那一半的操作全部禁掉从而换回泛型方向上更大的适用范围。3.3 星号投影List* 到底能干什么还有一个概念跟协变逆变紧密相关就是星号投影star projection。当你确确实实不知道泛型参数是什么类型但你还是想安全地操作这个泛型对象时可以用*代替类型参数。fun printList(list: List*) { val item list[0] // item 的类型是 Any? println(item) }List*在这里退化成一种“只知道是 List不知道装什么”的类型。因为List本身是协变的Listout E星号投影会把 E 推断为Any?所以你能读出来但不知道具体类型。如果是MutableList*你会同时失去写入能力因为往里塞东西不安全只能读取。如果你面对的是一个Consumerin T类型的对象星号投影的方向正好相反T 会被推断成Nothing也就是什么都不可能是。这也就意味着你无法读取任何有价值的数据但你可以往里传入任意类型——因为Nothing是任何类型的子类型逆变方向会让入参变成in Nothing实际效果是参数接受一切。星号投影不是万能的它的作用是让你的代码在不知道泛型参数时还能保持类型安全。我自己一般只在 debug 打印、序列化、反射辅助这类场景里用它业务代码里如果用到了*大概率说明接口设计有问题架不住泛型参数丢失了。3.4 实战改造一个 Android 仓库接口的设计说了一堆理论回到 Android 开发里最常见的一个场景屏幕展示用户信息。通常我们会定义一个仓库接口从远端加载数据。sealed interface UiStateout T { data class Loadingout T(val progress: Float) : UiStateT data class Successout T(val data: T) : UiStateT data class Errorout T(val message: String) : UiStateT } interface UserRepository { fun loadUser(): UiStateUser } class MainViewModel(private val repository: UserRepository) { fun observeUser(): UiStateUser repository.loadUser() }UiStateout T是我比较推荐的一种写法。它让UiStateUser可以安全地赋给UiStateAny如果你的界面层有统一处理所有状态的地方就不需要额外写类型转换。反过来如果你有一个UserRepository接口只负责接收命令参数上的 T 就可以用ininterface CommandHandlerin T { fun handle(command: T) } class UserCommandHandler : CommandHandlerUserCommand { override fun handle(command: UserCommand) { ... } }CommandHandlerUserCommand能赋给CommandHandlerAny因为能处理所有命令的处理器当然也能处理具体的UserCommand。这个例子和UiState正好一正一反把协变和逆变在实际项目里的分工说得清清楚楚。4. 编译期常见错误与排查实录4.1 为什么协变的集合不能 add这是我在代码评审里见到最多的问题。有人写了一个方法接收MutableListout Number然后在方法里往列表里塞数据结果编译不通过一脸困惑地来问我为什么。fun fillNumbers(list: MutableListout Number) { list.add(1) // 编译错误 }报错信息上面已经出现过Out-projected type MutableListout Number prohibits the use of fun add(element: Number)。这里的关键在于理解协变集合的真实运行时类型。MutableListout Number可以是MutableListInt也可以是MutableListDouble。你在方法里 add 的 1 是Int如果底层列表是MutableListDouble这个元素根本放不进去。就算是MutableListNumber也很难保证所有子类都能共存。所以编译器宁可损失灵活性也要彻底禁止写入。解决方法很简单如果你既需要读取又需要写入就不要把它声明成out。要么用普通的MutableListNumber要么用in投影让它只负责写入。4.2 out 类型为什么不能出现在参数位置这个问题属于声明处变型的知识盲区。有人想定义一个回调接口想当然地写了out结果 setter 和参数方法全都报错。interface Callbackout T { fun onResult(value: T) // 编译错误 }报错依然是那句Type parameter T is declared as out but occurs in in position。为什么允许onResult接收一个 T就会破坏安全性因为Callbackout T可以被赋给CallbackAny。届时任何调用方都可能往onResult里传一个跟原始类型无关的对象回调内部原本针对 T 的逻辑就会拿到完全陌生的类型类型系统形同虚设。正确的做法是如果这个回调既要产出结果又要接收事件干脆不要用out如果确认回调只会把结果往外报就只保留返回值方法。4.3 in 投影为什么不能读取数据相对out投影禁止 addin投影禁止 get 也是新手容易踩的坑。val list: MutableListin Number mutableListOf(1, 2, 3) val first: Number list[0] // 编译错误报错是In-projected type MutableListin Number prohibits the use of fun get(index: Int): E。原因在于MutableListin Number底层可能是MutableListAny也可能是MutableListInt。既然只能保证“这个列表能接受 Number 以及它的子类”就没办法保证从里面取出来的每个元素都恰好是 Number所以读取方法全部被禁用。遇到这种场景我通常建议直接拆成两个接口一个只读接口暴露读取能力一个只写接口暴露写入能力。这比在同一个接口里来回投影要清晰得多。4.4 排查思路与经验速查表如果你在项目里遇到协变或逆变相关的编译错误我的排查顺序是固定的先看报错里涉及的类型参数是出现在方法参数位置in 位置还是返回值位置out 位置再看这个参数在你的业务场景里到底是生产者还是消费者最后决定是调整声明处的out/in还是在调用处用类型投影。典型场景编译期报错关键信息原因解决方案往MutableListout Number里 addOut-projected type prohibits the use of fun addout 投影禁止写入改用MutableListNumber或in投影从MutableListin Number里 getIn-projected type prohibits the use of fun getin 投影禁止读取改用不变类型或用只读接口接收out 类型参数出现在入参位置Type parameter T is declared as out but occurs in in position违反声明处变型规则改成in T或去掉 out 修饰in 类型参数出现在返回值位置Type parameter T is declared as in but occurs in out position违反声明处变型规则改成out T或去掉 in 修饰List*读取后无法调用具体类型方法无具体编译错误但类型是 Any?星号投影丢掉了具体泛型信息重新定义泛型边界避免用星号投影传递核心业务数据4.5 几条实操心得我自己的经验是协变和逆变的边界不能靠死记硬背而是要把代码当作“类型安全的契约”来理解。每次写一个泛型接口前先问自己三个问题T 会被哪里产出、会被哪里消费、是同时被产出和消费还是只走单向。如果同时被产出又被消费就不该用out或in如果只走单向就放心地用对应关键字标记让编译器帮你省下一堆类型转换代码。在项目里我习惯把只读数据源和事件回调设计成协变把命令处理器、写入器设计成逆变。这样依赖方拿到的类型永远是最窄的、最安全的接口。比如 ViewModel 对外暴露的LiveData或StateFlow如果里面的数据只是传给 UI 渲染就很适合out而网络层接收请求对象的回调参数类型很适合in。如果你正在从 Java 迁移到 Kotlin建议把? extends全部替换成out把? super全部替换成in然后看编译器提示再逐步修正。Kotlin 编译器比 Java 更加严格但也正因为严格它能提前暴露很多 Java 里只能靠运行时崩溃才能发现的问题。最后分享一个小技巧当你对某个泛型类的方向拿不准时直接在 IDE 里看它的源码声明。Kotlin 标准库的List、Comparable、Function都是现成的案例多读几遍这些接口的out和in标法比翻十篇教程都有用。毕竟这些接口已经经受住了整个生态多年的大规模使用它们是协变逆变规则最真实、最可靠的行为样本。