7

【译】深入理解Rust中的生命周期

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzU5MDQ5NTIyNg%3D%3D&%3Bmid=2247484254&%3Bidx=1&%3Bsn=8b7bb267d863e800f2f50ca2478bd277
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

原文标题:Understanding Rust Lifetimes

原文链接:https://medium.com/nearprotocol/understanding-rust-lifetimes-e813bcd405fa

公众号:Rust 碎碎念

翻译 by:Praying

从 C++来到 Rust 并需要学习生命周期,非常类似于从 Java 来到 C++并需要学习指针。起初,它看起来是一个不必要的概念,是编译器应该处理好的东西。后来,当你意识到它赋予你更多的力量——在 Rust 中,它带来了更多的安全性和更好的优化--你感到兴奋,想掌握它,但却失败了,因为它并不直观,很难找到形式化的规则。可以说,C++指针比 Rust 生命周期更容易沉浸其中,因为 C++指针在代码中随处可见,而 Rust 生命周期通常隐藏在大量的语法糖背后。所以你最终会在语法糖不适用的时候接触生命周期,这通常是一些复杂的情况。当你面临的只有这些复杂情况时,你很难内化这个概念。

引言

对于生命周期,需要记住的第一件事就是,它们全都是关于引用(references)的,与其他东西无关。例如,当我们看到一个带有生命周期(lifetime)类型参数的结构体时,它指的是这个结构体所拥有的引用的生命周期,再无其他。不存在结构体的生命周期或者闭包的生命周期,只有结构体或闭包内部引用的生命周期。因此,我们对生命周期的讨论会不可避免地涉及到 Rust 引用。

生命周期背后的动机

要理解生命周期,我们首先需要理解其背后的动机,这就要求我们先理解借用规则背后的动机。借用规则中指出:

在代码中,存在对重叠内存的引用,也称为别名(aliasing),它们中至少有一个会变更(mutate)内存中的内容。

同时变更是不允许的,因为这样是不安全的,并且它阻碍编译器进行各种优化。

示例

假定我们现在想要写一个函数,该函数将一个坐标沿着 x 轴在给定方向上移动两倍的距离。

struct Coords {
pub x: i64,
pub y: i64,
}
fn shift_x_twice(coords: &mut Coords, delta: &i64) {
coords.x += *delta;
coords.x += *delta;
}

fn main() {
let mut a = Coords{x: 10, y: 10};
let delta_a = 10;
shift_x_twice(&mut a, &delta_a); // All good.

let mut b = Coords{x: 10, y: 10};
let delta_b = &b.x;
// shift_x_twice(&mut b, delta_b); // Compilation failure.
}

最后一条语句会把坐标移动三倍距离而不是两倍,这可能会在生产系统中引发各种 bug。关键问题在于, delta_b&mut b 指向一块重叠的内存,而这在 Rust 中是被生命周期和借用规则所阻止的。尤其是,Rust 编译器会提醒, delta_b 要求持有一个 b 的不可变引用直到 main() 结束,但是在那个作用域内,我们还试图创建一个 b 的可变引用,这是被禁止的。

为了能够进行借用规则检查,编译器需要知道所有引用的生命周期。在很多情况下,编译器能够自己推导出生命周期,但是有些情况它无法完成,这就需要开发者手动的对生命周期进行标注。此外,编译器还给开发者提供了工具,例如,我们可以要求所有实现了某个特定 trait 的结构体,其所有引用至少在给定的时间段内都是有效的。

对比 Rust 的引用和 C++中的引用,在 C++中,我们也可以有常量(const)和非常量(non-const)引用,类似于 Rust 中的 &x&mut x 。但是,C++中没有生命周期。常量引用(const reference)能够帮助 C++编译器进行优化,但是它们不能给出完整的安全性保证。所以,上面的示例如果用 C++来写是可以编译通过的。

脱糖(Desugaring)

