深入理解 Java/Kotlin 协变与逆变

21 阅读6分钟

深入理解 Java/Kotlin 协变与逆变

型变(Variance)是泛型编程中最容易让人困惑的概念之一。本文从问题出发,讲清原理,落到实战。


1. 引子:一个"理所当然"的错误

// Java
List<Cat> cats = new ArrayList<>();
List<Animal> animals = cats;  // ❌ 编译错误!

直觉上 Cat 是 Animal 的子类,List<Cat> 应该也能赋值给 List<Animal>。但编译器拒绝了——为什么?

List<Animal> animals = cats;
animals.add(new Dog());        // 合法! Animal 列表可以加 Dog
Cat cat = cats.get(0);         // 💥 取出一只 Dog,却以为是 Cat

如果允许这种赋值,类型安全就会被破坏。 这就是"型变"要解决的问题:如何在保持类型安全的前提下,让泛型也能享受继承带来的灵活性。


2. 三种型变关系

假设 Cat extends Animal,对于泛型 Box<Cat> 和 Box<Animal>:

型变关系含义
协变 CovarianceBox<Cat> 是 Box<Animal> 的子类型继承方向保持一致
逆变 ContravarianceBox<Animal> 是 Box<Cat> 的子类型继承方向发生反转
不变 Invariance两者没有任何继承关系默认行为
协变: Cat → Animal  ⟹  Producer<Cat> → Producer<Animal>   (方向一致 ↑↑)
逆变: Cat → Animal  ⟹  Consumer<Animal> → Consumer<Cat>   (方向反转 ↑↓)
不变: Cat → Animal  ⟹  Box<Cat> ✗ Box<Animal>             (毫无关系)

3. Java 的型变机制

3.1 数组协变:一个历史遗留的坑

Java 数组是协变的,这在编译期不会报错,但运行时可能崩溃:

Cat[] cats = {new Cat(), new Cat()};
Animal[] animals = cats;       // ✅ 编译通过(数组协变)
animals[0] = new Dog();        // 💥 运行时 ArrayStoreException!

数组在运行时仍记得自己是 Cat[],存入 Dog 时抛出异常。这是 Java 早期为了兼容没有泛型的代码而做的妥协,后来被泛型的"不变"设计纠正。

3.2 使用处型变:通配符

Java 泛型默认不变,灵活性靠使用处型变(在使用泛型的地方声明):

// ? extends T —— 协变:可以接受 T 及其子类
void feedAll(List<? extends Animal> animals) {
    for (Animal a : animals) {
        a.eat();                 // ✅ 读取安全:一定读出 Animal
    }
    // animals.add(new Cat());   // ❌ 编译错误:无法确定具体类型
}

// ? super T —— 逆变:可以接受 T 及其父类
void addCats(List<? super Cat> list) {
    list.add(new Cat());         // ✅ 写入安全:Cat 可以放进任何父类容器
    // Cat c = list.get(0);      // ❌ 只能以 Object 接收
}

口诀:extends 只能读,super 只能写。

3.3 PECS 原则

Producer Extends, Consumer Super —— 生产者用 extends,消费者用 super。

JDK 经典案例 Collections.copy:

public static <T> void copy(List<? super T> dest,   // dest 是消费者:写入
                            List<? extends T> src)  // src 是生产者:读取
{
    // src 生产数据(读) → dest 消费数据(写)
    for (int i = 0; i < src.size(); i++)
        dest.set(i, src.get(i));
}

// 使用:
List<Animal> animals = new ArrayList<>();
List<Cat> cats = List.of(new Cat());
Collections.copy(animals, cats);  // ✅ Cat 列表 → Animal 列表

4. Kotlin 的型变机制

Kotlin 认为 Java 的"每次使用都要写通配符"太啰嗦,于是把型变声明移到了类的定义处。

4.1 声明处型变:out 与 in

// out T —— 协变:该类只能"生产"T(作为返回值)
interface Producer<out T> {
    fun produce(): T          // ✅ T 出现在返回位置
    // fun consume(t: T)      // ❌ 编译错误:out 类型不能用作参数
}

// in T —— 逆变:该类只能"消费"T(作为参数)
interface Consumer<in T> {
    fun consume(t: T)         // ✅ T 出现在参数位置
    // fun produce(): T       // ❌ 编译错误:in 类型不能用作返回值
}

使用时就无需再写通配符:

val catProducer: Producer<Cat> = ...
val animalProducer: Producer<Animal> = catProducer   // ✅ 协变

val animalConsumer: Consumer<Animal> = ...
val catConsumer: Consumer<Cat> = animalConsumer      // ✅ 逆变

4.2 位置限制规则

声明允许出现的位置编译器的保证
out T(协变)仅返回值位置(out-position)只出不进,读取一定安全
in T(逆变)仅参数位置(in-position)只进不出,写入一定安全

