Java中协变逆变的实现与Kotlin中的区别
一、核心概念回顾:协变与逆变
协变 (Covariance):如果
Cat
是Animal
的子类型,那么Producer<Cat>
也是Producer<Animal>
的子类型。它适用于只读(输出)场景。逆变 (Contravariance):如果
Cat
是Animal
的子类型,那么Consumer<Animal>
是Consumer<Cat>
的子类型。它适用于只写(输入)场景。不变 (Invariance):
Box<Cat>
和Box<Animal>
没有关系。它适用于既可读又可写的场景。
二、Java 的实现:使用处变型 (Use-site Variance)
Java 的变型规则通过通配符(Wildcards) 在使用泛型的地方(如方法参数、局部变量声明)来指定。这意味着变型规则是由API的调用者或使用者来决定的。
1. 语法与实现
协变:
<? extends T>
// 声明一个协变的List,它只能被读取 List<? extends Number> numbers = new ArrayList<Integer>(); Number num = numbers.get(0); // OK, 安全地读取为Number // numbers.add(100); // 编译错误!不能写入
逆变:
<? super T>
// 声明一个逆变的List,它只能被写入 List<? super Integer> list = new ArrayList<Number>(); list.add(100); // OK, 安全地写入Integer // Integer i = list.get(0); // 编译错误!不能安全读取 Object obj = list.get(0); // 唯一能读取的方式
2. 特点与影响
规则在使用处指定:每次声明一个泛型变量或参数时,你都需要思考并使用
extends
或super
。这导致了著名的 PECS (Producer-Extends, Consumer-Super) 原则。灵活性高:同一个泛型类(如
ArrayList
)可以在不同的使用场景下被当作协变、逆变或不变的。语法噪音大:代码中会充斥大量通配符,使得签名变得复杂。
// 一个复杂的Java方法签名,包含了PECS原则 public static <T> void copy(List<? super T> dest, List<? extends T> src) {for (int i = 0; i < src.size(); i++) {dest.set(i, src.get(i));} }
三、Kotlin 的实现:声明处变型 & 使用处变型 (Declaration-site & Use-site Variance)
Kotlin 同时支持两种方式,但其核心创新和更推荐的是声明处变型。这意味着变型规则是由API的设计者在定义泛型类的时候就规定好的。
1. 声明处变型 (Declaration-site Variance) - 核心特性
在定义类或接口时,使用变型修饰符 out
或 in
。
协变:
out T
修饰符
out
表示这个泛型类Producer<T>
中的T
只用于输出(作为函数的返回类型)。这相当于向编译器承诺:”这个类绝对不会消费
T
类型的对象,只会生产它们“。效果:
Producer<Cat>
自动成为Producer<Animal>
的子类型,无需任何通配符。
// 1. 在声明类时,使用 `out` 将其定义为协变 interface Producer<out T> { // 注意这里的 out 关键字fun produce(): T // T 只出现在 out 位置(返回值)// fun consume(item: T): Unit // 编译错误!T 不能出现在 in 位置(参数) }// 2. 使用:无需任何额外语法,直接赋值 val catProducer: Producer<Cat> = ... val animalProducer: Producer<Animal> = catProducer // ✅ OK! 因为 T 是 out的 val animal: Animal = animalProducer.produce()
Kotlin 标准库中的
List
接口就是只读的,其泛型参数被声明为out
。interface List<out E> : Collection<E> { ... } // 因此 List<String> 是 List<Any?> 的子类型
逆变:
in T
修饰符
in
表示这个泛型类Consumer<T>
中的T
只用于输入(作为函数的参数类型)。这相当于向编译器承诺:”这个类只会消费
T
类型的对象,不会生产它们“。效果:
Consumer<Animal>
自动成为Consumer<Cat>
的子类型。
// 1. 在声明类时,使用 `in` 将其定义为逆变 interface Consumer<in T> { // 注意这里的 in 关键字fun consume(item: T): Unit // T 只出现在 in 位置(参数)// fun produce(): T // 编译错误!T 不能出现在 out 位置(返回值) }// 2. 使用:无需任何额外语法,直接赋值 val animalConsumer: Consumer<Animal> = ... val catConsumer: Consumer<Cat> = animalConsumer // ✅ OK! 因为 T 是 in的 catConsumer.consume(Cat()) // 实际调用的是 animalConsumer.consume(Animal)
Kotlin 标准库中的
Comparable
接口就是逆变的。interface Comparable<in T> { // 因此 Comparable<Any> 是 Comparable<String> 的子类型operator fun compareTo(other: T): Int }
2. 使用处变型 (Use-site Variance):类型投影 (Type Projection)
Kotlin 也提供了类似 Java 通配符的功能,用于在使用处临时改变型变规则,这被称为类型投影。
语法:在具体使用的地方使用
out
或in
。目的:用于处理那些在定义时是不变的泛型类(如
MutableList
),但在某个特定函数中,你只想以安全的方式使用它。
// 假设MutableList是不变的(它本来就是,因为既可读又可写)
fun copy(from: MutableList<out Animal>, to: MutableList<in Animal>) {// 这里,我们临时地将 'from' 投影为一个【生产者】// 意味着我们可以从 from 中安全地【读取】Animalfor (animal in from) {// 这里,我们临时地将 'to' 投影为一个【消费者】// 意味着我们可以向 to 中安全地【写入】Animalto.add(animal)}// from.add(Cat()) // 编译错误!'from' 被投影为 out,不能写入// val item: Animal = to[0] // 编译错误!'to' 被投影为 in,不能安全读取
}val cats: MutableList<Cat> = mutableListOf(Cat(), Cat())
val animals: MutableList<Animal> = mutableListOf(Dog())copy(cats, animals) // ✅ OK! 因为使用了类型投影
四、核心区别总结
特性 | Java | Kotlin |
---|---|---|
核心机制 | 使用处变型 (Use-site) | 声明处变型 (Declaration-site) 为主,使用处变型(类型投影)为辅 |
语法关键字 | ? extends T (协变), ? super T (逆变) | out T (协变), in T (逆变) |
决策者 | API的调用者/使用者 | API的设计者(声明处),或调用者(使用处投影) |
代码风格 | PECS原则,通配符大量出现在方法签名中 | 更简洁、更直观。泛型类自身声明其变型性质,使用时常无需额外修饰 |
List<String> 能否赋值给 List<Object> | 不能。必须写为 List<? extends Object> | 可以,但前提是Kotlin的 List 接口已声明为 interface List<out E> |
核心思想 | “我怎么使用你这个不变的盒子?” | “我是一个什么样的盒子?” |
五、常见问题总结
Q:“Java 和 Kotlin 在实现泛型的协变和逆变上有什么主要区别?”
A:
Java 使用的是‘使用处变型’。规则由API的调用者决定。它通过通配符 ? extends T
和 ? super T
在使用泛型的地方(如方法参数)来指定变型规则。这非常灵活,但导致了复杂的方法签名和需要牢记PECS原则。
Kotlin 优先采用‘声明处变型’。规则由API的设计者决定。它在定义泛型类或接口时,使用 out
(协变)和 in
(逆变)修饰符来规定该类的泛型参数是只用于输出还是只用于输入。这样,在使用时就直接具备协变或逆变的赋值能力,代码非常简洁直观。Kotlin 的 List
是只读的、协变的,就是因为其泛型参数被声明为 out
。
当然,Kotlin 也提供了类似 Java 的使用处变型,称为类型投影,使用 MutableList<out T>
这样的语法,用于临时处理那些本身是不变的泛型类。
总的来说,Java 的策略是‘使用时再告诉编译器规则’,而 Kotlin 的策略是‘设计时就声明好规则,使用时直接享受其好处’。这使得 Kotlin 代码在泛型方面通常更简洁、更易读。”