学习曲线中最陡峭的一段区域

在Rust2020年发起的调研中,大多数人认为Lifetime是最难掌握的特性。这很容易理解,因为在其他语言中你几乎找不到相似的内容作为对照。Lifetime实用解读 - 图1
图片来源: Rust Survey 2020 Results
但很多时候你又不得不面对使用Lifetime的使用场景,这些场景或简单或复杂,但最终矛头都指向一个问题:变量的存活时间。你大概已经知道,Rust通常会在编译时就推断出了变量或引用的生存范围和存活时间,而且能自动地进行简单的类型推断。但编译器为了追求确定性,它会倾向于在编译时确定尽可能多的信息,而有些东西只有在运行时才能确定,比如分支控制的结果。于是编译器在面临两难的问题时,通常都是直接放弃,报出错误,然后向开发者请求明确的信息。

The Rust Programming Language 在10.3章节对 Lifetime 进行了介绍和案例分析,但我觉得普通人需要反复阅读才能记住这些特性和解释。我在油管上也看一些有关Lifetime的教程,结合这两部分内容,我希望能在本文写下中文环境内最通俗易懂的Lifetime实用解读。

当然,我也十分推荐你仔细阅读Rust Book(The Rust Programming Language)和Rust Reference的相关章节,并在论坛上寻找更专业的解释,以形成自己的理解。

Lifetime和Ownership

如果按照Rust Book来学习的话,首先会了解到所有权(Ownership)这个东西。然后会知道Rust中变量的引用(Reference),借用(Borrow),转移(Move)等规则。我之前也做了一些简单的笔记。
在Rust中存活下来
对于普通变量来说,一旦离开了Rust推断的作用域,就会立即被丢掉,其占用的内存空间也几乎马上就会被清理。也就是说,如果你想要在这个变量上完成某些任务的话,必须要在其离被销毁之前完成,这其实是很容易理解的。它就像生物一样有着自己的生命周期。

对于引用(先不谈智能指针)来说,它也有着本能,那就是希望被引用的内容是在上下文中存在的,编译器也会积极地确保这一点。毕竟,皮之不存,毛将焉附?

问题在于,变量和它的引用并不是共享生命周期的,当变量被销毁后,引用所指的那段内存区域仍然存在,只是不再属于该变量了。这种情况就是引用的悬挂。

  1. fn main() {
  2. {
  3. let r;
  4. {
  5. let x = 5;
  6. r = &x;
  7. }
  8. println!("r: {}", r);
  9. }
  10. }

在上面的代码里, r 被赋值为 x 的引用,但 x 在其定义的内部作用域外已经不可用,因此会出现编译错误。

  1. $ cargo run
  2. Compiling chapter10 v0.1.0 (file:///projects/chapter10)
  3. error[E0597]: `x` does not live long enough
  4. --> src/main.rs:7:17
  5. |
  6. 7 | r = &x;
  7. | ^^ borrowed value does not live long enough
  8. 8 | }
  9. | - `x` dropped here while still borrowed
  10. 9 |
  11. 10 | println!("r: {}", r);
  12. | - borrow later used here
  13. error: aborting due to previous error
  14. For more information about this error, try `rustc --explain E0597`.
  15. error: could not compile `chapter10`.
  16. To learn more, run the command again with --verbose.

这是借用的情况,如果使用转移或者复制,那么相当于 x 中的内容的生命被延长到了 r 的长度,就不会出现错误。

这就是说,引用的寿命必须要比本体的寿命短,否则这个引用就是无效的。Rust通过borrow checker来确定引用是否有效。Borrow Checker并没有什么魔法,它只是静态地比较引用的作用域长度。

用Rust Book中的例子来解释, 'a'b 分别代表 rxLifetime。由于 'a'b 重叠且 'a 长于 'bborrow checker能发现这一引用悬挂。

  1. fn main() {
  2. {
  3. let r; // ---------+-- 'a
  4. // |
  5. { // |
  6. let x = 5; // -+---'b |
  7. r = &x; // | |
  8. } // -+ |
  9. // |
  10. println!("r: {}", r); // |
  11. } // ----------+
  12. }

对于两个Lifetime的重叠情况,大致可以分以下几种:
image.png
只有符合borrow checker规则的才能编译。

但是,你不用把上面那张图记住,因为这是borrow checker的工作,它已经为你提供好了一层恰到好处的抽象,你只需要明白编译错误的原因即可。

Borrow Checker的局限

由于Borrow Checker是进行的静态分析,它的局限显然在于运行时的推断,实际上,Rust在运行时不会进行这种费力的检查,如果Borrow Checker发现了自己不能判断的境况,会直接产生编译错误。

什么时候Borrow Checker无能为力呢?最明显的地方就是分支控制语句了,在运行之前Rust当然不知道会进入哪条分支。

这个和Lifetime又有什么关系呢?BorrowChecker需要确定其比较的Lifetime,而分支则可能导致Lifetime只有在运行时才确定,因此,我们需要有一种方式来协助BorrowChecker工作。

