什么是生命周期子类型化(Lifetime Subtyping)或变变性(Variance)?
这是一个非常深入且核心的 Rust 概念(虽然在其他类型系统中也存在,但在 Rust 中尤为重要)。这两个概念解释了为什么有些代码可以通过编译,而有些看似合理的代码却会被借用检查器(Borrow Checker)拒绝。
简单来说:
- 生命周期子类型化(Lifetime Subtyping):定义了生命周期之间的长短关系(谁能替代谁)。
- 变变性(Variance):定义了当生命周期或类型作为参数组合成更复杂的类型(如
&'a T或Vec<T>)时,这种子类型关系是如何传递(或不传递)的。
1. 生命周期子类型化 (Lifetime Subtyping)
在面向对象编程中,如果 Cat 是 Animal 的子类,那么在需要 Animal 的地方,你可以传入 Cat。这叫里氏替换原则。
在 Rust 中,生命周期也有这种关系:
- 规则:如果生命周期
'long比生命周期'short活得更长(即'long包含'short),那么'long就是'short的子类型。 - 语法:
'long: 'short(读作:'longoutlives'short)。
为什么长的是子类型?
因为子类型必须能替代父类型。
- 如果一个函数需要一个活 5 秒的引用(
'short)。 - 你给它一个活 10 秒的引用(
'long)。 - 这是安全的,因为 10 秒 > 5 秒。
- 所以,
'long可以替代'short,'long是子类型。
2. 变变性 (Variance)
变变性描述的是:如果 A 是 B 的子类型,那么 F<A> 和 F<B> 是什么关系?
在 Rust 中,主要有三种变变性:
A. 协变 (Covariance) —— "顺着变"
这是最常见的情况。
- 定义:如果
A是B的子类型,那么F<A>也是F<B>的子类型。 - Rust 例子:不可变引用
&'a T。- 因为
'long是'short的子类型。 - 所以
&'long T是&'short T的子类型。
- 因为
- 实际意义:你可以把一个长生命周期的引用传给一个要求短生命周期的函数。
B. 逆变 (Contravariance) —— "反着变"
这在 Rust 中比较少见,主要出现在函数的参数中。
- 定义:如果
A是B的子类型,那么F<B>是F<A>的子类型(关系反过来了)。 - Rust 例子:函数指针
fn(T)。- 假设你需要一个函数,它能处理活 10 秒的数据
fn(&'long T)。 - 你能不能传给它一个只能处理活 5 秒数据的函数
fn(&'short T)?不能,因为万一你给它传了 10 秒的数据,它可能只处理 5 秒就失效了(或者反过来理解:函数要求的条件越宽松,它越通用)。 - 实际上,如果你需要一个处理
'short的函数,你可以传入一个能处理'long的函数吗?也不行。 - 逆变的直观理解:如果你需要一个能处理
Animal的函数,你可以传入一个能处理Cat的函数吗?不行,因为调用者可能会传入Dog。但你可以传入一个能处理LivingThing(更宽泛/父类型)的函数。 - 所以在函数参数中,要求越低(父类型)的函数,反而可以替代要求高(子类型)的函数。
- 假设你需要一个函数,它能处理活 10 秒的数据
C. 不变 (Invariance) —— "不能变"
这是导致 Rust 新手最头疼的地方,通常出现在可变引用中。
- 定义:即使
A是B的子类型,F<A>和F<B>也没有任何关系,谁也不能替代谁。必须完全精确匹配。 - Rust 例子:可变引用
&'a mut T。
3. 为什么 &mut T 必须是“不变”的? (关键点)
这是理解 Variance 最重要的例子。为什么 &'long mut T 不能当作 &'short mut T 使用?
假设 &mut T 是协变的(允许长替短),看看会发生什么灾难:
plaintext
fn evil_function(s: &mut &'short str, l: &'long str) {
// 假设 s 原本是一个 &'long str 的可变引用
// 但因为协变,我们把它当成了 &'short str 的可变引用传入
// 我们把一个短生命周期的字符串赋值给了 s
*s = l; // 这里的 l 实际上如果是一个更短的生命周期怎么办?
}
fn main() {
let long_lived_string = String::from("Hello");
let mut long_ref: &'static str = "static"; // 长生命周期引用
{
let short_lived_string = String::from("World");
// 假设这是允许的(协变):
// 我们把 &mut &'static str 传给了一个接受 &mut &'short str 的函数
// 函数内部把 short_lived_string 赋值给了 long_ref
// evil_function(&mut long_ref, &short_lived_string);
}
// short_lived_string 在这里死掉了
// 灾难发生:long_ref 现在指向已经被释放的内存!
println!("{}", long_ref);
}
逻辑解析:
- 读取数据时:协变是安全的(长的可以当短的读)。
- 写入数据时:协变是不安全的。如果你把一个长生命周期的容器(
&mut T)伪装成短生命周期的容器,你就可以往里面塞入一个短命的数据。 - 当那个短命数据死亡后,原来的长生命周期容器里就留下了一个悬垂指针。
因此,Rust 强制规定:&mut T 对 T 是不变的(Invariant)。你必须提供确切的生命周期,不能自动转换。
4. 总结表
| 类型构造器 | 例子 | 变变性 (Variance) | 含义 |
|---|---|---|---|
| 不可变引用 | &'a T |
协变 (Covariant) | 对 'a 和 T 都是协变。长命引用可替代短命引用。 |
| 可变引用 | &'a mut T |
协变 (对 'a) 不变 (对 T) |
引用本身的生命周期可以缩短,但指向的数据类型 T 不能变。 |
| Box, Vec | Box<T>, Vec<T> |
协变 | 拥有所有权的数据结构通常是协变的。 |
| UnsafeCell | UnsafeCell<T> |
不变 (Invariant) | 内部可变性必须是不变的,防止数据竞争和悬垂指针。 |
| 函数参数 | fn(T) |
逆变 (Contravariant) | 接受更宽泛类型的函数可以替代接受具体类型的函数。 |
一句话总结
- 生命周期子类型化:活得长的 (
'long) 是活得短的 ('short) 的子类型。 - 协变(绝大多数情况):可以用长的替代短的(如
&T)。 - 不变(
&mut T):必须完全一致,为了防止你往长命容器里塞短命数据。