子类型和变性

Rust 使用生命周期来跟踪借用和所有权之间的关系。然而,生命周期的简单实现要么过于严格,要么允许未定义的行为。

为了允许灵活使用生命周期,同时防止滥用,Rust 使用子类型变性

让我们从一个例子开始。

// Note: debug expects two parameters with the *same* lifetime
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 has a shorter lifetime than '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 一样长的类型。让我们尝试对生命周期使用子类型。

子类型

子类型是指一种类型可以用来代替另一种类型的概念。

让我们定义 SubSuper 的子类型(在本章中我们将使用 Sub <: Super 的表示法)。

这向我们表明,Super 定义的要求集合完全由 Sub 满足。然后,Sub 可能有更多的要求。

现在,为了将子类型与生命周期一起使用,我们需要定义生命周期的要求

'a 定义了一段代码区域。

现在我们已经为生命周期定义了一组要求,我们可以定义它们之间的关系

当且仅当 'long 定义的代码区域完全包含 'short 时,'long <: 'short

'long 可以定义比 'short 更大的区域,但这仍然符合我们的定义。

正如我们将在本章的其余部分看到的那样,子类型比这复杂和微妙得多,但是这个简单的规则是一个非常好的 99% 的直觉。除非您编写不安全的代码,否则编译器会自动处理所有极端情况。

但这毕竟是 Rustonomicon。我们正在编写不安全的代码,所以我们需要了解这些东西的真正工作原理,以及我们如何把它搞砸。

回到上面的例子,我们可以说 '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 has a shorter lifetime than 'static
        debug(hello, world); // hello silently downgrades from `&'static str` into `&'world str`
    }
}

变性

上面,我们略过了 'static <: 'b 意味着 &'static T <: &'b T 的事实。这使用了称为变性的属性。但是,它并不总是像这个例子那样简单。要理解这一点,让我们尝试稍微扩展一下这个例子

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。但是,在稍后在 println! 中使用 hello 之前,world 就超出了作用域。

这是一个经典的 use-after-free 错误!

我们的第一反应可能是责怪 assign impl,但这里实际上没有任何问题。我们可能想将 T 分配给 T 这并不奇怪。

问题在于我们不能假设 &mut &'static str&mut &'b str 是兼容的。这意味着 &mut &'static str 不能&mut &'b str子类型,即使 'static'b 的子类型。

变性是 Rust 借用来定义关于其泛型参数的子类型之间关系的概念。

注意:为了方便起见,我们将定义一个泛型类型 F<T>,以便我们可以轻松地谈论 T。希望这在上下文中是清楚的。

类型 F变性是其输入的子类型如何影响其输出的子类型。Rust 中有三种变性。给定两个类型 SubSuper,其中 SubSuper 的子类型

  • 如果 F<Sub>F<Super> 的子类型(子类型属性被传递),则 F协变
  • 如果 F<Super>F<Sub> 的子类型(子类型属性被“反转”),则 F逆变
  • 否则,F不变的(不存在子类型关系)

如果我们记得上面的例子,如果 'a <: 'b,那么将 &'a T 视为 &'b T 的子类型是可以的,因此我们可以说 &'a T'a 上是协变的。

此外,我们看到将 &mut &'a U 视为 &mut &'b U 的子类型是不可以的,因此我们可以说 &mut TT 上是不变

这是其他一些泛型类型及其变性的表格

'aTU
&'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> 和所有其他内部可变性类型遵循与 UnsafeCell<T> 相同的逻辑
  • 具有内部可变性的 UnsafeCell<T> 具有与 &mut T 相同的变性属性
  • *const T 遵循 &T 的逻辑
  • *mut T 遵循 &mut T(或 UnsafeCell<T>)的逻辑

有关更多类型,请参阅参考资料中的“变性”部分

注意:该语言中唯一的逆变来源是函数的参数,这就是为什么它在实践中很少出现的原因。调用逆变涉及使用带有特定生命周期的引用的函数指针进行高阶编程(而不是通常的“任何生命周期”,它进入高阶生命周期,其工作独立于子类型)。

现在我们对变性有了一些更正式的理解,让我们更详细地研究一些示例。

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 函数

#![allow(unused)]
fn main() {
fn assign<T>(input: &mut T, val: T) {
    *input = val;
}
}

