4、类型变异(上):协变(out)、逆变(in)的概念与使用场景

类型变异,说白了就是解决「泛型类型之间能不能互相赋值」的问题。

我记得刚学 Kotlin 那会儿,看到 List<String> 能不能传给 List<Any> 这种问题,脑子是懵的。后来踩了几个坑才明白,这背后其实就两个核心概念:协变逆变

今天咱们先聊协变和逆变的概念,以及它们各自的使用场景。下节课再讲声明处变异和星投影,这样节奏刚刚好。

4.1 为什么需要类型变异?

先看一个简单例子:

open class Animal
class Dog : Animal()

fun handleAnimals(animals: List<Animal>) {
    // 处理动物列表
}

fun main() {
    val dogs: List<Dog> = listOf(Dog(), Dog())
    handleAnimals(dogs) // 这行能编译通过吗?
}

答案是:能通过

为什么?因为 List<T> 在 Kotlin 中是协变的。但如果你换成 MutableList<T>

fun handleMutableAnimals(animals: MutableList<Animal>) {
    animals.add(Cat()) // 假设有 Cat 类
}

fun main() {
    val dogs: MutableList<Dog> = mutableListOf(Dog(), Dog())
    handleMutableAnimals(dogs) // 编译错误!
}

这就报错了。为什么同样的泛型,一个能传一个不能传?

嗯,这里要注意:能不能传,取决于泛型参数在类中的使用位置

4.2 协变(out)—— 只读不写

协变,用 out 关键字标记。它的意思是:这个泛型参数只能出现在「输出」位置

什么叫输出位置?就是方法的返回值类型。比如:

interface Producer<out T> {
    fun produce(): T  // T 在返回值位置,OK
    // fun consume(item: T) // T 在参数位置,编译错误!
}

我在项目中遇到过这样一个场景:需要设计一个数据源接口,只提供数据,不修改数据。用协变就特别合适:

interface DataSource<out T> {
    fun fetch(): T
    fun fetchAll(): List<T>
}

class UserDataSource : DataSource<User> {
    override fun fetch(): User = User()
    override fun fetchAll(): List<User> = listOf(User())
}

fun processDataSource(source: DataSource<Any>) {
    val data = source.fetch()
    println(data)
}

fun main() {
    val userSource: DataSource<User> = UserDataSource()
    processDataSource(userSource) // 协变,可以传递
}

你想想看,DataSource<User> 能赋值给 DataSource<Any>,这就是协变的威力。

协变的核心原则:生产者(Producer)用 out。你只能从里面拿东西,不能往里面放东西。

4.3 逆变(in)—— 只写不读

逆变正好相反,用 in 关键字标记。它的意思是:这个泛型参数只能出现在「输入」位置

比如方法的参数:

interface Consumer<in T> {
    fun consume(item: T)  // T 在参数位置,OK
    // fun produce(): T // T 在返回值位置,编译错误!
}

我做过一个日志系统,需要处理不同类型的日志。用逆变就很自然:

interface Logger<in T> {
    fun log(item: T)
}

class AnyLogger : Logger<Any> {
    override fun log(item: Any) {
        println("Log: $item")
    }
}

fun logError(logger: Logger<Throwable>, error: Throwable) {
    logger.log(error)
}

fun main() {
    val anyLogger: Logger<Any> = AnyLogger()
    logError(anyLogger, RuntimeException("Oops")) // 逆变,可以传递
}

这里 Logger<Any> 能赋值给 Logger<Throwable>,因为 Any 是 Throwable 的父类。逆变的方向和继承方向相反,所以叫「逆」变。

记忆小技巧

  • out = 输出 = 协变 = 子类可以当父类用
  • in = 输入 = 逆变 = 父类可以当子类用

4.4 使用场景对比

场景 使用 out 使用 in
数据生产者 ✅ 非常适合 ❌ 不适合
数据消费者 ❌ 不适合 ✅ 非常适合
不可变集合 ✅ List、Set ❌ 不适用
比较器 ❌ 不适用 ✅ Comparator
事件处理器 ❌ 不适用 ✅ 非常适合

4.5 避坑指南

我曾经犯过的错

有一次我设计了一个缓存接口,既想用协变让子类缓存能赋值给父类缓存,又想在接口里提供「更新缓存」的方法。结果编译死活过不去。

后来才明白:一个泛型参数不能同时是 out 和 in。要么只读,要么只写,不能又当又立。

如果你确实需要既能读又能写,那就别用变异了,老老实实写 MutableList<T> 这种不变的类型。

4.6 实际项目中的选择

我个人习惯这样判断:

  • 数据层:Repository 接口用 out,因为只提供数据
  • UI 层:Adapter 用 in,因为只消费数据
  • 工具类:Comparator、Predicate 用 in
  • 集合类:不可变集合用 out,可变集合用不变

举个例子,我最近写的一个网络请求库:

// 响应解析器 - 只读,用 out
interface ResponseParser<out T> {
    fun parse(raw: String): T
}

// 请求构建器 - 只写,用 in
interface RequestBuilder<in T> {
    fun build(body: T): Request
}

这样设计的好处是:调用方可以灵活地传递子类型或父类型,代码复用性大大提高。

4.7 小结

协变和逆变,说白了就是 Kotlin 帮你做类型安全检查的两种策略:

  • 协变(out):只能产出,不能消费。子类可以当父类用。
  • 逆变(in):只能消费,不能产出。父类可以当子类用。

记住一个口诀:生产者用 out,消费者用 in。下次写泛型接口的时候,先想想这个接口是「给数据」还是「收数据」,答案就出来了。

下节课咱们聊声明处变异和星投影,到时候会看到更多实战中的骚操作。今天就先到这里,有问题欢迎留言讨论。