大白话讲解 Kotlin协变与逆变

核心问题:型变解决的是"泛型类型的继承关系"问题。

想象一下你有一个盒子(泛型类):

  1. Box<Apple> :一个专门装苹果的盒子。
  2. Box<Fruit> :一个可以装任何水果(苹果、香蕉、橙子...)的盒子。

直觉上你会觉得:既然 AppleFruit 的子类型,那 Box<Apple> 应该也是 Box<Fruit> 的子类型吧? 这样我就能把一盒苹果当成一盒水果来用(比如把苹果盒递给一个需要水果盒的地方)。

但默认情况下(不变 / Invariant),Kotlin (和 Java) 认为 Box<Apple>Box<Fruit> 是完全没有关系的两种类型!

kotlin 复制代码
class Box<T>(var item: T)

fun main() {
    val appleBox: Box<Apple> = Box(Apple())
    val fruitBox: Box<Fruit> = Box(Fruit())

    // 编译错误!Type mismatch: required Box<Fruit>, found Box<Apple>
    // fruitBox = appleBox // 不允许:不能把苹果盒赋值给水果盒变量
}

为什么不允许?安全!

假设允许 fruitBox = appleBox,那么 fruitBox 这个变量现在指向的实际上是一个 Box<Apple>。看起来没问题?危险在于写入操作!

kotlin 复制代码
// 假设上面错误的赋值被允许了
fruitBox = appleBox // (错误赋值,假设编译器允许了)
// 现在 fruitBox 变量类型是 Box<Fruit>,但实际对象是 Box<Apple>

// 然后我往这个"水果盒"里放一个香蕉 (Banana 也是 Fruit)
fruitBox.item = Banana() // !!! 灾难 !!!

// 但实际上,appleBox 里的 item 必须是 Apple 类型!
// 现在 appleBox.item 被塞进了一个 Banana,程序在运行时会崩溃!
val apple: Apple = appleBox.item // 试图取出苹果,结果拿到香蕉 -> ClassCastException!

默认不变就是为了防止这种"把香蕉放进苹果盒"的错误。 编译器不知道你的 Box 是只用来读 (get) 还是只用来写 (set),所以它最安全的做法就是禁止 Box<Apple>Box<Fruit> 之间的赋值。


型变的本质:告诉编译器更多信息,放宽限制(在保证安全的前提下)

我们需要一种方式告诉编译器:"我的这个泛型类,在某些场景下是安全的,你可以允许 Box<Apple> 当作 Box<Fruit> 来用,或者反过来"。这就是 outin 的作用。

1. 协变 (Covariant) - out (生产者,只取不出)

  • 含义: 如果 AB 的子类型,那么 Producer<A> 就是 Producer<B> 的子类型。

  • 关键词: out

  • 位置: 在声明泛型类/接口时使用(声明处型变)。

  • 作用: 放宽输出(读取)的限制。

  • 限制: 标记了 out 的类型参数 T 只能出现在输出位置(方法的返回值类型) ,不能出现在输入位置(方法的参数类型)。编译器会严格检查这点。

  • 为什么安全? 因为 T 只用于输出(你只能从里面拿东西出来),不能往里塞东西。既然不能往里塞,就不可能发生"把香蕉塞进苹果盒"的错误。

  • 生活比喻: 生产者(Producer) 。想象一个水果榨汁机 Juicer<out T: Fruit>。你给它水果(T 的子类型),它给你果汁。你放苹果进去,它产苹果汁;你放橙子进去,它产橙汁。重要的是:

    • 输出安全: 它承诺产出的果汁是 T 类型(或子类型)。如果它是 Juicer<Apple>,它保证产出苹果汁。你把它当作 Juicer<Fruit> 使用时,你知道它产出的至少是果汁(可能是苹果汁、橙汁等),这完全没问题。
    • 输入限制: 它本身不让你往果汁出口塞水果(T 不能作为输入参数类型),所以安全。
  • Kotlin 例子:List<out E>

    • List 在 Kotlin 标准库中声明为 interface List<out E>

    • 为什么?因为 List 在 Kotlin 默认是只读 的!你只能从 Listget 元素(输出),不能 add 元素(输入 - 修改操作在 MutableList 里)。

    • 所以,它是安全的!List<Apple> 可以被当作 List<Fruit> 使用:

      kotlin 复制代码
      val apples: List<Apple> = listOf(Apple(), Apple())
      val fruits: List<Fruit> = apples // 成功!协变允许。因为只能读
      val firstFruit: Fruit = fruits[0] // 安全,取出来的是 Apple (也是 Fruit)
      // fruits.add(Banana()) // 错误!Kotlin 的 List 没有 add 方法。安全!