在我们深入理解生命周期之前,我们需要弄清生命周期是什么,因为各种 Rust 文档用生命周期这个词既指代作用域(scope)也指代类型参数(type-parameter)。在这里,我们用 生命周期(lifetime ) 表示一个作用域,用 生命周期参数(lifetime-parameter ) 来表示一个参数,编译器会用一个真正的生命周期来替换这个参数,就像它在推导泛型时那样。

示例

为了让解释更加清晰,我们将会对一些 Rust 代码进行脱糖(译注:指脱去语法糖)。考虑下面的代码:

fn announce(value: &impl Display) {
println!("Behold! {}!", value);
}

fn main() {
let num = 42;
let num_ref = #
announce(num_ref);
}

下面是脱糖的版本:

fn announce<'a, T>(value: &'a T) where T: Display {
println!("Behold! {}!", value);
}
fn main() {
'x: {
let num = 42;
'y: {
let num_ref = &'y num;
'z: {
announce(num_ref);
}
}
}
}

后面脱糖的代码使用生命周期参数 'a 和生命周期/作用域 'x'y 进行了显式的标注。

我们还使用 impl Display 来比较生命周期参数和一般的类型参数。注意这里语法糖是如何把生命周期参数 'a 和类型参数 T 都隐藏起来的。注意,作用域并不是 Rust 语法的一部分,我们只是用它来标注,所以脱糖后的代码是无法编译的。而且,在这个以及后面的示例中,我们忽略了在 Rust 2018 中加入的非词法生命周期(non-lexical lifetimes)以简化我们的解释。

子类型

从技术角度看,生命周期不是一个类型,因为我们无法像 u64 或者 Vec<T> 这样的普通的类型一样构建一个生命周期的实例。然而,当我们对函数或结构进行参数化时,生命周期参数就像类型参数一样被使用,请看上面的 announce 示例。另外,我们后面会看到的变型规则(Variance Rule)也会像使用类型一样使用生命周期,所以我们在本文中也会称之为类型。

比较生命周期和普通类型、生命周期参数和普通类型参数是有用的:

  • 当编译器为一个普通类型参数推导类型时,如果有多个类型可以满足类型参数,编译器就会报错。而在生命周期的情况下,如果有多个生命周期可以满足给定的生命周期参数,编译器将会使用最小的那个生命周期。

  • 简单的 Rust 类型没有子类型,更具体来讲,一个结构体不能是另一个结构体的子类型,除非它们有生命周期参数。但是,生命周期允许有子类型,并且,如果生命周期 'longer 覆盖了整个 'shorter ,那么 'longer 就是 'shorter 的子类型。生命周期子类型还可以对将生命周期参数化的类型进行有限的子类型化。正如我们在后面所见,它是指 &'longer int&'shorter int 的子类型。 'static 生命周期是所有生命周期的一个子类型,因为它是最长的。 'static 和 Java 中的 Object 恰好相反, Object 在 Java 中是所有类型的超类型。

规则

强制转换和子类型

Rust 有一系列规则,允许一个类型被强制转换为另一个类型。尽管强制转换和子类型很相似,但是能够区分它们也很重要。关键的不同在于,子类型没有改变底层的值,但是强制转换改变了。具体来讲,编译器在强制转换的位置插入额外的代码以执行某些底层转换,而子类型只是一个编译器检查。因为这些额外的代码对开发者是不可见的,并且强制转换和子类型看起来很相似,因为二者看起来都像这样:

let b: B;
...
let a: A = b;

强制转换和子类型放一起:

// 这是强制转换(This is coercion):
let values: [u32; 5] = [1, 2, 3, 4, 5];
let slice: &[u32] = &values;

// 这是子类型(This is subtyping):
let val1 = 42;
let val2 = 24;
'x: {
let ref1 = &'x val1;
'y: {
let mut ref2 = &'y val2;
ref2 = ref1;
}
}

