资讯动态

Kotlin泛型形变注解out与in:从类型安全到实战选型

发布时间:2026/10/16 23:08:57 来源:尧图企业网站定制
1. 从一次编译报错说起为什么需要形变注解第一次被out和in卡住是在写一个通用的数据仓库层。当时定义了一个RepositoryT接口想让RepositoryString能赋值给RepositoryAny结果编译器直接甩脸类型不匹配。我当时的第一反应是——String 明明是 Any 的子类型凭什么RepositoryString不能当RepositoryAny用这个疑问恰恰就是形变注解要解决的核心问题。在 Kotlin 里泛型默认是不型变的。也就是说哪怕String是Any的子类型ListString和ListAny之间也没有任何继承关系。这个设计不是 Kotlin 拍脑袋决定的Java 的泛型同样如此。原因很实在如果允许随意转换类型安全就会崩盘。想象一下如果MutableListString能当成MutableListAny用那你就能往里面塞一个Int然后取出来的时候按String处理运行时直接崩溃。但现实开发中我们确实经常需要这种子类型容器可以当父类型容器用的能力。比如一个只负责读取的函数接收ListAny完全没问题传ListString进去也应该合法因为它根本不会往里写东西。Kotlin 用out和in这两个形变注解把什么时候可以安全转换这件事从运行时搬到了编译期让编译器帮你把关。这篇内容适合已经会写 Kotlin 泛型、但每次看到out/in就靠猜的开发者。我会从类型安全的底层逻辑讲起把out协变、in逆变的适用场景、编译器约束、以及实际项目中的选型经验全部拆开。读完你至少能做到看到一段泛型代码能判断该不该加形变注解加了之后编译器为什么放行或拦截。提示形变注解不是优化技巧而是类型契约。加错了不是性能问题是安全问题。所以理解它背后的约束比记住语法重要得多。2. 协变out只出不进的生产者逻辑2.1 用生产者类比理解out的本质out修饰的类型参数官方叫协变。我更喜欢用生产者来记一个Producerout T只负责产出T不负责消费T。就像一个水果篮你只能从里面拿水果出来不能往里放东西。既然只能拿那装苹果的篮子当然可以当装水果的篮子用——你从里面拿出来的永远是苹果而苹果本来就是水果。这个类比直接对应到代码约束上被out标记的 T只能出现在返回值位置不能出现在参数位置。编译器就是这么检查的。你写一个fun get(): T没问题写一个fun put(item: T)编译器立刻报错。因为一旦允许 T 出现在参数位置就意味着这个类型既能生产又能消费那它就不再是纯粹的生产者协变的安全性就不成立了。// 正确T 只出现在返回值位置 interface Producerout T { fun produce(): T } // 编译报错T 出现在参数位置违反 out 约束 interface BadProducerout T { fun consume(item: T) // Error: Type parameter T is declared as out but occurs in in position }2.2 标准库里的outList为什么是协变的Kotlin 标准库里的Listout E就是协变的经典案例。注意看它的定义List只有get这类读取方法没有add这类写入方法。正因为如此ListString才能安全地赋值给ListAny。val strings: ListString listOf(a, b, c) val anys: ListAny strings // 合法因为 List 是协变的 fun printAll(list: ListAny) { list.forEach { println(it) } } printAll(strings) // 传 ListString 完全没问题对比一下MutableList它没有out修饰因为它既能读又能写。如果你试图把MutableListString赋给MutableListAny编译器会直接拦截。这不是 Kotlin 故意刁难而是因为MutableListAny允许add(123)而底层的MutableListString根本承受不了这个操作。2.3 声明处协变与使用处协变的选择Kotlin 支持两种协变写法声明处协变在类/接口定义时加out和使用处协变在函数参数上用out投影。这两种方式解决的问题不同选错了会让 API 变得别扭。声明处协变适合那种天生就是生产者的类型比如Producer、Source、List。一旦在类定义上加了out所有使用这个类的地方都自动获得协变能力一劳永逸。而使用处协变适合那些大部分时候是消费者偶尔需要协变的场景比如一个函数只想读取集合内容// 使用处协变只在这个函数里把 List 当生产者用 fun sumAll(numbers: Listout Number): Double { return numbers.sumOf { it.toDouble() } } sumAll(listOf(1, 2, 3)) // ListInt 可以传入 sumAll(listOf(1.5, 2.5)) // ListDouble 也可以传入这里Listout Number的意思是我不关心你传进来的是ListInt还是ListDouble我只保证从里面读出来的东西是Number的子类型。这种写法比在函数里写死ListNumber灵活得多。注意使用处协变有个容易踩的坑——投影之后你就不能调用任何接收 T 参数的方法了。Listout Number你只能读不能写。如果函数内部需要写入就不能用out投影。3. 逆变in只进不出的消费者逻辑3.1 消费者视角下的inin修饰的类型参数叫逆变方向跟out正好相反。用消费者来记一个Consumerin T只负责消费T不负责产出 T。就像一个垃圾桶你只能往里扔垃圾不能从里面往外拿。既然只能扔那扔水果的桶能不能当扔苹果的桶用可以因为苹果本来就是水果扔苹果进水果桶天经地义。但反过来就不行ConsumerApple不能当ConsumerFruit用因为ConsumerFruit承诺能接收任何水果而ConsumerApple只能接收苹果你扔个香蕉进去它就崩了。所以逆变的转换方向是父类型容器可以当子类型容器用跟协变正好相反。interface Consumerin T { fun consume(item: T) } val anyConsumer: ConsumerAny ConsumerAny { println(it) } val stringConsumer: ConsumerString anyConsumer // 合法逆变ConsumerAny能接收任何对象那它当然也能接收 String所以把它当ConsumerString用是安全的。这就是逆变的直觉。3.2 标准库里的inComparable为什么是逆变的Comparablein T是标准库里逆变的代表。它的compareTo方法接收一个 T 参数属于消费者行为。正因为逆变ComparableAny可以赋值给ComparableStringval anyComparable: ComparableAny object : ComparableAny { override fun compareTo(other: Any): Int 0 } val stringComparable: ComparableString anyComparable // 合法这个设计很巧妙一个能比较任意对象的比较器当然也能比较字符串。逆变让这种能力更强的消费者可以替代能力更弱的消费者。3.3 函数类型中的逆变为什么参数是inKotlin 的函数类型(T) - R其实等价于Function1in T, out R。参数 T 是逆变返回值 R 是协变。这个设计不是随便定的而是由函数的行为决定的函数消费参数生产返回值。val f: (Any) - String { it.toString() } val g: (String) - Any f // 合法参数逆变返回值协变f能接收任何对象并返回 String那它当然能接收 String 并返回 AnyString 是 Any 的子类型。参数位置逆变、返回值位置协变两者组合起来让函数类型的子类型关系符合直觉。理解这一点之后很多之前觉得奇怪的赋值就通了。比如ArrayT为什么是不型变的因为数组既能读又能写get生产 Tset消费 T两个方向都有所以既不能协变也不能逆变。4. 编译器到底在检查什么形变约束的底层规则4.1 位置规则out位置与in位置编译器检查形变的核心规则就一条out类型参数只能出现在 out 位置in类型参数只能出现在 in 位置。那什么是 out 位置、什么是 in 位置简单说返回值位置是 out 位置参数位置是 in 位置。但实际规则比这复杂因为泛型可以嵌套。比如ListT作为返回值时T 处于 out 位置ListT作为参数时T 处于 in 位置。如果List本身是协变的那ListT作为参数时T 的位置会翻转一次。interface Exampleout T { fun produce(): T // T 在 out 位置OK fun consumeList(items: ListT) // List 是协变的T 在 List 的 out 位置 // 但 ListT 整体在参数位置in 位置 // 翻转后 T 处于 out 位置OK fun consumeDirect(item: T) // T 直接在 in 位置报错 }这个位置翻转是理解形变约束的关键。协变类型作为参数时内部的位置会翻转逆变类型作为参数时内部位置也会翻转。编译器就是靠这套位置计算来判断每个类型参数是否合法的。4.2 类型投影星号投影与out/in投影除了声明处和使用处的形变Kotlin 还有类型投影。最常见的是星号投影List*它等价于Listout Any?表示我不知道里面装的是什么但我只能读不能写。fun printSize(list: List*) { println(list.size) // list.add(...) // 报错星号投影不能写 val item: Any? list[0] // 只能读读出来是 Any? }星号投影在只需要读取集合大小、遍历元素这类场景非常实用。它比写ListAny?更准确因为它明确表达了我不关心具体类型的意图。使用处投影out和in则更精细。Listout Number表示这是 Number 的某个子类型的列表我只读Consumerin String表示这是 String 的某个父类型的消费者我只写。这两种投影在 API 设计里经常用来放宽参数限制。4.3 常见编译错误与修复对照实际开发中形变相关的编译错误就那么几类。我整理了一张对照表遇到报错可以直接查错误信息关键词根本原因修复方案Type parameter T is declared as out but occurs in in positionout 参数出现在参数位置去掉 out或改用使用处投影Type parameter T is declared as in but occurs in out positionin 参数出现在返回值位置去掉 in或改用使用处投影Type mismatch: inferred type is X but Y was expected泛型不型变导致检查是否需要加 out/in 或使用投影Cannot use T as reified type parameter类型擦除用 inline reified或传 KClass这张表里的第三类错误最隐蔽因为报错信息不会直接告诉你需要形变注解。我的经验是当你看到两个泛型类型明明有子类型关系却报类型不匹配时第一反应就该是这里是不是需要协变或逆变。提示reified和形变注解经常一起出现但它们是两个独立的问题。reified解决的是类型擦除形变解决的是子类型关系。别把两者混为一谈。5. 实战选型什么时候该用out什么时候该用in5.1 从接口职责反推形变方向选out还是in最可靠的方法不是看类型参数本身而是看接口的职责。问自己一个问题这个接口主要是往外给东西还是往里收东西如果接口的核心方法是获取、读取、生产那它大概率该用out。比如SourceT、ProviderT、RepositoryT只读版本。如果核心方法是处理、接收、消费那它该用in。比如HandlerT、ListenerT、ComparatorT。// 只读仓库生产者用 out interface ReadOnlyRepositoryout T { fun findById(id: Long): T? fun findAll(): ListT } // 事件处理器消费者用 in interface EventHandlerin E { fun handle(event: E) }这个判断方法在 90% 的场景下都管用。剩下 10% 是那些既读又写的接口它们本来就不该加形变注解保持不型变才是正确的。5.2 一个真实的重构案例从报错到优雅我之前维护过一个数据同步模块最初的定义是这样的interface DataSourceT { fun fetch(): T fun save(item: T) }这个接口既读又写所以不能加形变注解。但实际使用中读取和写入的调用方完全不同读取方只关心fetch写入方只关心save。结果就是读取方被迫接受一个完整的DataSourceT无法利用协变。重构方案是拆成两个接口interface ReadableSourceout T { fun fetch(): T } interface WritableSourcein T { fun save(item: T) } interface DataSourceT : ReadableSourceT, WritableSourceT拆完之后读取方的函数签名可以写成fun syncFrom(source: ReadableSourceAny)任何ReadableSourceString、ReadableSourceInt都能传进来。写入方写成fun syncTo(sink: WritableSourceString)任何能接收 String 的 sink 都能用。这个重构没有改任何业务逻辑但 API 的灵活性提升了一个档次。5.3 形变注解与Java通配符的对应关系如果你有 Java 背景可以把out对应到? extends Tin对应到? super T。但两者有个重要区别Java 的通配符是使用处的Kotlin 的形变注解是声明处的。这意味着 Kotlin 的形变能力更自动——一旦在类定义上加了out所有使用处都自动获得协变不需要每次写? extends。KotlinJava 等价位置Listout TList? extends T使用处interface Producerout T无直接等价Java 只能在方法上用通配符声明处Consumerin TConsumer? super T使用处List*List?使用处这个差异导致 Kotlin 的泛型 API 通常比 Java 更简洁。Java 里到处是? extends和? superKotlin 里大部分情况下在类定义处写一次就够了。6. 那些年踩过的形变坑6.1 坑一给MutableList加out导致无法写入刚学形变那会儿我觉得out很酷就给自己的MutableList包装类加了out。结果所有写入方法全部报错整个类废了一半。当时的困惑是我只是想让它支持协变赋值为什么连写都不让写了后来想明白了out是一个契约它向编译器承诺这个类型参数只出不进。编译器信了这个承诺才允许协变赋值。如果你既想要协变又想要写入那这个承诺就是假的编译器当然要拦。正确的做法是要么去掉out接受不型变要么拆成只读和可写两个接口。6.2 坑二使用处投影后调用方法报错另一个高频坑是使用处投影。比如写了fun process(list: Listout Number)然后在函数体里想调用list.add(...)编译器报错。这时候很多人会困惑List不是有add吗问题在于Listout Number是投影类型它把add方法的参数类型变成了Nothing。因为编译器不知道底层列表的具体类型如果允许你add就可能往ListInt里塞Double。所以投影之后所有接收 T 参数的方法都不可用。这是类型安全的代价也是投影的边界。6.3 坑三误以为Array可以协变Java 的数组是协变的String[]可以赋给Object[]但会在运行时抛ArrayStoreException。Kotlin 吸取了这个教训ArrayT是不型变的。ArrayString不能赋给ArrayAny编译期就拦截。val strings: ArrayString arrayOf(a, b) // val anys: ArrayAny strings // 编译报错这个设计避免了 Java 数组那种编译通过、运行崩溃的尴尬。如果你确实需要协变行为用List而不是Array。List是只读的协变安全Array可读可写协变不安全。6.4 坑四在泛型类里混用out和in最复杂的坑是同时使用out和in。比如一个转换器接口interface Converterin I, out O { fun convert(input: I): O }这个定义是合法的I 是消费者参数O 是生产者返回值。但如果你不小心把顺序写反或者在某处把 I 当返回值用编译器就会报错。我的经验是先确定每个类型参数的角色再决定形变方向。I 是输入天然该inO 是输出天然该out。角色清晰了形变方向就不会错。注意Converterin I, out O这种写法在函数式编程里很常见它对应的是Function1in I, out O。理解了这个模式Kotlin 函数类型的形变规则就完全通了。7. 把形变注解用对的几条经验形变注解这东西看再多理论不如自己写几遍。我的建议是下次定义泛型接口时先别急着加out或in先问自己三个问题。第一这个类型参数是往外给还是往里收往外给考虑out往里收考虑in。第二接口里有没有同时读写这个类型参数的方法如果有那就别加形变注解保持不型变。第三调用方是否真的需要子类型转换如果所有调用方都用同一个具体类型那加不加形变注解其实没区别加了反而增加理解成本。还有一个实用技巧当你拿不准该不该加out时先加上然后看编译器报不报错。如果所有方法都通过说明这个类型确实是纯生产者out是合适的。如果有方法报错那说明它不纯要么去掉out要么把那个方法拆到另一个接口里。编译器在这里扮演的是一个严格的类型顾问它的报错信息虽然有时候不够友好但方向永远是对的。最后说一个我自己的体会形变注解的价值不在于让代码能编译而在于让类型契约显式化。一个加了out的接口等于在文档里写了一句我只生产不消费。后来接手代码的人看到这个注解立刻就能理解接口的职责边界。这种自文档化的效果比写十行注释都管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