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

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

  • 协变 (Covariance) :如果 CatAnimal 的子类型,那么 Producer<Cat> 也是 Producer<Animal> 的子类型。它适用于只读(输出)场景。

  • 逆变 (Contravariance) :如果 CatAnimal 的子类型,那么 Consumer<Animal>Consumer<Cat> 的子类型。它适用于只写(输入)场景。

  • 不变 (Invariance)Box<Cat>Box<Animal> 没有关系。它适用于既可读又可写的场景。


二、Java 的实现:使用处变型 (Use-site Variance)

Java 的变型规则通过通配符(Wildcards)使用泛型的地方 (如方法参数、局部变量声明)来指定。这意味着变型规则是由API的调用者使用者来决定的。

1. 语法与实现
  • 协变 : <? extends T>

    java 复制代码
    // 声明一个协变的List,它只能被读取
    List<? extends Number> numbers = new ArrayList<Integer>();
    Number num = numbers.get(0); // OK, 安全地读取为Number
    // numbers.add(100); // 编译错误!不能写入
  • 逆变 : <? super T>

    java 复制代码
    // 声明一个逆变的List,它只能被写入
    List<? super Integer> list = new ArrayList<Number>();
    list.add(100); // OK, 安全地写入Integer
    // Integer i = list.get(0); // 编译错误!不能安全读取
    Object obj = list.get(0); // 唯一能读取的方式
2. 特点与影响
  • 规则在使用处指定 :每次声明一个泛型变量或参数时,你都需要思考并使用 extendssuper。这导致了著名的 PECS (Producer-Extends, Consumer-Super) 原则。

  • 灵活性高 :同一个泛型类(如 ArrayList)可以在不同的使用场景下被当作协变、逆变或不变的。

  • 语法噪音大:代码中会充斥大量通配符,使得签名变得复杂。

    java 复制代码
    // 一个复杂的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) - 核心特性

在定义类或接口时,使用变型修饰符 outin

  • 协变 : out T

    • 修饰符 out 表示这个泛型类 Producer<T> 中的 T 只用于输出(作为函数的返回类型)。

    • 这相当于向编译器承诺:"这个类绝对不会消费 T 类型的对象,只会生产它们"。

    • 效果Producer<Cat> 自动成为 Producer<Animal> 的子类型,无需任何通配符。

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

    Kotlin 复制代码
    interface List<out E> : Collection<E> { ... } // 因此 List<String> 是 List<Any?> 的子类型
  • 逆变 : in T

    • 修饰符 in 表示这个泛型类 Consumer<T> 中的 T 只用于输入(作为函数的参数类型)。

    • 这相当于向编译器承诺:"这个类只会消费 T 类型的对象,不会生产它们"。

    • 效果Consumer<Animal> 自动成为 Consumer<Cat> 的子类型。

    Kotlin 复制代码
    // 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 接口就是逆变的。

    Kotlin 复制代码
    interface Comparable<in T> { // 因此 Comparable<Any> 是 Comparable<String> 的子类型
        operator fun compareTo(other: T): Int
    }
2. 使用处变型 (Use-site Variance):类型投影 (Type Projection)

Kotlin 也提供了类似 Java 通配符的功能,用于在使用处 临时改变型变规则,这被称为类型投影

  • 语法 :在具体使用的地方使用 outin

  • 目的 :用于处理那些在定义时是不变 的泛型类(如 MutableList),但在某个特定函数中,你只想以安全的方式使用它。

Kotlin 复制代码
// 假设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 代码在泛型方面通常更简洁、更易读。"

相关推荐
这周也會开心6 小时前
Spring-MVC
java·spring·mvc
SimonKing6 小时前
跨域,总在发OPTIONS请求?这次终于搞懂CORS预检了
java·后端·程序员
Kapaseker6 小时前
每个Kotlin开发者应该掌握的最佳实践,第三趴
android·kotlin
考虑考虑6 小时前
dubbo3超时时间延长
java·后端·dubbo
boy快快长大6 小时前
【Spring AI】简单入门(一)
java·人工智能·spring
失散137 小时前
分布式专题——15 ZooKeeper特性与节点数据类型详解
java·分布式·zookeeper·云原生·架构
ThisIsMirror7 小时前
Spring的三级缓存如何解决单例Bean循环依赖
java·spring·缓存
菠菠萝宝7 小时前
【Java八股文】12-分布式面试篇
java·分布式·zookeeper·面试·seata·redisson
yk100107 小时前
Spring DefaultSingletonBeanRegistry
java·后端·spring
Metaphor6927 小时前
Java 将 PDF 转换为 HTML:高效解决方案与实践
java·经验分享·pdf·html