它所做的只是接受一个可变引用和一个值,并使用该值覆盖引用对象。此函数的重要之处在于它创建了类型相等约束。它的签名明确指出,引用对象和值必须是完全相同的类型。

同时,在调用者中,我们传入 &mut &'static str&'world str

由于 &mut TT 上是不变的,编译器得出结论它不能对第一个参数应用任何子类型,因此 T 必须正好是 &'static str

这与 &T 的情况相反

#![allow(unused)]
fn main() {
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 str 才可以变为 &'b str,如果 'static <: 'b,则该条件成立。这是真的,所以编译器很乐意继续编译这段代码。

事实证明,为什么 Box(以及 Vec、HashMap 等)协变的论证与为什么生命周期协变的论证非常相似:一旦你尝试将它们塞到类似可变引用的东西中,它们就会继承不变性,并且会阻止你做任何坏事。

但是 Box 使我们可以更容易地专注于我们部分忽略的引用的按值方面。

与许多允许值随时自由别名的语言不同,Rust 有一个非常严格的规则:如果您可以更改或移动值,则保证只有您可以访问它。

考虑以下代码

let hello: Box<&'static str> = Box::new("hello");

let mut world: Box<&'b str>;
world = hello;

我们已经忘记了 hello 的生命周期为 'static 这一事实没有任何问题,因为一旦我们将 hello 移动到仅知道它的生命周期为 'b 的变量,我们就摧毁了宇宙中唯一记得它的生命周期更长的东西

只剩下一件事要解释:函数指针。

要了解为什么 fn(T) -> UU 上应该是协变的,请考虑以下签名

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 的函数,那么可以处理任何引用生命周期的函数肯定可以正常工作。

让我们在实践中看看这一点

use std::cell::RefCell;
thread_local! {
    pub static StaticVecs: RefCell<Vec<&'static str>> = RefCell::new(Vec::new());
}

/// saves the input given into a thread local `Vec<&'static str>`
fn store(input: &'static str) {
    StaticVecs.with_borrow_mut(|v| v.push(input));
}

/// Calls the function with it's input (must have the same lifetime!)
fn demo<'a>(input: &'a str, f: fn(&'a str)) {
    f(input);
}

fn main() {
    demo("hello", store); // "hello" is 'static. Can call `store` fine

    {
        let smuggle = String::from("smuggle");

        // `&smuggle` is not static. If we were to call `store` with `&smuggle`,
        // we would have pushed an invalid lifetime into the `StaticVecs`.
        // Therefore, `fn(&'static str)` cannot be a subtype of `fn(&'a str)`
        demo(&smuggle, store);
    }

    // use after free 😿
    StaticVecs.with_borrow(|v| println!("{v:?}"));
}

这就是为什么函数类型与该语言中的任何其他类型都不同,在其参数上是变的。

现在,对于标准库提供的类型来说,这一切都很好,但是对于定义的类型,方差是如何确定的呢? 简单来说,一个结构体继承其字段的方差。 如果一个结构体 MyType 有一个泛型参数 A,并在一个字段 a 中使用,那么 MyTypeA 的方差与 aA 的方差完全相同。

然而,如果 A 在多个字段中使用

  • 如果所有对 A 的使用都是协变的,那么 MyTypeA 是协变的
  • 如果所有对 A 的使用都是逆变的,那么 MyTypeA 是逆变的
  • 否则,MyTypeA 是不变的
#![allow(unused)]
fn main() {
use std::cell::Cell;

struct MyType<'a, 'b, A: 'a, B: 'b, C, D, E, F, G, H, In, Out, Mixed> {
    a: &'a A,     // covariant over 'a and A
    b: &'b mut B, // covariant over 'b and invariant over B

    c: *const C,  // covariant over C
    d: *mut D,    // invariant over D

    e: E,         // covariant over E
    f: Vec<F>,    // covariant over F
    g: Cell<G>,   // invariant over G

    h1: H,        // would also be covariant over H except...
    h2: Cell<H>,  // invariant over H, because invariance wins all conflicts

    i: fn(In) -> Out,       // contravariant over In, covariant over Out

    k1: fn(Mixed) -> usize, // would be contravariant over Mixed except..
    k2: Mixed,              // invariant over Mixed, because invariance wins all conflicts
}
}