2. 逆变 (Contravariant) - in (消费者,只存不取)

  • 含义: 如果 AB 的子类型,那么 Consumer<B> 就是 Consumer<A> 的子类型。注意方向反了!

  • 关键词: in

  • 位置: 在声明泛型类/接口时使用(声明处型变)。

  • 作用: 放宽输入(写入)的限制。

  • 限制: 标记了 in 的类型参数 T 只能出现在输入位置(方法的参数类型) ,不能出现在输出位置(方法的返回值类型)。编译器会严格检查这点。

  • 为什么安全? 因为 T 只用于输入(你只能往里面塞东西),不能从里面拿东西出来(除了像 Any? 这种绝对安全的类型)。既然不能拿出来用,就不可能发生"期望拿到苹果却拿到香蕉"的错误。

  • 生活比喻: 消费者(Consumer) 。想象一个垃圾桶 TrashCan<in T>。你只能往里面扔垃圾(T 或其父类型)。如果它是 TrashCan<Fruit>

    • 你可以扔水果(Fruit)、苹果(Apple)、香蕉(Banana)、甚至厨余垃圾(如果 FoodWaste > Fruit)------ 只要是 T (Fruit) 或其父类型都行。

    • 现在,把它当作 TrashCan<Apple> 使用(逆变允许 TrashCan<Fruit>TrashCan<Apple> 的子类型!):

      css 复制代码
      val fruitTrashCan: TrashCan<Fruit> = TrashCan()
      val appleTrashCan: TrashCan<Apple> = fruitTrashCan // 逆变允许!TrashCan<Fruit> 可以赋值给 TrashCan<Apple> 变量
      
      // 往"苹果垃圾桶"里扔一个苹果 (实际对象是 TrashCan<Fruit>)
      appleTrashCan.throwIn(Apple()) // 安全!Apple 是 Fruit
      // 往"苹果垃圾桶"里扔一个香蕉 (实际对象是 TrashCan<Fruit>)
      appleTrashCan.throwIn(Banana()) // 安全!Banana 也是 Fruit。苹果垃圾桶承诺能处理苹果,但实际它能处理所有水果,处理香蕉当然也没问题!
    • 关键安全点:

      • appleTrashCan 变量要求你只能扔 Apple (或其子类型)。
      • 但实际指向的 fruitTrashCan 能处理所有 Fruit (包括 AppleBanana)。
      • 所以,当你通过 appleTrashCanApple 时,fruitTrashCan 完全能处理。
      • 当你通过 appleTrashCanBanana 时,虽然 appleTrashCan 变量类型只承诺处理 Apple,但扔 Banana 在语法上符合 appleTrashCan 的类型要求吗?不符合! 编译器会根据变量类型 TrashCan<Apple> 检查,throwIn(Banana()) 会报错:Banana 不是 Apple。所以你根本没法通过 appleTrashCan 变量往里扔香蕉! 安全由调用方的类型检查保证了。
      • 同时,TrashCan 不能让你从里面拿出一个具体的 T(比如 Apple),因为 T 只用于输入。你只能知道里面都是垃圾 (Any?)。所以不可能发生类型错误。
  • Kotlin 例子:Comparable<in T>

    • Comparable 接口声明:interface Comparable<in T> { fun compareTo(other: T): Int }

    • 为什么是 incompareTo 只消费 T (作为参数输入) ,不生产 T (不返回 T)。

    • 作用:允许更广泛的比较。

      kotlin 复制代码
      open class Fruit
      class Apple : Fruit()
      
      val appleComparator: Comparable<Apple> = object : Comparable<Apple> {
          override fun compareTo(other: Apple): Int { ... } // 比较两个苹果
      }
      
      // 逆变允许:Comparable<Apple> 是 Comparable<Fruit> 的子类型?
      // 不!记住逆变含义:如果 Apple 是 Fruit 的子类型,那么 Comparable<Fruit> 是 Comparable<Apple> 的子类型。
      // 所以,一个能比较任意水果 (Fruit) 的比较器,可以被当作一个只能比较苹果 (Apple) 的比较器来使用:
      val fruitComparator: Comparable<Fruit> = ... // 能比较任何水果
      val appleComp: Comparable<Apple> = fruitComparator // 逆变允许!安全
      
      val apple1 = Apple()
      val apple2 = Apple()
      appleComp.compareTo(apple1, apple2) // 安全!fruitComparator 当然能比较两个苹果
      • 一个要求比较 Fruit 的函数,可以传入一个 Comparable<Fruit>
      • 一个要求比较 Apple 的函数,根据逆变,也可以传入一个 Comparable<Fruit> (因为它能处理 Fruit,处理 Apple 更没问题)。

