Rust 使用生命周期(lifetimes)来跟踪借用(borrows)和所有权(ownership)之间的关系。然而,对生命周期的朴素实现要么过于严格,要么会允许未定义行为(undefined behavior)。
为了在允许灵活使用生命周期的同时防止其被误用,Rust 引入了**子类型(subtyping)和变型(variance)**机制。
让我们从一个例子开始。
// 注意:debug 期望两个参数具有*相同*的生命周期
fn debug<'a>(a: &'a str, b: &'a str) {
println!("a = {a:?} b = {b:?}");
}
fn main() {
let hello: &'static str = "hello";
{
let world = String::from("world");
let world = &world; // 'world 的生命周期比 'static 短
debug(hello, world);
}
}
在一个保守的生命周期实现中,由于 hello 和 world 具有不同的生命周期,我们可能会看到如下错误:
error[E0308]: mismatched types
--> src/main.rs:10:16
|
10 | debug(hello, world);
| ^
| |
| expected `&'static str`, found struct `&'world str`
这将非常令人遗憾。在这种情况下,我们真正想要的是接受任何生命周期至少和 'world 一样长的类型。让我们尝试使用子类型来处理生命周期。
子类型(Subtyping)
子类型是指一种类型可以代替另一种类型使用。
我们定义:如果 Sub 是 Super 的子类型(本章中我们将使用记号 Sub <: Super)。
这意味着 Super 所定义的所有要求都被 Sub 完全满足。Sub 可能还包含更多要求。
现在,为了将子类型应用于生命周期,我们需要定义生命周期的要求:
'a定义了一段代码区域(region of code)。
既然我们已经为生命周期定义了一组明确的要求,就可以定义它们之间的关系:
'long <: 'short当且仅当'long所定义的代码区域完全包含'short。
'long 可以定义比 'short 更大的区域,但这仍然符合我们的定义。
正如本章后续内容所示,子类型实际上要复杂和微妙得多,但这条简单规则在 99% 的情况下都能提供很好的直觉。而且,除非你编写 unsafe 代码,否则编译器会自动为你处理所有边界情况。
但我们现在阅读的是《Rustonomicon》(Rust 黑魔法手册)。我们正在编写 unsafe 代码,因此需要理解这些机制的真实工作原理,以及我们可能如何搞砸它。
回到上面的例子,我们可以说 'static <: 'world。暂时我们也接受这样一个观点:生命周期的子类型关系可以通过引用传递(关于这一点,详见“变型”一节),即 &'static str 是 &'world str 的子类型。这样我们就可以将 &'static str “降级”为 &'world str。于是,上述示例就能成功编译:
fn debug<'a>(a: &'a str, b: &'a str) {
println!("a = {a:?} b = {b:?}");
}
fn main() {
let hello: &'static str = "hello";
{
let world = String::from("world");
let world = &world; // 'world 的生命周期比 'static 短
debug(hello, world); // hello 静默地从 `&'static str` 降级为 `&'world str`
}
}
变型(Variance)
上面我们略过了这样一个事实:'static <: 'b 意味着 &'static T <: &'b T。这使用了一种称为**变型(variance)**的属性。不过,并非所有情况都像这个例子这么简单。为了理解这一点,让我们稍微扩展一下这个例子:
fn assign<T>(input: &mut T, val: T) {
*input = val;
}
fn main() {
let mut hello: &'static str = "hello";
{
let world = String::from("world");
assign(&mut hello, &world);
}
println!("{hello}"); // use after free 😿
}
在 assign 中,我们将 hello 引用设置为指向 world。但随后 world 超出了作用域,而之后在 println! 中又使用了 hello!
这是一个经典的 use-after-free(释放后使用) bug!
我们的第一反应可能是责怪 assign 的实现,但实际上这里并没有问题。我们希望将一个 T 赋值给另一个 T,这本身并不奇怪。
真正的问题在于:我们不能假设 &mut &'static str 和 &mut &'b str 是兼容的。这意味着即使 'static 是 'b 的子类型,&mut &'static str 也不能是 &mut &'b str 的子类型。
变型是 Rust 借用来定义泛型参数之间子类型关系的概念。
注意:为方便起见,我们将定义一个泛型类型
F<T>,以便于讨论T。希望在上下文中这是清晰的。
类型 F 的变型描述了其输入的子类型关系如何影响其输出的子类型关系。Rust 中有三种变型。给定两个类型 Sub 和 Super,其中 Sub 是 Super 的子类型(Sub <: Super):
- 如果
F<Sub>是F<Super>的子类型,则称F是**协变(covariant)**的(子类型属性被传递下去)。 - 如果
F<Super>是F<Sub>的子类型,则称F是**逆变(contravariant)**的(子类型属性被“反转”)。 - 否则,称
F是**不变(invariant)**的(不存在子类型关系)。
回顾上面的例子,我们可以将 &'a T 视为 &'b T 的子类型(只要 'a <: 'b),因此我们可以说 &'a T 对 'a 是协变的。
同时,我们也看到不能将 &mut &'a T 视为 &mut &'b T 的子类型,因此我们可以说 &mut T 对 T 是不变的。
以下是一些其他泛型类型及其变型的表格:
| 类型 | 'a |
T |
U |
|---|---|---|---|
&'a T |
协变 | 协变 | |
&'a mut T |
协变 | 不变 | |
Box<T> |
协变 | ||
Vec<T> |
协变 | ||
UnsafeCell<T> |
不变 | ||
Cell<T> |
不变 | ||
fn(T) -> U |
逆变 | 协变 | |
*const T |
协变 | ||
*mut T |
不变 |
其中一些可以通过与其他类型的类比来解释:
Vec<T>以及其他拥有所有权的指针和集合遵循与Box<T>相同的逻辑。Cell<T>以及其他内部可变性(interior mutability)类型遵循与UnsafeCell<T>相同的逻辑。UnsafeCell<T>具有内部可变性,因此其变型属性与&mut T相同。*const T遵循&T的逻辑。*mut T遵循&mut T(或UnsafeCell<T>)的逻辑。
更多类型请参阅 Rust 官方参考手册中的“Variance”一节。
注意:语言中唯一的逆变来源是函数的参数,这也是为什么在实践中很少遇到逆变。触发逆变通常涉及高阶编程,例如使用带有特定生命周期引用的函数指针(而不是通常的“任意生命周期”,后者涉及高阶生命周期(higher-rank lifetimes),而高阶生命周期独立于子类型机制工作)。
现在我们对变型有了更正式的理解,让我们通过更多详细示例来深入探讨。
fn assign<T>(input: &mut T, val: T) {
*input = val;
}
fn main() {
let mut hello: &'static str = "hello";
{
let world = String::from("world");
assign(&mut hello, &world);
}
println!("{hello}");
}
当我们运行这段代码时会发生什么?
error[E0597]: `world` does not live long enough
--> src/main.rs:9:28
|
6 | let mut hello: &'static str = "hello";
| ------------ type annotation requires that `world` is borrowed for `'static`
...
9 | assign(&mut hello, &world);
| ^^^^^^ borrowed value does not live long enough
10 | }
| - `world` dropped here while still borrowed
很好,它没有编译通过!让我们详细分解这里发生了什么。
首先看 assign 函数:
fn assign<T>(input: &mut T, val: T) {
*input = val;
}
它所做的只是接收一个可变引用和一个值,并用该值覆盖引用所指向的内容。这个函数的关键在于它创建了一个类型相等约束(type equality constraint):它的签名明确指出,引用所指向的内容和传入的值必须是完全相同的类型。
而在调用方,我们传入的是 &mut &'static str 和 &'world str。
由于 &mut T 对 T 是不变的,编译器得出结论:它不能对第一个参数应用任何子类型转换,因此 T 必须精确地是 &'static str。
这与 &T 的情况形成对比:
fn debug<T: std::fmt::Debug>(a: T, b: T) {
println!("a = {a:?} b = {b:?}");
}
这里同样要求 a 和 b 具有相同的类型 T。但由于 &'a T 对 'a 是协变的,我们被允许执行子类型转换。因此编译器决定:只要 &'static str 是 &'b str 的子类型(即 'static <: 'b 成立),就可以将 &'static str 转换为 &'b str。而 'static <: 'b 确实成立,所以编译器愉快地继续编译。
事实上,Box(以及 Vec、HashMap 等)之所以可以是协变的,其理由与生命周期可以是协变的理由非常相似:一旦你试图将它们放入类似可变引用的东西中,它们就会继承不变性,从而阻止你做任何危险的事情。
不过,Box 让我们更容易聚焦于引用的按值语义(by-value aspect),这是我们之前部分略过的。
与许多允许值在任何时候被自由别名化的语言不同,Rust 有一条非常严格的规则:如果你被允许修改或移动一个值,那么你就是唯一能访问它的人。
考虑以下代码:
let hello: Box<&'static str> = Box::new("hello");
let mut world: Box<&'b str>;
world = hello;
这里完全没有问题:我们忘记了 hello 的生命周期是 'static,因为一旦我们将 hello 移动到一个只知道其生命周期为 'b 的变量中,我们就销毁了宇宙中唯一记得它活得更久的东西!
只剩下函数指针需要解释了。
要理解为什么 fn(T) -> U 应该对 U 是协变的,考虑以下签名:
fn get_str() -> &'a str;
这个函数声称产生一个受生命周期 'a 约束的 str。因此,提供一个具有如下签名的函数是完全合法的:
fn get_static() -> &'static str;
当调用该函数时,它只期望一个生命周期至少为 'a 的 &str,实际值活得更久完全没关系。
然而,同样的逻辑不适用于参数。考虑用以下函数去满足:
fn store_ref(&'a str);
而提供:
fn store_static(&'static str);
第一个函数可以接受任何生命周期至少为 'a 的字符串引用,但第二个函数无法接受生命周期短于 'static 的字符串引用,这就会导致冲突。协变在这里行不通。但如果我们反过来,它就可行了!如果我们需要一个能处理 &'static str 的函数,那么一个能处理任意生命周期引用的函数肯定也能胜任。
让我们在实践中看看这一点:
thread_local! {
pub static StaticVecs: RefCell<Vec<&'static str>> = RefCell::new(Vec::new());
}
/// 将输入保存到线程局部的 `Vec<&'static str>` 中
fn store(input: &'static str) {
StaticVecs.with_borrow_mut(|v| v.push(input));
}
/// 调用函数并传入其输入(必须具有相同的生命周期!)
fn demo<'a>(input: &'a str, f: fn(&'a str)) {
f(input);
}
fn main() {
demo("hello", store); // "hello" 是 'static,可以正常调用 `store`
{
let smuggle = String::from("smuggle");
// `&smuggle` 不是 'static。如果我们用 `&smuggle` 调用 `store`,
// 就会把一个无效生命周期推入 `StaticVecs`。
// 因此,`fn(&'static str)` 不能是 `fn(&'a str)` 的子类型
demo(&smuggle, store);
}
// use after free 😿
StaticVecs.with_borrow(|v| println!("{v:?}"));
}
这就是为什么函数类型——与语言中其他所有类型不同——对其参数是逆变的。
以上这一切对于标准库提供的类型来说都很好,但你自己定义的类型的变型是如何确定的呢?
非正式地说,一个结构体(struct)会继承其字段的变型。如果一个结构体 MyType 有一个泛型参数 A,并且 A 被用于某个字段 a 中,那么 MyType 对 A 的变型就完全等于 a 对 A 的变型。
但如果 A 被用于多个字段中:
- 如果
A的所有使用都是协变的,则MyType对A是协变的; - 如果
A的所有使用都是逆变的,则MyType对A是逆变的; - 否则,
MyType对A是不变的。
use std::cell::Cell;
struct MyType<'a, 'b, A: 'a, B: 'b, C, D, E, F, G, H, In, Out, Mixed> {
a: &'a A, // 对 'a 和 A 协变
b: &'b mut B, // 对 'b 协变,对 B 不变
c: *const C, // 对 C 协变
d: *mut D, // 对 D 不变
e: E, // 对 E 协变
f: Vec<F>, // 对 F 协变
g: Cell<G>, // 对 G 不变
h1: H, // 本来对 H 也是协变的,但是……
h2: Cell<H>, // 对 H 不变,因为不变性在冲突中胜出
i: fn(In) -> Out, // 对 In 逆变,对 Out 协变
k1: fn(Mixed) -> usize, // 本来对 Mixed 逆变,但是……
k2: Mixed, // 对 Mixed 不变,因为不变性在冲突中胜出
}