Java中协变逆变的实现与Kotlin中的区别

发布于:2025-09-01 ⋅ 阅读:(15) ⋅ 点赞:(0)

一、核心概念回顾:协变与逆变

  • 协变 (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 中安全地【读取】Animal
    for (animal in from) {
        // 这里,我们临时地将 'to' 投影为一个【消费者】
        // 意味着我们可以向 to 中安全地【写入】Animal
        to.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 代码在泛型方面通常更简洁、更易读。”