这段代码能够工作,因为 'x'y 的子类型,而且也因此, &'x 也是 &'y 的子类型。

通过学习一些最常见的强制转换,很容易就能区分二者,剩下的一些不常见的,见 Rustonomicon [1]

  • 指针弱化: &mut T&T

  • 解引用:类型 &T&x&U 的类型 &*x ,如果 T: Deref<Target=U> 。这使得我们可以像使用普通类型一样使用智能指针

  • [T; n][T]

  • 如果 T: TraitTdyn Trait

你可能想知道为什么 'x'y 的子类型这件事能够推导出 &'x 也是 &'y 的子类型?要回答这个问题,我们需要讨论 Variance。

变型(Variance)

基于前面的内容,我已经可以很容易区分生命周期 'longer 是否是生命周期 'shorter 的子类型。你甚至可以直观地理解为什么 &'longer T&'shorter T 的子类型。但是,你能够区分 &'a mut &'longer T 是否是 &'a mut &'shorter T 的子类型嘛?实际上做不到,要知道为什么,我们需要 Variance 规则。

正如我们之前所说,生命周期能够对那些生命周期参数化的类型上进行有限的子类型化。变型 是类型构造器(type-constructor)的一个属性, 类型构造器是一个带有参数的类型,比如 Vec<T> 或者 &mut T 。更具体的,变型决定了参数的子类型化如何影响结果类型的子类型化。如果类型构造器有多个参数,比如 F<'a, T, U> 或者 &'b mut V ,那么变型就针对每个参数单独计算。

有三种类型的变型:

  • 如果 F<Subtype>F<Supertype> 的子类型(subtype), F<T>T协变(convarinat)

  • 如果 F<Subtype>F<Supertype> 的超类型(supertype),那么 F<T>T逆变(contravariant)

  • 如果 F<Subtype> 既不是 F<Supertype> 的子类型,也不算 F<Supertype> 的超类型,它们不兼容, F<T>T不变(invariant)

当类型构造器有多个参数时,我们这样来讨论单个的变型,例如, F<'a, T>'a 的协变并且是 T 的不变。而且,还有第四种类型的变型- 二变体 ,但它是一个特定的编译器实现细节,这里我们不需要了解。

下面是一张针对最常见的类型构造器的变型表格:

IN7ZV3Z.png!mobile

协变基本上是一个传递规则。逆变很少见,并且只发生在当我们传递指针到一个使用了 更高级别 trait 约束 [2] 的函数时才会发生,不变是最重要的,当我们开始组合变型时,我们会看到它的动机。

变型运算(Variance arithmetic)

现在我们知道 &'a mut TVec<T> 的子类型和超类型是什么了,但是我们知道 &'a mut Vec<T>Vec<&'a mut T> 的子类型和超类型是什么嘛?要回答这个问题,我们需要知道如何组合类型构造器的 variance。

组合变型有两种数学运算: Transform 和最大下确界(greatest lower bound, GLB )。Transform 用于类型组合,而 GLB 用于所有的聚合体:结构体、元组、枚举以及联合体。让我们分别用 0、+、和 - 来表示不变,协变和逆变。然后 Transform(X)和 GLB(^)可以用下面两张表来表示:

F7jMR3E.png!mobile

示例

假定,我们想要知道 Box<&'longer bool> 是否是 Box<&'shorter bool> 的一个子类型。换句话说,也就是我们想要知道 Box<&'a bool> 关于 'a 的协变。 &'a bool 是关于 'a 的协变, Box<T> 是关于 T 的协变。因为它是一个组合,所以我们需要应用 Transform(X): 协变(+) x 协变(+) = 协变(+),这意味着我们可以把 Box<&'longer bool> 赋予 Box<&'shorter bool>

类似的, Cell<&'longer bool> 不能被赋给 Cell<&'shorter bool> ,因为 协变 (+) x 不变 (0) = 不变 (0)

