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

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

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


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

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

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

java 复制代码
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>:

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

3. Java 的型变机制

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

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

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

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

3.2 使用处型变:通配符

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

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:

java 复制代码
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

kotlin 复制代码
// 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 类型不能用作返回值
}

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

kotlin 复制代码
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) 只进不出,写入一定安全

对比记忆:

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

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

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

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 星号投影 <*>

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

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

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

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

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

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

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

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

6. 实战应用

6.1 Kotlin 标准库的设计

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:

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

所以下面的赋值合法:

kotlin 复制代码
val f1: (Animal) -> Cat = { animal -> Cat() }
val f2: (Cat) -> Animal = f1    // ✅

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

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

6.3 Android 实战:通用事件回调

kotlin 复制代码
// 事件监听器:只消费事件 → 用 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 通用数据生产者

kotlin 复制代码
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 型变对比

维度 Java Kotlin
型变位置 使用处(每次用都要写) 声明处为主(定义一次,处处生效)
协变写法 ? extends T out T / 声明处 class Foo<out T>
逆变写法 ? super T in T / 声明处 class Foo<in T>
通配任意类型 <?> <*>(星号投影)
数组 协变(有运行时风险) Array<T> 不变(修正了 Java 缺陷)
逃生舱 无(使用处声明) @UnsafeVariance 注解
kotlin 复制代码
interface List<out E> {
    // contains 需要 E 作参数(in 位置),但 out E 不允许!
    // 实际上 contains 只是"比较",不会真的写入,是安全的
    fun contains(element: @UnsafeVariance E): Boolean   // ✅ 逃生舱
}
相关推荐
BBmmo1 小时前
我的 Java 学习笔记 · 第 7 篇:泛型与通配符
java
独泪了无痕1 小时前
Hutool之RandomUtil:随机数生成的终极利器
java·后端
运行时异常1 小时前
【WMS 仓储系统集成 AI Agent 实战】第 7 讲:Vue3 前端工程化——Token 刷新锁、Markdown 渲染踩坑与权限体系
java
她的男孩1 小时前
开放接口限流从 20 改到 200 还是每分钟 20 次:拆完防重放+幂等+限流,我找到 5 个静默失效的坑
java·后端·架构
天天被压力1 小时前
【Python 量化取数指南 #13】Python 把行情落库:sqlite 一键存,回测随用随取
java·人工智能·python
天天被压力1 小时前
【Python 量化取数指南 #14】Python 清洗行情数据:复权停牌对齐,回测不翻车
java·人工智能·python
独泪了无痕1 小时前
开发利器Hutool之MapUtil的使用
java·后端
mmsx1 小时前
Android 一个设备多个 App 抢:跨进程共享服务的架构设计
android·kotlin·app
唐青枫1 小时前
Java ThreadLocal 详解:从线程隔离到线程池实战,彻底避开脏数据与内存泄漏
java