协变、逆变与型变的深度解析
从一个"直觉性错误"开始
在前一篇文章中,我们知道了 String 是 Any 的子类型——任何需要 Any 的地方,都可以传入 String。这是子类型化(Subtyping)最基本的含义。
那么一个自然的推论是:List<String> 是不是 List<Any> 的子类型?
如果你的直觉告诉你"是的"——恭喜你,你踩中了泛型世界中最经典的陷阱。
// 假设 MutableList<String> 是 MutableList<Any> 的子类型...
val strings: MutableList<String> = mutableListOf("hello", "world")
val anys: MutableList<Any> = strings // 假设这行编译通过
anys.add(42) // 往"Any 列表"里添加一个 Int——合法
val s: String = strings[2] // 从"String 列表"里取出第三个元素
// 💥 运行时爆炸!strings[2] 实际上是 Int(42),不是 String
如果编译器允许 MutableList<String> 赋值给 MutableList<Any>,那么通过 anys 引用往列表里塞入一个 Int,就打破了 strings 引用的类型约束——"列表里只有 String"变成了谎言。
把泛型容器想象成一个带标签的快递柜。
MutableList<String>就是一个标注了"只放书"的柜子。如果你把柜子的标签改成"什么都能放"(MutableList<Any>),然后往里面塞了一块砖头——下一个来取书的人打开柜子,拿到的却是砖头。问题不在于"砖头是不是一种物品"(Int确实是Any),而在于你修改了柜子标签,但柜子的原始承诺已被破坏。
这个问题的本质是:元素类型的子类型关系,不能直接"传递"给可变泛型容器的类型关系。这就是泛型型变(Variance)要解决的核心问题。
不变型(Invariance):泛型的默认立场
定义与直觉
当一个泛型类没有声明任何型变修饰符时,它是不变的(Invariant)。这意味着即使 A 是 B 的子类型,Container<A> 和 Container<B> 之间也没有任何子类型关系——它们是完全无关的两个类型。
// MutableList<T> 是不变的——T 没有 out 也没有 in
val strings: MutableList<String> = mutableListOf("hello")
// val anys: MutableList<Any> = strings // ❌ 编译错误:Type mismatch
// val ints: MutableList<Int> = strings // ❌ 编译错误:Type mismatch
为什么可变容器必须不变
从前面的例子我们已经看到:如果可变容器是协变的,你可以通过父类型引用往容器里塞入类型不匹配的元素。反过来,如果可变容器是逆变的:
// 假设 MutableList<Any> 是 MutableList<String> 的子类型...
val anys: MutableList<Any> = mutableListOf(42, "hello", 3.14)
val strings: MutableList<String> = anys // 假设这行编译通过
val s: String = strings[0] // 💥 strings[0] 实际上是 Int(42),不是 String
读取时取出的元素无法保证是 String——类型安全同样被破坏。
一个泛型类如果既"生产" T(在返回值位置使用 T),又"消费" T(在参数位置使用 T),那它必须是不变的。否则无论哪个方向的子类型化,都会引入运行时类型错误。
MutableList<T> 正是这种情况——get() 生产 T,add() 消费 T。
Java 数组的历史教训:协变的可变容器
Java 的数组(Array)设计了一个错误的先例——它们是协变的。String[] 是 Object[] 的子类型:
// Java:数组是协变的——编译通过!
String[] strings = {"hello", "world"};
Object[] objects = strings; // ✅ 编译通过:String[] 是 Object[] 的子类型
objects[0] = 42; // ✅ 编译通过:42 是 Object
// 💥 运行时:ArrayStoreException!
Java 把本应在编译期发现的类型错误推迟到了运行时。JVM 不得不在每一次数组写入时执行一次 ArrayStoreException 检查——这是额外的运行时开销,也是一个众所周知的设计失误。
Kotlin 吸取了这个教训——Array<T> 在 Kotlin 中是不变的:
val strings: Array<String> = arrayOf("hello")
// val objects: Array<Any> = strings // ❌ 编译错误——Kotlin 直接在编译期阻止
协变(Covariance):out 关键字与"只读"承诺
核心问题:只读容器可以安全地协变
既然可变容器不能协变,那只读容器呢?如果一个容器只生产元素,从不消费元素——也就是说,你只能从中取出 T,不能往里放入 T——那么把 Container<String> 视为 Container<Any> 的子类型就是安全的:
// 从 List<String> 中取出的每一个元素都是 String
// String 是 Any 的子类型
// 所以取出的元素一定也可以当作 Any 使用——没有类型破坏
val strings: List<String> = listOf("hello", "world")
val anys: List<Any> = strings // ✅ 安全——List 只读,不能往里塞东西
这就是协变的核心逻辑:如果一个泛型类只"生产" T,那么类型参数的子类型关系可以安全"传递"给泛型类的类型关系。
out 关键字的语义
在 Kotlin 中,用 out 关键字声明一个类型参数为协变的:
// T 只出现在"输出"位置——函数返回值、只读属性
interface Source<out T> {
fun nextT(): T // ✅ T 在返回值位置(out 位置)
val current: T // ✅ val 只读属性——等同于 getter 的返回值
}
fun demo(strs: Source<String>) {
val objects: Source<Any> = strs // ✅ 协变:Source<String> 是 Source<Any> 的子类型
val item: Any = objects.nextT() // ✅ 返回 String,当作 Any 使用——安全
}
out 这个名字很直觉——类型参数只能朝"外"流动:从类的内部流向调用者。
编译器严格检查 out 的合法性——如果你试图让 T 出现在"输入"位置(参数类型),编译器会报错:
interface Source<out T> {
fun nextT(): T // ✅ 返回类型——out 位置
// fun consume(item: T) // ❌ 编译错误:Type parameter T is declared as 'out'
// but occurs in 'in' position in type T
}
把
out想象成一个单向出口的水龙头——水(数据)只能从水管(类)流向外面(调用者),你不能把水倒回去。因为水只会流出来,所以水管标注的是"纯净水"(String),但你用一个"液体"标签的桶(Any)去接也完全没问题——纯净水当然是液体。
Kotlin 标准库中的协变设计
Kotlin 标准库中最重要的协变接口就是 List<out E>:
// kotlin-stdlib 源码(简化)
public interface List<out E> : Collection<E> {
override val size: Int
operator fun get(index: Int): E // E 只在返回值位置
fun indexOf(element: @UnsafeVariance E): Int // 注意这里的 @UnsafeVariance
fun subList(fromIndex: Int, toIndex: Int): List<E>
// ... 没有 add()、set() 等修改方法
}
因为 List 是只读的(没有 add()、set() 等修改方法),E 只出现在返回值位置,所以声明为 out E 是安全的。这使得 List<String> 可以赋值给 List<Any>——这在日常编程中非常实用:
fun printAll(items: List<Any>) {
for (item in items) println(item)
}
val names: List<String> = listOf("Alice", "Bob")
printAll(names) // ✅ 协变——List<String> 是 List<Any> 的子类型
而 MutableList<E> 因为同时生产和消费 E,所以不能声明 out——它是不变的:
// kotlin-stdlib 源码(简化)
public interface MutableList<E> : List<E>, MutableCollection<E> {
override fun add(element: E): Boolean // E 在参数位置(in 位置)
override fun set(index: Int, element: E): E // E 同时在参数和返回值位置
}
@UnsafeVariance:有意识地违反规则
你可能注意到了 List.indexOf() 的签名中有一个 @UnsafeVariance 注解:
fun indexOf(element: @UnsafeVariance E): Int
indexOf 的参数类型是 E——但 E 被声明为 out,按规则不应该出现在参数位置。这里用 @UnsafeVariance 告诉编译器:"我知道我在做什么,请放行。"
为什么 indexOf 是安全的?因为它只读取 element 的值做比较,不会把它存入列表。indexOf 对列表的内容没有任何修改——它只是一个查询操作。从类型安全的角度看,传入一个 String 到 List<Any>.indexOf() 不会破坏任何类型约束。
@UnsafeVariance 是一个有控制的逃生舱——在你完全理解为什么特定情况是安全的前提下,可以绕过编译器的位置检查。滥用它会把编译期错误变成运行时炸弹。
编译器的"位置检查"机制
编译器如何确保 out 声明的合法性?它对类中的每一个使用 T 的位置进行分类:
| 位置 | 分类 | out T 允许? |
in T 允许? |
|---|---|---|---|
| 函数返回类型 | out 位置 | ✅ | ❌ |
val 属性类型 |
out 位置 | ✅ | ❌ |
| 函数参数类型 | in 位置 | ❌ | ✅ |
var 属性类型 |
in + out 位置 | ❌ | ❌ |
| 构造函数参数(不成为属性) | in 位置(特例豁免) | ✅¹ | ✅ |
¹ 构造函数参数是一个特例——编译器允许
out T出现在构造函数参数中,因为构造函数只在对象创建时调用一次,不存在"之后通过这个参数往里塞错误类型"的风险。
class Container<out T>(val value: T) { // ✅ 构造函数参数 + val 属性——都合法
fun get(): T = value // ✅ 返回类型
// fun set(item: T) { } // ❌ 参数类型——违反 out 位置规则
// var mutable: T = value // ❌ var 属性——同时是 in 和 out 位置
}
逆变(Contravariance):in 关键字与"只消费"承诺
直觉理解:比较器的子类型关系
逆变是协变的"镜像"——子类型关系被反转了。最直觉的例子是比较器(Comparator)。
假设你有一个能比较任何 Number 的比较器,那么它当然也能比较 Int(因为 Int 是 Number 的子类型)。也就是说:
Comparable<Number> "是" Comparable<Int> 的子类型
注意方向:Number 是 Int 的父类型,但 Comparable<Number> 反而是 Comparable<Int> 的子类型——子类型关系被反转了。
val numberComparator: Comparable<Number> = object : Comparable<Number> {
override fun compareTo(other: Number): Int = TODO()
}
// 一个能比较 Number 的比较器,当然也能比较 Int
val intComparator: Comparable<Int> = numberComparator // ✅ 逆变
in 关键字的语义
// T 只出现在"输入"位置——函数参数
public interface Comparable<in T> {
public operator fun compareTo(other: T): Int // T 只在参数位置(in 位置)
}
in 的意思是:类型参数只能朝"内"流动——从调用者流向类的内部。Comparable 只"消费" T(接受 T 作为参数做比较),从不"生产" T(不返回 T)。
把逆变想象成一台垃圾处理器。一台标注"能处理所有垃圾"(
Consumer<Any>)的处理器,当然也能处理"厨余垃圾"(Consumer<Food>)。所以"全能处理器"可以替代"厨余处理器"——虽然Any是Food的父类型,但Consumer<Any>是Consumer<Food>的子类型。子类型关系被反转了。
逆变的类型安全保证
为什么逆变只允许 T 出现在参数位置?考虑如果 T 出现在返回值位置会怎样:
// 假设 Comparable<in T> 有一个返回 T 的方法...
interface BadComparable<in T> {
fun compareTo(other: T): Int
fun getExample(): T // 假设允许这个方法
}
val numComp: BadComparable<Number> = // ...
val intComp: BadComparable<Int> = numComp // 逆变允许这个赋值
val result: Int = intComp.getExample()
// 💥 实际返回的是 Number(来自 numComp),不一定是 Int!
所以编译器禁止 in T 出现在返回值位置——确保逆变的安全性。
实际应用:函数参数的逆变
逆变在 Kotlin 中一个极为重要的应用场景是事件处理器和消费者接口:
// 事件处理器——只消费事件,不生产事件
interface EventHandler<in E> {
fun handle(event: E) // E 只在参数位置
}
open class UIEvent
class ClickEvent : UIEvent()
class LongPressEvent : UIEvent()
// 能处理所有 UIEvent 的处理器
val uiHandler: EventHandler<UIEvent> = object : EventHandler<UIEvent> {
override fun handle(event: UIEvent) {
println("处理 UI 事件: $event")
}
}
// 逆变:EventHandler<UIEvent> 是 EventHandler<ClickEvent> 的子类型
val clickHandler: EventHandler<ClickEvent> = uiHandler // ✅ 安全
clickHandler.handle(ClickEvent()) // ✅ ClickEvent 是 UIEvent 的子类型,uiHandler 能处理
三种型变的完整对比
| 型变 | Kotlin 修饰符 | Java 等价 | 子类型关系 | T 的位置限制 | 核心角色 |
|---|---|---|---|---|---|
| 不变 | (无) | 默认 | C<A> 与 C<B> 无关 |
T 可在任何位置 | 生产 + 消费 |
| 协变 | out |
? extends T |
A : B → C<A> : C<B> |
T 只在 out 位置 | 只生产 |
| 逆变 | in |
? super T |
A : B → C<B> : C<A> |
T 只在 in 位置 | 只消费 |
用一张图来表示子类型关系的方向:
类型参数: String ───→ Any (String 是 Any 的子类型)
协变 (out): List<String> ───→ List<Any> (方向相同)
逆变 (in): Comparable<Any> ───→ Comparable<String> (方向反转)
不变: MutableList<String> ╳ MutableList<Any> (没有关系)
声明处型变 vs 使用处型变
Java 的方案:在每一个使用点声明型变
Java 不支持声明处型变——型变关系只能在使用泛型的地方通过通配符(Wildcard)指定:
// Java:使用处型变——每次使用都要写通配符
void printAll(List<? extends Object> list) { // 协变
for (Object item : list) {
System.out.println(item);
}
}
void addNumbers(List<? super Integer> list) { // 逆变
list.add(1);
list.add(2);
}
这意味着 每一个使用 List 的地方,调用者都要决定是否使用 ? extends 或 ? super。对于像 List 这样只读的接口,每一个接受 List 参数的方法都需要写 List<? extends T>——因为 Java 的 List 本身是不变的。
这导致了两个问题:
- 代码冗余:相同的型变声明被重复了无数次
- 心智负担:调用者必须理解 PECS 原则才能正确使用
Kotlin 的方案:在声明处一劳永逸
Kotlin 的 out/in 写在类/接口的声明处——一次声明,所有使用点自动生效:
// Kotlin:声明处型变——接口的设计者做一次决策
public interface List<out E> : Collection<E> {
// 所有使用 List 的地方自动获得协变行为
}
// 使用者无需任何额外标注
fun printAll(items: List<Any>) { // 不需要写 List<out Any>
items.forEach { println(it) }
}
val names: List<String> = listOf("Alice", "Bob")
printAll(names) // ✅ 自动协变——因为 List 声明了 out
对比同样的代码在 Java 和 Kotlin 中的写法:
// Java:调用者的负担
public void processAll(List<? extends Number> numbers) { ... }
public void addDefaults(List<? super Integer> target) { ... }
// Kotlin:声明者的智慧
// 如果接口声明了 out/in,调用者什么都不用做
fun processAll(numbers: List<Number>) { ... } // List<out E> 已声明协变
PECS 原则在 Kotlin 中的自然表达
Java 社区总结出的 PECS(Producer Extends, Consumer Super) 原则:
- **Producer(生产者)**用
? extends T——对应 Kotlin 的out T - **Consumer(消费者)**用
? super T——对应 Kotlin 的in T
在 Java 中,PECS 是开发者需要自己记住并手动应用的规则。在 Kotlin 中,PECS 被编码进了类型系统本身——当你声明 interface Source<out T> 时,编译器会自动确保 T 只出现在 Producer 位置。
这就是为什么 Kotlin 选择声明处型变的根本原因:把复杂性从"每个调用点"转移到"一个声明点",让类的设计者承担决策责任,而不是把负担推给每一个使用者。
Kotlin 也支持使用处型变(类型投影)
当一个类型被声明为不变的(因为它既生产又消费 T),但你在某个特定使用场景下只需要它的生产能力或消费能力时,可以用**类型投影(Type Projection)**在使用处指定型变:
// Array<T> 是不变的——T 既出现在 in 位置也出现在 out 位置
// 但在某些场景下,我们只需要"从中读取"
fun copy(from: Array<out Any>, to: Array<Any>) {
// from 被投影为 out——只能读取,不能写入
for (i in from.indices) {
to[i] = from[i] // ✅ 读取 from[i]——合法
}
// from[0] = "x" // ❌ 编译错误——out 投影禁止写入
}
val strings: Array<String> = arrayOf("hello", "world")
val anys: Array<Any> = arrayOf("", "")
copy(strings, anys) // ✅ 因为 from 参数是 Array<out Any>,接受 Array<String>
反方向也可以:
fun fill(dest: Array<in String>, value: String) {
// dest 被投影为 in——只能写入,读取时返回 Any?
for (i in dest.indices) {
dest[i] = value // ✅ 写入——合法
}
// val s: String = dest[0] // ❌ 编译错误——读取只能得到 Any?
val any: Any? = dest[0] // ✅ 可以当 Any? 读取
}
编译产物:声明处型变在 JVM 上的映射
JVM 本身不理解 Kotlin 的 out/in——这些概念存在于 Kotlin 编译器的类型系统中。编译为字节码时:
声明处型变:编码在 .class 文件的 Signature 属性(元数据)中。Kotlin 编译器和 IDE 读取这些元数据来执行型变检查。对于 Java 互操作,声明处型变在公开 API 的方法签名中自动转换为通配符:
// Kotlin 源码
class Box<out T>(val value: T)
fun getBox(): Box<String> = Box("hello")
// 从 Java 视角看到的(字节码等效)
// getBox 的返回类型在 Java 中是 Box<String>
// 但当 Box<String> 被赋值给 Box<? extends Object> 时,
// Kotlin 编译器自动在签名中生成通配符
public Box<String> getBox() { ... }
使用处型变:直接映射为 Java 风格的通配符:
// Kotlin 源码
fun process(list: List<out Number>) { ... }
// JVM 字节码签名
public void process(List<? extends Number> list) { ... }
// Kotlin 源码
fun consume(list: List<in Int>) { ... }
// JVM 字节码签名
public void consume(List<? super Integer> list) { ... }
函数类型的型变:参数逆变、返回值协变
函数类型是泛型接口
在前面的文章《高阶函数与 Lambda 的底层原理》中,我们了解到 Kotlin 的函数类型 (P) -> R 在编译后是 Function1<P, R> 接口。这个接口的声明是:
// kotlin-stdlib 源码
public interface Function1<in P1, out R> : Function<R> {
public operator fun invoke(p1: P1): R
}
注意型变声明:P1 是 in(逆变),R 是 out(协变)。这意味着函数类型自带"参数逆变、返回值协变"的特性——这正是类型理论中**里氏替换原则(Liskov Substitution Principle, LSP)**在函数类型上的体现。
参数位置:逆变
一个函数如果能处理更宽泛的输入,那它当然也能处理更具体的输入:
// 能接受 Any 的函数
val handleAny: (Any) -> Unit = { println(it) }
// 需要一个能处理 String 的函数——handleAny 完全胜任
val handleString: (String) -> Unit = handleAny // ✅ (Any) -> Unit 是 (String) -> Unit 的子类型
handleString("hello") // handleAny 接收到 String,而 String 是 Any 的子类型——安全
(Any) -> Unit 是 (String) -> Unit 的子类型,因为:
- 调用
handleString("hello")时,传入的是String - 实际执行的是
handleAny,它期望Any String是Any的子类型——参数兼容- 所以替换是安全的
返回值位置:协变
一个函数如果能返回更具体的结果,那它返回的结果当然也可以当作更宽泛的类型使用:
// 返回 String 的函数
val getString: () -> String = { "hello" }
// 需要一个返回 Any 的函数——getString 完全胜任
val getAny: () -> Any = getString // ✅ () -> String 是 () -> Any 的子类型
val result: Any = getAny() // 实际返回 String,当作 Any 使用——安全
综合:函数类型的完整子类型关系
将参数逆变和返回值协变结合起来:
// (Any) -> String 是 (String) -> Any 的子类型?
// 参数:Any ← String(逆变方向正确✅)
// 返回值:String → Any(协变方向正确✅)
// 所以:是!
val f: (Any) -> String = { it.toString() }
val g: (String) -> Any = f // ✅ 合法
g("hello") // 调用 f,传入 String(是 Any 的子类型✅),返回 String(是 Any 的子类型✅)
推到极端:
// (Nothing) -> Any 是所有单参函数类型的"超类型"
// 因为:
// - 参数 Nothing 是所有类型的子类型→逆变后 Function<in Nothing> 是最宽泛的
// - 返回值 Any 是所有非空类型的超类型→协变后 Function<out Any> 是最宽泛的
函数类型的型变层级:
(Nothing) -> Any
╱ ╲
(String) -> Any (Nothing) -> String
╲ ╱
(String) -> String
协变的实际案例:Flow<out T> 为什么是协变的
Kotlin 协程库中的 Flow<T> 接口声明为协变的:
// kotlinx.coroutines.flow 源码
public interface Flow<out T> {
public suspend fun collect(collector: FlowCollector<T>)
}
Flow 是一个数据流的生产者——它只负责"发射"数据给收集者,自己不消费数据。从 Flow 的使用者视角看,你只从 Flow 中读取数据,而不往 Flow 中写入数据。
这使得以下代码自然成立:
fun processNumbers(flow: Flow<Number>) {
// ...
}
val intFlow: Flow<Int> = flowOf(1, 2, 3)
processNumbers(intFlow) // ✅ Flow<Int> 是 Flow<Number> 的子类型(协变)
协变使 Flow 的 API 更加灵活——一个处理 Flow<Number> 的函数天然就能处理 Flow<Int>、Flow<Double> 和所有其他 Number 子类型的 Flow。如果 Flow 是不变的,你就需要在每个使用点手动投影。
标准库中的协变设计一览
| 接口 | 声明 | 为什么是协变的 |
|---|---|---|
List<out E> |
只读列表 | 只有 get() 等读取操作,不修改内容 |
Set<out E> |
只读集合 | 只有 contains()、遍历等查询操作 |
Iterator<out T> |
只生产元素 | next() 返回 T,hasNext() 返回 Boolean |
Sequence<out T> |
惰性生产者 | 只生产元素供消费 |
Flow<out T> |
异步数据流 | 只向收集者发射数据 |
标准库中的逆变设计一览
| 接口 | 声明 | 为什么是逆变的 |
|---|---|---|
Comparable<in T> |
比较器 | compareTo() 只接受 T 作为参数 |
Continuation<in T> |
协程续体 | resumeWith() 只消费结果 |
完整类型系统图:Any、Nothing、泛型型变的交汇
在前面的文章《Kotlin 类型系统全解析》中,我们建立了从 Any 到 Nothing 的类型层级。现在加入泛型型变后,类型系统的全貌如下:
Any?
(一切类型的顶端)
┌────┴────┐
│ │
Any null
(非空类型根)
┌────┬────┤────┐
│ │ │ │
String Int Number ...
│ │ │
│ └────┘
│ │
│ │
│ │
Nothing
(一切类型的底端)
型变维度──泛型类型之间的子类型关系
协变 (out): List<String> ──→ List<Any>
逆变 (in): Comparable<Any> ──→ Comparable<String>
不变: MutableList<String> ╳ MutableList<Any>
函数类型:
(Nothing) -> Any (超)
↑
(String) -> String
↑
(Any) -> Nothing (子——但无实例)
型变让泛型类型的子类型关系不再是简单的"A 是 B 的子类",而是根据类型参数的使用方式(生产还是消费)动态决定的——这正是 Kotlin 类型系统精密之处。
综合实战:型变机制的字节码验证
// ① 协变接口——只生产 T
interface Producer<out T> {
fun produce(): T
}
// ② 逆变接口——只消费 T
interface Consumer<in T> {
fun consume(item: T)
}
// ③ 不变接口——既生产又消费 T
interface Processor<T> {
fun process(item: T): T
}
// ④ 协变的实际使用
class StringProducer : Producer<String> {
override fun produce(): String = "Hello"
}
// ⑤ 逆变的实际使用
class AnyConsumer : Consumer<Any> {
override fun consume(item: Any) {
println("消费: $item")
}
}
fun main() {
// 协变:Producer<String> 可以赋值给 Producer<Any>
val strProducer: Producer<String> = StringProducer()
val anyProducer: Producer<Any> = strProducer // ✅ out 协变
println(anyProducer.produce()) // "Hello"
// 逆变:Consumer<Any> 可以赋值给 Consumer<String>
val anyConsumer: Consumer<Any> = AnyConsumer()
val strConsumer: Consumer<String> = anyConsumer // ✅ in 逆变
strConsumer.consume("World") // "消费: World"
// 使用处型变(类型投影)
val mutableList: MutableList<String> = mutableListOf("a", "b", "c")
printElements(mutableList) // ✅ MutableList<String> → MutableList<out Any>
}
// ⑥ 使用处型变:用 out 投影把不变的 MutableList 变为只读的协变视图
fun printElements(list: MutableList<out Any>) {
for (item in list) {
println(item) // ✅ 可以读取——out 位置
}
// list.add("x") // ❌ 编译错误——out 投影禁止写入
}
编译产物分析:
| 源码构造 | JVM 字节码签名 | 关键行为 |
|---|---|---|
Producer<out T> |
Producer 接口,produce() 返回 Object |
T 擦除为 Object;元数据记录 out |
Consumer<in T> |
Consumer 接口,consume(Object) |
T 擦除为 Object;元数据记录 in |
anyProducer = strProducer |
直接赋值,无额外指令 | 型变在编译期验证,运行时无开销 |
strConsumer = anyConsumer |
直接赋值,无额外指令 | 同上 |
MutableList<out Any> |
List<? extends Object> |
使用处型变→Java 通配符 |
核心观察
型变检查是纯编译期行为。在运行时,Producer<String> 和 Producer<Any> 是同一个类(因为类型擦除)。out/in 修饰符不会生成任何额外的字节码指令——它们只存在于编译器的类型检查阶段和 .class 文件的元数据中。
这意味着型变是一种零运行时开销的安全机制——所有的安全保证都在编译期完成,运行时没有任何性能代价。
自行验证方法
在 IntelliJ IDEA 或 Android Studio 中:
- 打开任意 Kotlin 文件
- 菜单栏 → Tools → Kotlin → Show Kotlin Bytecode
- 点击 Decompile 按钮查看等效 Java 代码
- 重点观察:
- 协变/逆变赋值语句——在字节码中没有额外的检查指令
- 使用处型变(
out/in投影)——映射为 Java 的? extends/? super - 构造函数中
out T参数——编译为普通的Object参数
小结
本文从"直觉性错误"出发,逐层深入到了型变的编译器实现和字节码层面:
| 知识点 | 核心结论 |
|---|---|
| 子类型化问题 | 元素类型的子类型关系不能直接传递给可变泛型容器——这就是"型变"要解决的核心问题 |
| 不变型 | 默认行为——泛型类既生产又消费 T 时必须不变,否则编译器无法保证类型安全 |
协变 (out) |
只生产 T 的容器可以协变——List<String> 是 List<Any> 的子类型;编译器通过"位置检查"确保 T 只在 out 位置 |
逆变 (in) |
只消费 T 的容器可以逆变——子类型关系反转;Comparable<Number> 是 Comparable<Int> 的子类型 |
| 声明处 vs 使用处 | Kotlin 侧重声明处型变(一次声明,处处生效),Java 使用处型变(每次使用都需通配符)。Kotlin 也支持使用处型变(类型投影) |
| PECS 的自然表达 | Producer Extends = out,Consumer Super = in——在 Kotlin 中被编码进类型系统而非依赖开发者记忆 |
| 函数类型的型变 | Function1<in P, out R>——参数逆变、返回值协变,是里氏替换原则在函数类型上的精确表达 |
Flow<out T> 的设计 |
Flow 是数据生产者,声明为协变使得 Flow<Int> 可以赋值给 Flow<Number>——增加 API 灵活性 |
| 编译产物 | 型变是纯编译期行为,零运行时开销——out/in 存在于元数据和类型检查中,不生成额外字节码指令 |
理解型变的本质是"在子类型关系传递时做安全约束"——这不是语法糖,而是类型系统为了在灵活性和安全性之间取得最优平衡所做的精密设计。