ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Kotlin 泛型协变与逆变:out/in 与类型安全实战

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