学习曲线中最陡峭的一段区域
在Rust2020年发起的调研中,大多数人认为Lifetime是最难掌握的特性。这很容易理解,因为在其他语言中你几乎找不到相似的内容作为对照。
图片来源: 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推断的作用域,就会立即被丢掉,其占用的内存空间也几乎马上就会被清理。也就是说,如果你想要在这个变量上完成某些任务的话,必须要在其离被销毁之前完成,这其实是很容易理解的。它就像生物一样有着自己的生命周期。
对于引用(先不谈智能指针)来说,它也有着本能,那就是希望被引用的内容是在上下文中存在的,编译器也会积极地确保这一点。毕竟,皮之不存,毛将焉附?
问题在于,变量和它的引用并不是共享生命周期的,当变量被销毁后,引用所指的那段内存区域仍然存在,只是不再属于该变量了。这种情况就是引用的悬挂。
fn main() {{let r;{let x = 5;r = &x;}println!("r: {}", r);}}
在上面的代码里, r 被赋值为 x 的引用,但 x 在其定义的内部作用域外已经不可用,因此会出现编译错误。
$ cargo runCompiling chapter10 v0.1.0 (file:///projects/chapter10)error[E0597]: `x` does not live long enough--> src/main.rs:7:17|7 | r = &x;| ^^ borrowed value does not live long enough8 | }| - `x` dropped here while still borrowed9 |10 | println!("r: {}", r);| - borrow later used hereerror: aborting due to previous errorFor more information about this error, try `rustc --explain E0597`.error: could not compile `chapter10`.To learn more, run the command again with --verbose.
这是借用的情况,如果使用转移或者复制,那么相当于 x 中的内容的生命被延长到了 r 的长度,就不会出现错误。
这就是说,引用的寿命必须要比本体的寿命短,否则这个引用就是无效的。Rust通过borrow checker来确定引用是否有效。Borrow Checker并没有什么魔法,它只是静态地比较引用的作用域长度。
用Rust Book中的例子来解释, 'a 和 'b 分别代表 r 和 x 的 Lifetime。由于 'a 与 'b 重叠且 'a 长于 'b ,borrow checker能发现这一引用悬挂。
fn main() {{let r; // ---------+-- 'a// |{ // |let x = 5; // -+---'b |r = &x; // | |} // -+ |// |println!("r: {}", r); // |} // ----------+}
对于两个Lifetime的重叠情况,大致可以分以下几种:
只有符合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 。
fn main() {let string1 = String::from("abcd");let string2 = "xyz";let result = longest(string1.as_str(), string2);println!("The longest string is {}", result);}fn longest(x: &str, y: &str) -> &str {if x.len() > y.len() {x} else {y}}/*编译错误,并且提示你添加lifetime参数$ cargo runCompiling chapter10 v0.1.0 (file:///projects/chapter10)error[E0106]: missing lifetime specifier--> src/main.rs:9:33|9 | fn longest(x: &str, y: &str) -> &str {| ^ expected lifetime parameter|= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`error: aborting due to previous errorFor more information about this error, try `rustc --explain E0106`.error: could not compile `chapter10`.To learn more, run the command again with --verbose.*/
你可以通过这种方式添加lifetime标记。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {if x.len() > y.len() {x} else {y}}
lifetime的语法是这样的,开头是 ' 符号,其后紧跟一个标记字符串:
&i32 // 引用,存在隐式的lifetime&'a i32 // 有着明确的lifetime标记的不可变引用&'another mut i32 // 有着明确的lifetime标记的可变引用
什么是Lifetime
正如在上面修正后的 longest 中,你只改变了函数签名,包括函数参数和返回值的表达形式。没有引入额外的关键词,也没有引入额外的依赖。你只是在向编译器解释:“我的这个方法有着名为 'a 的lifetime,并且所有参数以及返回值也需要匹配到 'a 这个lifetime”。这句话澄清了一个点,那就是参数 x , y 拥有了相同的lifetime,而返回的引用的lifetime会与相同标记参数的lifetime中最小的哪一个一致。
**
请注意, 'a 什么也没有改变,它不会改变 x 或 y 的lifetime,它只是在形容这个函数在 'a lifetime下该如何表现而已。任何违背该表现的函数调用都不会编译。
比如:
fn main() {let string1 = String::from("long string is long");let result;{let string2 = String::from("xyz");result = longest(string1.as_str(), string2.as_str());}println!("The longest string is {}", result);}
按照 'a 的形容,result的lifetime是string1和string2中较小的那一个,也就是string2,然而在调用中string2的lifetime要小于result的lifetime,和 'a 想要的形容不一致,因此上面的代码不会编译通过。
Static Lifetime
虽然前面说过Lifetime syntax只是一种标识或指示,但在Rust中也存在由特定含义的Lifetime,比如 'static 。
回到上一个比较字符串的例子当中,我们用的都是 String , 它们之后调用 as_str() 作为方法参数。代码不会编译通过是因为违背了Lifetime标识。但是,如果你使用 &str 作为 string1 和 string2 的类型,代码则可以编译通过。
fn main() {let string1 = "long string is long";let result;{let string2 ="xyz";result = longest(string1, string2);}println!("The longest string is {}", result);}
这是因为 &str 字面量拥有 'static 的Lifetime。声名字面量相当于:
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 关键字来说,它可以直接使用在变量声明上:
static BYTES: [u8; 3] = [1, 2, 3];
因而 BYTES 有用如下特性:
- 编译时创建
- 只有在unsafe代码中才能改变(mutate)
- 在整个程序中都有效
Static is not ‘static
变量拥有'staticLifetime 标识,但并非在整个程序中都有效,也并非只能在编译时创建。
如果你看见类似 &'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看起来是很丑陋的。