总结:关键区别与记忆口诀

特性 协变 (out) 逆变 (in)
关键字 out in
含义 Producer<A>Producer<B> 的子类型 (当 AB 的子类型) Consumer<B>Consumer<A> 的子类型 (当 AB 的子类型)
方向 子类型关系与 T 一致 (A -> B) 子类型关系与 T 相反 (B -> A)
T 的位置 只能输出 (返回值) 只能输入 (参数)
角色 生产者 (Producer) - 你 它那里获取 T 消费者 (Consumer) - 你提供 T
安全保证 不能写入 T (防止污染) 不能读取 T (除 Any? 外) (防止误用)
常见例子 Kotlin List<out E>, Source<out T> Kotlin Comparable<in T>, Consumer<in T>
记忆口诀 OUT = OUTPUT = 输出 = 只取不出 = 生产东西 IN = INPUT = 输入 = 只存不取 = 消费东西
子类型赋值 Producer<Apple> 可赋给 Producer<Fruit> Consumer<Fruit> 可赋给 Consumer<Apple>

简单粗暴记忆法:

  • 看到 out:想着这个类像个只读的仓库 或者水果榨汁机 ,你只能从里面拿东西(T) 出来用。它生产 T。所以 Box<Apple> 可以安全地当作 Box<Fruit> 用。
  • 看到 in:想着这个类像个垃圾桶 或者比较器 ,你只能往里面塞东西(T) 。它消费 T。所以 TrashCan<Fruit> (能处理所有水果的桶) 可以安全地当作 TrashCan<Apple> (只能处理苹果的桶) 来用(因为大桶的容量完全覆盖小桶的需求)。

核心思想: outin给编译器的承诺和安全约束 。通过限制类型参数 T 在类中出现的位置 (只能输出或只能输入),编译器就能在保证类型安全的前提下,允许更灵活的泛型类型赋值(协变或逆变),让你的代码更通用。记住 PECS (Producer-Extends, Consumer-Super) 原则(来自Java,对应Kotlin的 outin)也很有帮助:生产者用 out (extends), 消费者用 in (super)。理解了这个比喻和限制,型变就不再迷糊了!

相关推荐
江号软件分享40 分钟前
有效保障隐私,如何安全地擦除电脑上的敏感数据
前端
web守墓人2 小时前
【前端】ikun-markdown: 纯js实现markdown到富文本html的转换库
前端·javascript·html
Savior`L2 小时前
CSS知识复习5
前端·css
许白掰2 小时前
Linux入门篇学习——Linux 工具之 make 工具和 makefile 文件
linux·运维·服务器·前端·学习·编辑器
中微子6 小时前
🔥 React Context 面试必考!从源码到实战的完整攻略 | 99%的人都不知道的性能陷阱
前端·react.js
中微子7 小时前
React 状态管理 源码深度解析
前端·react.js
加减法原则8 小时前
Vue3 组合式函数:让你的代码复用如丝般顺滑
前端·vue.js
yanlele9 小时前
我用爬虫抓取了 25 年 6 月掘金热门面试文章
前端·javascript·面试
lichenyang4539 小时前
React移动端开发项目优化
前端·react.js·前端框架
你的人类朋友9 小时前
🍃Kubernetes(k8s)核心概念一览
前端·后端·自动化运维