型变
问题
Rust 中的协变、逆变和不变是什么?它们如何影响泛型和生命周期?
答案
子类型关系
在 Rust 中,子类型关系只存在于生命周期之间:'long: 'short('long 是 'short 的子类型),意味着 'long 至少活得和 'short 一样久。
fn example<'long: 'short, 'short>(
long_ref: &'long str,
short_ref: &'short str,
) {
// 'long 比 'short 活得久,所以 &'long str 可以当 &'short str 用
let _: &'short str = long_ref; // ✅ 协变
}
三种型变
| 型变 | 含义 | 直觉 |
|---|---|---|
| 协变(Covariant) | F<'long> 是 F<'short> 的子类型 | "方向一致" |
| 逆变(Contravariant) | F<'short> 是 F<'long> 的子类型 | "方向相反" |
| 不变(Invariant) | 无子类型关系 | "不能替换" |
Rust 中的型变规则
| 类型 | 'a 上的型变 | T 上的型变 |
|---|---|---|
&'a T | 协变 | 协变 |
&'a mut T | 协变 | 不变 |
*const T | — | 协变 |
*mut T | — | 不变 |
Box<T> | — | 协变 |
Vec<T> | — | 协变 |
Cell<T> | — | 不变 |
fn(T) -> U | — | T 逆变,U 协变 |
UnsafeCell<T> | — | 不变 |
为什么 &mut T 对 T 不变?
fn evil<'long: 'short, 'short>(
long_ref: &'long str,
slot: &mut &'short str, // 如果 &mut T 对 T 协变...
) {
// 假设允许:把 slot 的类型 "放宽" 为 &mut &'long str
// 然后写入一个更短生命周期的引用
*slot = long_ref; // 这看起来安全
}
// 但如果反过来:
fn evil2(slot: &mut &'static str) {
let local = String::from("temp");
// 如果 &mut T 协变,slot 可以当 &mut &'a str 用
// 就能把临时引用写进去 → 悬垂引用!
}
&mut T 对 T 必须不变,否则可以通过可变引用写入更短生命周期的值,导致悬垂引用。
PhantomData 控制型变
use std::marker::PhantomData;
// 协变(默认)
struct Covariant<T> {
_marker: PhantomData<T>, // 协变于 T
}
// 逆变
struct Contravariant<T> {
_marker: PhantomData<fn(T)>, // 逆变于 T
}
// 不变
struct Invariant<T> {
_marker: PhantomData<fn(T) -> T>, // 不变于 T
}
// 或者
struct Invariant2<T> {
_marker: PhantomData<*mut T>, // 不变于 T
}
常见面试问题
Q1: 为什么函数参数是逆变的?
答案:
直觉:如果一个函数接受 &'short str,那么一个接受 &'long str 的函数可以替代它吗?不可以——因为更严格(要求更长生命周期)。但一个接受更短生命周期的函数可以接受更长生命周期的输入。
// fn(&'static str) 可以用在需要 fn(&'a str) 的地方
// 因为 'static 更长,函数能处理更长的也能处理更短的
简单说:参数位置"消费"类型,所以方向相反(逆变);返回位置"产出"类型,方向一致(协变)。
Q2: Cell<T> 为什么对 T 不变?
答案:
Cell<T> 允许通过共享引用修改内部值(内部可变性)。如果 Cell<T> 对 T 协变,就可能通过 &Cell<&'long str> 写入一个 &'short str,导致生命周期不安全。本质原因和 &mut T 一样——可写就必须不变。
Q3: 普通开发中需要关心型变吗?
答案:
大多数情况下不需要。编译器会自动处理。只有在以下场景需要理解型变:
- 编写包含裸指针的 unsafe 代码(需要用 PhantomData 标注正确的型变)
- 遇到复杂的生命周期编译错误时帮助理解原因
- 设计 zero-cost 抽象或底层库