示例

下面来自 Rustonomicon 的示例解释了为什么在一些类型构造器上我们需要不变性(invariant)。它试图编写一段代码,这段代码使用了一个被释放后的对象。

fn evil_feeder<T>(input: &mut T, val: T) {
*input = val;
}

fn main() {
let mut mr_snuggles: &'static str = "meow! :3"; // mr. snuggles forever!!
{
let spike = String::from("bark! >:V");
let spike_str: &str = &spike; // Only lives for the block
evil_feeder(&mut mr_snuggles, spike_str); // EVIL!
}
println!("{}", mr_snuggles); // Use after free?
}

Rust 编译器不会编译这段代码。要理解其原因,我们首先要对代码进行脱糖:

fn evil_feeder<'a, T>(input: &'a mut T, val: T) {
*input = val;
}

fn main() {
let mut mr_snuggles: &'static str = "meow! :3";
{
let spike = String::from("bark! >:V");
'x: {
let spike_str: &'x str = &'x spike;
'y: {
evil_feeder(&’y mut mr_snuggles, spike_str);
}
}
}
println!("{}", mr_snuggles);
}

在编译期间,编译器试图找到满足约束的参数 T 。回想一下,编译器会采用最小的生命周期,所以它会尝试为 T 使用 &'x str 。现在, evil_feeder 的第一个参数是 &'y mut &'x str ,而我们试图传递 &'y &'static str 。这样会有效么?

为了使其有效, &'y mut &'z str 应该是 'z 的协变,因为 'static'y 的子类型。回顾一下, &'y mut T 是关于 T 的不变, &'z T 是关于 'z 的协变。 &'y mut &'z str 是关于 'z ,因为 协变(+) x 不变 (0) = 不变 (0)。因此,它将无法编译。

有趣的是,这段代码如果用 C++来写就可以编译通过。

结合结构体的示例

关于结构体,我们需要使用 GLB 而不是 Transform,这只在我们使用函数指针涉及到协变时才有意义。下面是一个无法编译的示例,因为结构体 Owner 是关于生命周期参数 'a 的不变,编译器给出的错误信息也有表明:

type annotation requires that `spike` is borrowed for `'static`

不变性从本质上禁用了子类型化,也因此, spike 的生命周期准确匹配 mr_sunggles

struct Owner<'a:'c, 'b:'c, 'c> {
pub dog: &'a &'c str,
pub cat: &'b mut &'c str,
}

fn main() {
let mut mr_snuggles: &'static str = "meow! :3";
let spike = String::from("bark! >:V");
let spike_str: &str = &spike;
let alice = Owner { dog: &spike_str, cat: &mut mr_snuggles };
}

结尾

要记住所有的规则是非常困难的 ,并且我们也不想每次在 Rust 中遇到困难的情况都去搜索这些规则。培养直觉的最好方式是理解和记住这些规则所阻止的不安全的情况。

  • 第一个移动坐标的示例让我们记住,借用规则不允许同时变更和别名。

  • &'a T&'a mut T'a 的协变,因为在一个期望得到短的生命周期的地方传递一个更长的生命周期总是可以的。除非它们被包裹在可变引用或相似事物中。

  • &'a mut TUnsafeCell<T>Cell<T>*mut T 允许可变访问,所以为了避免上面的 evil_feeder 示例以及类似问题,我们想要它是 T 的不变,这意味着生命周期的准确匹配。

每一个版本的发布,Rust 的可用性和友好性都在改善,然而,生命周期是一个核心概念,仍然需要深挖。这篇文章集合了各种资源的信息,让你做一次深入研究而不必多次:)。

fQveueV.jpg!mobile

参考资料

[1]

Rustonomicon: https://doc.rust-lang.org/stable/nomicon/coercions.html

[2]

更高级别 trait 约束: https://doc.rust-lang.org/stable/nomicon/hrtb.html


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK