泛型的三种型变类型:逆变,协变和不变

1. 一句话直观记忆

  • 协变(out) 出来安全(只读生产者)。如果 Cat <: Animal,那 Box <: Box

  • 逆变(in) 进去安全(只写消费者)。如果 Cat <: Animal,那 Box <: Box

  • 不变 :两边都不放过;Box 与 Box 没有子类型关系。

换成口号:生产者用 extends/out;消费者用 super/in;默认不变


2. 形式化定义(看"子类型关系如何传递")

设 S <: T(S 是 T 的子类型),则:

  • 协变: 方向保持
  • 逆变: 方向相反
  • 不变: 无子类型关系

3. Java 的三种型变(用通配符实现)

Java 的泛型默认不变;通过通配符 ? extends / ? super 在**使用处(use-site)**表达协/逆变。

3.1 协变:? extends T(只读生产者)

ini 复制代码
List<? extends Number> src = List.of(1, 2, 3);
// src.add(4); // ❌ 编译错误:不能安全写入
Number n = src.get(0);    // ✅ 读取安全(最小上界是 Number)

场景 :把 List 当作 List<? extends Number> 来读取元素。

代价:编译器禁止 add,因为你可能传入 Double 等破坏具体子类型。

3.2 逆变:? super T(只写消费者)

ini 复制代码
List<? super Integer> dst = new ArrayList<Number>();
dst.add(123);         // ✅ 可以写入 Integer(以及其子类)
Object x = dst.get(0); // 只能当 Object 读出(类型不精确)

场景 :把 List 当作 List<? super Integer> 来接收整数写入(消费者)。

代价:读出的精确类型丢失(只能是 Object)。

3.3 不变(默认)

ini 复制代码
List<Integer> a = new ArrayList<>();
List<Number>  b = a; // ❌ 不成立(不变)

结论:默认不变可以在编译期阻止很多潜在类型错配。

3.4 经典法则(PECS)

Producer Extends, Consumer Super****

  • 生产者(只读来源)→ ? extends T

  • 消费者(只写目标)→ ? super T

实战例:复制函数

javascript 复制代码
static <T> void copy(List<? extends T> from, List<? super T> to) {
  for (T e : from) to.add(e);
}

4. Kotlin 的型变(声明处 + 使用处)

Kotlin 在**声明处(declaration-site)**就能标注型变,更易用。

4.1 声明处协变out

kotlin 复制代码
interface Source<out T> { fun get(): T }
// 因为只"产出 T",声明为 out T → Source<Cat> 可当作 Source<Animal>
  • 约束:标了 out 的位置不能当参数使用(除非在类型投影等安全场合)。

4.2 声明处逆变in

kotlin 复制代码
interface Sink<in T> { fun put(x: T) }
// 因为只"消费 T",声明为 in T → Sink<Animal> 可当作 Sink<Cat>
  • 约束:标了 in 的位置不能作为返回类型(否则返回类型不安全)。

4.3 使用处投影(与 Java 通配符类似)

  • 协变投影:List 等价 Java List<? extends Number>

  • 逆变投影:MutableList 等价 Java List<? super Integer>

4.4 Kotlin 函数类型的型变(非常重要)

函数类型是参数逆变,返回协变

kotlin 复制代码
// (P) -> R  等价于 Function1<in P, out R>
val f: (Animal) -> Cat
val g: (Cat) -> Animal
// g 可以赋给 f 吗?参数逆变 → 需要 (super Cat);返回协变 → 需要 (sub Cat)
// 正确结论: (Animal)->Cat 可以赋给 (Cat)->Animal ? 反之更常见:
// 接口 Comparator<in T> / Producer<out T> 源于这一原则

更实用的例子:Comparator(只消费待比较对象,逆变);Iterable(只生产元素,协变)。

4.5 星投影

当你"不关心具体类型参数"时使用:

kotlin 复制代码
val anyList: List<*> = listOf(1, 2, 3) // 只能以 Any? 读取,不可写

