Kotlin泛型型变:深入理解`in`与`out`背后的类型安全之道

364 阅读4分钟

一句话总结:

outin是Kotlin用来保证泛型类型安全的“交通规则”。out规定了“生产/输出”通道(协变),in规定了“消费/输入”通道(逆变),其根本目的是防止你在类型转换后,做出“把香蕉放进苹果箱”这样的危险操作。


一、问题的根源:List<苹果>List<水果>吗?

在开始之前,我们先思考一个问题:如果苹果水果的子类,那么一个装满苹果的列表List<苹果>,能否被当作一个List<水果>来使用?

直觉上是可以的,因为从里面拿出来的每个苹果“都是”水果。但如果这是一个可写的MutableList呢?

val appleList: MutableList<苹果> = mutableListOf(苹果())
val fruitList: MutableList<水果> = appleList // 编译错误!如果这行能通过...
fruitList.add(香蕉()) // ...那么我们就能把香蕉放进一个只能装苹果的列表里!
val apple: 苹果 = appleList[1] // ...从里面取出来时,就会发生类型转换异常!

为了在编译期就杜绝这种风险,Kotlin泛型默认是不变的 (Invariant) 。而outin就是我们告诉编译器“在特定场景下可以安全地放宽限制”的方式。


二、型变的两种“玩法”:声明处 vs. 使用处

1. 声明处型变 (Declaration-site Variance): 给你的类预设角色

当你自己设计一个泛型类或接口时,可以预先声明它的型变特性。

  • out - 生产者 (Producer) :如果你的类只用于“生产”或“提供”T类型的值。

    // 水果箱只出不进,是天生的生产者
    interface 水果箱<out T> {
        fun 拿出水果(): T
        // fun 放入水果(item: T) // 编译错误!T不能出现在输入位置
    }
    

    因为编译器知道这个箱子是“只出不进”的,所以它允许我们安全地进行协变转换:水果箱<苹果>可以被赋值给水果箱<水果>

  • in - 消费者 (Consumer) :如果你的类只用于“消费”或“处理”T类型的值。

    // 垃圾桶只进不出,是天生的消费者
    interface 垃圾桶<in T> {
        fun 扔垃圾(item: T)
        // fun 取出垃圾(): T // 编译错误!T不能出现在输出位置
    }
    

    因为编译器知道这个垃圾桶是“只进不出”的,所以它允许我们安全地进行逆变转换:垃圾桶<水果>可以被赋值给垃圾桶<香蕉皮>。(你可以用一个能装任何水果的垃圾桶,来专门装香蕉皮)。

2. 使用处型变 (Use-site Variance): 临时赋予角色

对于本身是不变的泛型类(比如MutableList<T>,因为它既能读又能写),我们可以在使用它的时候,临时“投影”成一个生产者或消费者。

fun copyFrom(source: List<out 水果>, destination: MutableList<in 水果>) {
    for (fruit in source) {
        destination.add(fruit)
    }
}

val appleList: List<苹果> = listOf(苹果())
val anyFruitList: MutableList<水果> = mutableListOf()

// 在调用时,编译器允许:
// 1. 将`List<苹果>`当作`List<out 水果>` (生产者)
// 2. 将`MutableList<水果>`当作`MutableList<in 水果>` (消费者)
copyFrom(appleList, anyFruitList) 

这里的outin,就是在使用时向编译器做出的“临时承诺”,让不变的类型也能灵活地用于协变或逆变场景。


三、型变的指导原则:PECS

记住这个行业标准缩写,能帮你做出正确的设计:PECS - Producer-out, Consumer-in

  • 当你的泛型参数T主要用于返回值(生产T),就用out
  • 当你的泛型参数T主要用于函数参数(消费T),就用in
  • 如果既用于参数又用于返回值,那就保持不变(不加关键字)。

四、Kotlin标准库中的实战

  • List<out E>List接口是只读的,它是一个天生的生产者,所以被声明为out协变。
  • Comparable<in T>Comparable接口用于消费一个T类型的对象来进行比较(compareTo(other: T)),所以被声明为in逆变。
  • MutableList<E>:这个接口既有get(index: Int): E(生产)又有add(element: E)(消费),所以它必须是不变的

结论

inout远不止是“只读/只写”的语法糖,它们是Kotlin类型系统中实现编译期安全使用灵活性之间精妙平衡的核心机制。理解它们背后的“生产者/消费者”模型和两种型变场景,能让你在设计和使用泛型时,写出既灵活又绝对安全的代码,真正掌握这门语言的精髓。