对比记忆:

out T  ≈  Java 的 ? extends T   (生产者,往外给)
in  T  ≈  Java 的 ? super  T    (消费者,往里收)

4.3 使用处型变(类型投影)

当类本身没有声明型变时,Kotlin 也支持在使用处投影:

class Box<T>(var value: T)          // 默认不变

fun copy(from: Box<out Animal>, to: Box<in Animal>) {
    to.value = from.value           // ✅ 读 from(协变),写 to(逆变)
}

4.4 星号投影 <*>

val list: MutableList<*> = mutableListOf("a", 1, true)
// 等价于 MutableList<out Any?>:只能读(读出来是 Any?),不能写

5. 原理:为什么"只出不进"是安全的

型变的本质,是编译器用位置限制换取子类型关系。

协变 Box<out T> 能安全地当 Box<out Animal> 用,前提是:
  你只能通过它"拿"东西 —— 拿到的一定是 Animal ✓
  禁止"放"东西 —— 否则可能往 Box<Cat> 里放 Dog ✗

逆变 Box<in T> 能安全地当 Box<in Cat> 用,前提是:
  你只能通过它"放"东西 —— 放 Cat 进 Animal 容器没问题 ✓
  禁止"拿"东西 —— 拿出来的可能是任何 Animal,不保证是 Cat ✗

记忆技巧:把泛型容器想成仓库。

Producer<out T> = 供货仓库
    只能出货 ──► 给出来的什么都是 Animal,安全 ✓

Consumer<in T> = 收货仓库
    只能收货 ──► Cat 一定能放进 Animal 的仓库,安全 ✓

6. 实战应用

6.1 Kotlin 标准库的设计

// List 只读 → 声明为协变
interface List<out E> : Collection<E>

// MutableList 可读写 → 必须不变
interface MutableList<E> : List<E>, MutableCollection<E>

// 因此:
val cats: List<Cat> = listOf(Cat())
val animals: List<Animal> = cats                    // ✅ 协变

val mCats: MutableList<Cat> = mutableListOf(Cat())
// val mAnimals: MutableList<Animal> = mCats        // ❌ 不变,编译错误

设计启示:只读接口加 out,只写接口加 in,读写都有就保持不变。

6.2 Function 类型的型变签名

Kotlin 函数类型的定义完美体现了 PECS:

// 参数用 in(消费参数),返回值用 out(生产结果)
interface Function1<in P, out R> {
    operator fun invoke(p: P): R
}

所以下面的赋值合法:

val f1: (Animal) -> Cat = { animal -> Cat() }
val f2: (Cat) -> Animal = f1    // ✅

// 分析:
//   参数: (Animal) 可以当 (Cat) 用 → 逆变 (传 Cat 进去,f1 收 Animal 没问题)
//   返回值: Cat 可以当 Animal 用  → 协变 (返回 Cat 就是返回 Animal)

记忆:参数逆变,返回值协变(函数类型也是"消费参数、生产结果")。

6.3 Android 实战:通用事件回调

// 事件监听器:只消费事件 → 用 in
interface OnEventListener<in T> {
    fun onEvent(event: T)
}

open class BaseEvent
class ClickEvent : BaseEvent()

// 一个监听所有基础事件的监听器,可以直接用于 ClickEvent
val baseListener = object : OnEventListener<BaseEvent> {
    override fun onEvent(event: BaseEvent) = println("收到: $event")
}

val clickListener: OnEventListener<ClickEvent> = baseListener  // ✅ 逆变
clickListener.onEvent(ClickEvent())   // 输出: 收到: ClickEvent@...

6.4 通用数据生产者

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

class CatDataSource : DataSource<Cat> {
    override fun load() = listOf(Cat(), Cat())
}

// 可以统一交给处理 Animal 的函数
fun printAll(source: DataSource<Animal>) {
    source.load().forEach { println(it) }
}

printAll(CatDataSource())   // ✅ 协变:DataSource<Cat> → DataSource<Animal>

7. 对比总结与面试速记

Java vs Kotlin 型变对比

维度JavaKotlin
型变位置使用处(每次用都要写)声明处为主(定义一次,处处生效)
协变写法? extends Tout T / 声明处 class Foo<out T>
逆变写法? super Tin T / 声明处 class Foo<in T>
通配任意类型<?><*>(星号投影)
数组协变(有运行时风险)Array<T> 不变(修正了 Java 缺陷)
逃生舱无(使用处声明)@UnsafeVariance 注解
interface List<out E> {
    // contains 需要 E 作参数(in 位置),但 out E 不允许!
    // 实际上 contains 只是"比较",不会真的写入,是安全的
    fun contains(element: @UnsafeVariance E): Boolean   // ✅ 逃生舱
}