5. 数组的特例(高频面试点)

  • Java 数组协变 的:Cat[] 可赋给 Animal[]。但这不类型安全,会在运行时抛出 ArrayStoreException:
ini 复制代码
Animal[] a = new Cat[10];
a[0] = new Dog(); // 运行期 ArrayStoreException
  • Kotlin Array 默认不变 ;需要型变时用 Array / Array 的投影

6. 什么时候用哪种型变?

需求 Java 写法 Kotlin 写法 说明
只读数据源(生产者) List<? extends T> List / 声明处 out 可读不可写
只写接收端(消费者) List<? super T> MutableList / 声明处 in 可写不可精确读
同时读写(容器、缓冲) 具体 List(不变) MutableList(不变) 默认不变更稳
API 抽象的只读接口 Iterable<? extends T> Iterable 设计层面协变
比较/回调(只消费) Comparator<? super T> Comparator 逆变更宽松
函数类型 N/A (in P) -> out R 记住"参逆返协"

7. 实战模式与坑点

7.1 只读接口与可变实现分离(Kotlin/Android 推荐)

kotlin 复制代码
interface Repo {
  val items: List<Item> // 只读暴露(协变接口)
}
class RepoImpl : Repo {
  private val _items = mutableListOf<Item>()
  override val items: List<Item> get() = _items // 外部看不到可变方法
}
  • 对外协变只读 ,对内持有可变实现,既安全又易维护。

7.2 复制/转换 API:PECS 套路

javascript 复制代码
// Java:from 生产(extends),to 消费(super)
static <T> void copy(List<? extends T> from, List<? super T> to) {
  for (T e : from) to.add(e);
}

7.3 比较器/回调要用逆变,兼容更广

ini 复制代码
Comparator<? super Cat> cmp = Comparator.comparing(Animal::getAge);
// 可拿比较 Animal 的比较器来排 Cat,更灵活

7.4 避免"错误协变"导致的写入失效

  • List<? extends Number> 看似"广",却不能 add ;若你既要读又要写,就不要用 extends/out,而是用不变 List 或把写入端单独抽出去。

8. 面试/复盘小抄(TL;DR)

  • 协变 :读安全、写受限------生产者(Java ? extends / Kotlin out)。

  • 逆变 :写安全、读不精确------消费者(Java ? super / Kotlin in)。

  • 不变:默认、稳妥------读写都要时用它。

  • PECS:Producer Extends,Consumer Super。

  • 函数类型参数逆变、返回协变

  • 数组:Java 协变但不安全(会 ArrayStoreException);Kotlin Array 不变。

  • API 设计:只读接口协变、消费者接口逆变;实现细节保持不变以便读写。

相关推荐
拉不动的猪1 小时前
深入理解 JavaScript 中的静态属性、原型属性与实例属性
前端·javascript·面试
光军oi2 小时前
面试Redis篇—————缓存穿透问题及解决策略
redis·缓存·面试
洛卡卡了4 小时前
一次上线事故,我干脆写了套灰度发布系统
后端·面试·架构
123461615 小时前
互联网大厂Java面试:从Spring Boot到微服务的探索
java·数据库·spring boot·微服务·面试·mybatis·orm
用户99045017780098 小时前
程序员只懂技术还远远不够!不懂这点,你可能永远在敲代码
后端·面试
晨非辰9 小时前
《数据结构风云》:二叉树遍历的底层思维>递归与迭代的双重视角
数据结构·c++·人工智能·算法·链表·面试
WYiQIU1 天前
高级Web前端开发工程师2025年面试题总结及参考答案【含刷题资源库】
前端·vue.js·面试·职场和发展·前端框架·reactjs·飞书
GISer_Jing1 天前
小米前端面试
前端·面试·职场和发展
小龙报1 天前
《赋能AI解锁Coze智能体搭建核心技能(2)--- 智能体开发基础》
人工智能·程序人生·面试·职场和发展·创业创新·学习方法·业界资讯