子类型与变型(Subtyping and Variance)

更新于 2026-01-17

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);
    }
}

在一个保守的生命周期实现中,由于 helloworld 具有不同的生命周期,我们可能会看到如下错误:

error[E0308]: mismatched types
 --> src/main.rs:10:16
   |
10 |         debug(hello, world);
   |                      ^
   |                      |
   |                      expected `&'static str`, found struct `&'world str`

这将非常令人遗憾。在这种情况下,我们真正想要的是接受任何生命周期至少和 'world 一样长的类型。让我们尝试使用子类型来处理生命周期。

子类型(Subtyping)

子类型是指一种类型可以代替另一种类型使用。

我们定义:如果 SubSuper 的子类型(本章中我们将使用记号 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 中有三种变型。给定两个类型 SubSuper,其中 SubSuper 的子类型(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 TT不变的。

以下是一些其他泛型类型及其变型的表格:

类型 '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 TT不变的,编译器得出结论:它不能对第一个参数应用任何子类型转换,因此 T 必须精确地&'static str

这与 &T 的情况形成对比:

fn debug<T: std::fmt::Debug>(a: T, b: T) {
    println!("a = {a:?} b = {b:?}");
}

这里同样要求 ab 具有相同的类型 T。但由于 &'a T'a协变的,我们被允许执行子类型转换。因此编译器决定:只要 &'static str&'b str 的子类型(即 'static <: 'b 成立),就可以将 &'static str 转换为 &'b str。而 'static <: 'b 确实成立,所以编译器愉快地继续编译。

事实上,Box(以及 VecHashMap 等)之所以可以是协变的,其理由与生命周期可以是协变的理由非常相似:一旦你试图将它们放入类似可变引用的东西中,它们就会继承不变性,从而阻止你做任何危险的事情。

不过,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 中,那么 MyTypeA 的变型就完全等于 aA 的变型。

但如果 A 被用于多个字段中:

  • 如果 A 的所有使用都是协变的,则 MyTypeA 是协变的;
  • 如果 A 的所有使用都是逆变的,则 MyTypeA 是逆变的;
  • 否则,MyTypeA 是不变的。
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 不变,因为不变性在冲突中胜出
}