同样使用Rust Book的例子,因为 longest 的参数是&str, 返回类型也是&str,而内部逻辑则是选择其中一个参数返回。但是,Borrow Checker需要知道在 longest 返回时,“它”引用的内容还存不存在,然而此处的“它”有可能是 x 也有可能是 y

  1. fn main() {
  2. let string1 = String::from("abcd");
  3. let string2 = "xyz";
  4. let result = longest(string1.as_str(), string2);
  5. println!("The longest string is {}", result);
  6. }
  7. fn longest(x: &str, y: &str) -> &str {
  8. if x.len() > y.len() {
  9. x
  10. } else {
  11. y
  12. }
  13. }
  14. /*编译错误,并且提示你添加lifetime参数
  15. $ cargo run
  16. Compiling chapter10 v0.1.0 (file:///projects/chapter10)
  17. error[E0106]: missing lifetime specifier
  18. --> src/main.rs:9:33
  19. |
  20. 9 | fn longest(x: &str, y: &str) -> &str {
  21. | ^ expected lifetime parameter
  22. |
  23. = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
  24. error: aborting due to previous error
  25. For more information about this error, try `rustc --explain E0106`.
  26. error: could not compile `chapter10`.
  27. To learn more, run the command again with --verbose.*/

你可以通过这种方式添加lifetime标记。

  1. fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  2. if x.len() > y.len() {
  3. x
  4. } else {
  5. y
  6. }
  7. }

lifetime的语法是这样的,开头是 ' 符号,其后紧跟一个标记字符串:

  1. &i32 // 引用,存在隐式的lifetime
  2. &'a i32 // 有着明确的lifetime标记的不可变引用
  3. &'another mut i32 // 有着明确的lifetime标记的可变引用

什么是Lifetime

正如在上面修正后的 longest 中,你只改变了函数签名,包括函数参数和返回值的表达形式。没有引入额外的关键词,也没有引入额外的依赖。你只是在向编译器解释:“我的这个方法有着名为 'a 的lifetime,并且所有参数以及返回值也需要匹配到 'a 这个lifetime”。这句话澄清了一个点,那就是参数 xy 拥有了相同的lifetime,而返回的引用的lifetime会与相同标记参数的lifetime中最小的哪一个一致。
**
请注意, 'a 什么也没有改变,它不会改变 xy 的lifetime,它只是在形容这个函数在 'a lifetime下该如何表现而已。任何违背该表现的函数调用都不会编译。

比如:

  1. fn main() {
  2. let string1 = String::from("long string is long");
  3. let result;
  4. {
  5. let string2 = String::from("xyz");
  6. result = longest(string1.as_str(), string2.as_str());
  7. }
  8. println!("The longest string is {}", result);
  9. }

按照 'a 的形容,result的lifetime是string1和string2中较小的那一个,也就是string2,然而在调用中string2的lifetime要小于result的lifetime,和 'a 想要的形容不一致,因此上面的代码不会编译通过。
image.png

Lifetime标记在函数中是链接参数与返回值的指示。
**

Static Lifetime

虽然前面说过Lifetime syntax只是一种标识或指示,但在Rust中也存在由特定含义的Lifetime,比如 'static
回到上一个比较字符串的例子当中,我们用的都是 String , 它们之后调用 as_str() 作为方法参数。代码不会编译通过是因为违背了Lifetime标识。但是,如果你使用 &str 作为 string1string2 的类型,代码则可以编译通过。

  1. fn main() {
  2. let string1 = "long string is long";
  3. let result;
  4. {
  5. let string2 ="xyz";
  6. result = longest(string1, string2);
  7. }
  8. println!("The longest string is {}", result);
  9. }

这是因为 &str 字面量拥有 'static 的Lifetime。声名字面量相当于:

  1. let string1: &'static str = "long string is long";

&'staic 意味着直到程序结束前对该变量的引用都会有效。这也意味着它不会轻易回收,所以不要滥用 'static

Static is not const

这是两个不同的关键字,有着不同的用处。

  • static - global variable or lifetime lasting the entire program execution`
  • const - define constant items or constant raw pointers

注意!这里说的是 static 关键字,并非 'static 这个Lifetime。两者虽然相关但是情景是不同的。对于 static 关键字来说,它可以直接使用在变量声明上:

  1. static BYTES: [u8; 3] = [1, 2, 3];

因而 BYTES 有用如下特性:

  • 编译时创建
  • 只有在unsafe代码中才能改变(mutate)
  • 在整个程序中都有效

    Static is not ‘static

    变量拥有 'static Lifetime 标识,但并非在整个程序中都有效,也并非只能在编译时创建。

如果你看见类似 &'static T 这样的用法, 'static 表示对变量的一种声名,你可以认为:

  • 这是一个对T的 immutable 引用。
  • 这个引用的有效期(你可以安全使用的时间)最长可以匹配到程序结束。
  • 当T是immutable且不被人为move出所属scope时引用可以带到最长的有效期。

为如果你看见类似 T: 'static 这样的generic用法, 'static 则表示对generic的一种限定:

  • T可以是borrowed也可以是owned,因为这就是generic的妙处。
  • 当T是borrowed,基本和上一个情况类似。
  • 当T是owned,它拥有所有owned类型的特征:
    • 可在运行时生成并分配空间。
    • 可以是mutable,可在运行时被drop掉。
    • T的有效期归属于owner,并不一定等同于整个程序。
    • T的Lifetime标识是多样的。你可以用 T:'a 因为 'static >= 'a

省略的Lifetime

也就是所谓的Lifetime Elision Rules。但说真的,引入这一条规则是为了更方便地写代码,用户在不知道这条规则的情况下也能写出高质量的代码,这才是省略规则的最终目的。如果有可能,用户最好能在减少一些自行书写Lifetime标识的机会而让编译器推断,因为Lifetime看起来是很丑陋的。

参考

  1. https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html
  2. https://github.com/pretzelhammer/rust-blog/blob/master/posts/common-rust-lifetime-misconceptions.md#1-t-only-contains-owned-types