深入理解协变与逆变:从 Java 到 Swift
本文最后更新于 2026年7月24日 早上
一篇搞懂泛型变体(Variance)在两门语言中的不同哲学
前言
协变(Covariance)和逆变(Contravariance)是类型系统中最容易让人困惑的概念之一。很多开发者即使写过几年泛型代码,面对 ? extends T 和 ? super T 时依然要犹豫半天。
这篇文章会用 Java 作为主力讲解语言(因为它的通配符机制是理解变体的最佳教材),然后再对比 Swift 的实现差异。读完你会明白:
- 协变/逆变到底在解决什么问题
- Java 的 PECS 原则为什么成立
- Swift 为什么没有通配符,却依然存在变体
- 两套设计各自的应用场景
一、预备知识:一个继承体系
为了后续讨论,我们先搭一个简单的类继承结构:
1 | |
Dog 和 Cat 都是 Animal 的子类,这是所有讨论的起点。
二、什么是协变(Covariance)?
协变是指:保留类型的继承方向。
用大白话说:如果 Dog 是 Animal 的子类,那么某种「容器类型」Container<Dog> 也应该被视为 Container<Animal> 的子类。
Java 中的协变:? extends T
1 | |
这里 List<? extends Animal> 可以接收 List<Dog>,因为继承方向被保留了。
协变的限制:只能读,不能写
1 | |
为什么禁止写入?因为编译器不知道 list 实际是 List<Dog> 还是 List<Cat>。如果允许写入 Dog,万一实际类型是 List<Cat>,类型安全就被破坏了。
结论:协变适合「生产者」(Producer)—— 我从中读取数据。
三、什么是逆变(Contravariance)?
逆变是指:反转类型的继承方向。
如果 Dog 是 Animal 的子类,那么 Container<Animal> 反而是 Container<? super Dog> 的子类。
Java 中的逆变:? super T
1 | |
List<Animal> 可以赋值给 List<? super Dog>,因为 Animal 是 Dog 的父类,方向反转了。
逆变的限制:只能写,不能读(读出来全是 Object)
1 | |
这里重点解释一下为什么 list.add(new Animal()) 会被禁止:
List<? super Dog> 的实际类型可能是 List<Dog>(注意,Dog 也是 Dog 的父类,因为自身是自身的父类)。如果实际类型是 List<Dog>,那么放入 Animal 就会破坏 List<Dog> 的类型约束。编译器为了绝对安全,只允许放入 Dog 及其子类,因为无论实际类型是 List<Dog>、List<Animal> 还是 List<Object>,Dog 总能安全地放进去。
而读取时,因为实际类型可能是 List<Object>,所以只能保证读出来的是 Object,类型信息被擦除了。
结论:逆变适合「消费者」(Consumer)—— 我往里放入数据。
四、Java 的 PECS 原则
Java 社区总结了一条经典口诀:
PECS:Producer Extends, Consumer Super
- 如果你从容器中读取(生产数据),用
? extends T(协变) - 如果你往容器中写入(消费数据),用
? super T(逆变)
这条原则在 Java 集合框架中随处可见,比如 Collections.copy() 方法的签名:
1 | |
src是生产者,用extends(从中读取)dest是消费者,用super(向其中写入)
五、Swift 中的协变与逆变
Swift 的设计哲学和 Java 有很大不同。Swift 没有 Java 那样的通配符(? extends / ? super),泛型默认是不变的。但在三个特定场景下,变体规则依然存在。
5.1 数组(Array)—— 协变
Swift 的数组支持协变:
1 | |
这其实是不安全的协变(和 Java 数组一样),但 Swift 通过值语义和写时复制(COW)降低了风险。在实际使用中,由于数组是 struct(值类型),拷贝时会独立复制,不容易出现 Java 数组那样的 ArrayStoreException。
5.2 闭包(Closure)—— 返回值协变 + 参数逆变
这是 Swift 中最符合类型理论的地方。函数类型的子类型规则是:
- 返回值类型:支持协变(可以返回更具体的子类型)
- 参数类型:支持逆变(可以接受更宽泛的父类型)
1 | |
为什么参数要逆变?因为调用 Handler 时,外界会传入一个 Dog。如果内部函数接受的是 Animal,传入 Dog 完全合法(Dog 是 Animal 的子类)。
5.3 泛型(Generic)—— 默认不变
Swift 的泛型类和结构体默认是不变的:
1 | |
Box<Dog> 和 Box<Animal> 没有任何类型关系。如果需要变体行为,必须通过关联类型(Associated Type)配合 where 约束,或者使用类型擦除(如 AnyPublisher)。
六、Java vs Swift 全面对比
| 维度 | Java | Swift |
|---|---|---|
| 泛型变体支持 | 通过 ? extends(协变)和 ? super(逆变)显式声明 |
默认不变,需通过协议/关联类型间接实现 |
| 数组 | 协变(不安全,运行时会抛出 ArrayStoreException) |
协变(通过值语义和写时复制降低风险) |
| 函数/闭包 | 方法重写时返回值可协变,参数类型必须不变 | 返回值协变 + 参数逆变(完全遵循函数子类型理论) |
| 设计目的 | 集合读写灵活性与安全性平衡(PECS) | 类型安全优先,函数式编程友好 |
| 语法标识 | extends(上界)和 super(下界)关键字 |
无专用关键字,靠类型推断和编译器检查 |
| 类型安全 | 编译时强检查(通配符边界) | 编译时更强检查(泛型系统更严格) |
七、两套设计背后的哲学
Java 的思路:「我声明时就要说清楚我是用来读还是用来写」
Java 的泛型通配符是在使用端(use-site)进行变体声明。开发者需要在声明变量时就明确告诉编译器:这个容器是只读的(extends)还是只写的(super)。这是一种显式的 API 设计约束。
优点:灵活、精细控制。
缺点:语法复杂,学习曲线陡峭(PECS 口诀就是明证)。
Swift 的思路:「除了数组和闭包,泛型就是两个不同的类型」
Swift 的变体规则是声明端(declaration-site)定义的,语言内置了少数几个变体场景(数组、闭包),其余场景默认不变。这种设计更倾向于简单明了:大多数情况下,不同类型的泛型容器就是没有关系,不需要额外思考。
优点:代码更直观,不易出错。
缺点:某些场景需要手动类型擦除,增加样板代码。
八、总结
| 语言 | 核心理念 | 适合场景 |
|---|---|---|
| Java | 通过 PECS 原则在集合读写中灵活使用协变/逆变 | 大型企业应用、集合框架设计 |
| Swift | 默认不变,仅在数组和闭包中内置变体规则 | iOS/macOS 开发、函数式编程 |
一句话概括:
Java 的协变/逆变是「显式的集合访问权限控制」,而 Swift 的协变/逆变是「隐式的语言底层类型法则」——前者服务于 API 设计的灵活性,后者服务于类型系统的纯净性。