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