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>,这就是协变的威力。
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。下次写泛型接口的时候,先想想这个接口是「给数据」还是「收数据」,答案就出来了。
下节课咱们聊声明处变异和星投影,到时候会看到更多实战中的骚操作。今天就先到这里,有问题欢迎留言讨论。