type
Post
status
Published
date
Dec 4, 2023
slug
summary
生命周期,简而言之就是引用的有效作用域。在大多数时候,我们无需手动的声明生命周期,因为编译器可以自动进行推导,Rust 生命周期之所以难,是因为这个概念对于我们来说是全新的,没有其它编程语言的经验可以借鉴。
tags
Rust
category
技术分享
icon
password
声明:部分内容来自《Rust语言圣经(Rust Course)》,其中主要来自: - 2.10 认识生命周期 - 4.1 生命周期

认识生命周期

生命周期,简而言之就是引用的有效作用域。在大多数时候,我们无需手动的声明生命周期,因为编译器可以自动进行推导,用类型来类比下:
  • 就像编译器大部分时候可以自动推导类型 <-> 一样,编译器大多数时候也可以自动推导生命周期
  • 在多种类型存在时,编译器往往要求我们手动标明类型 <-> 当多个生命周期存在,且编译器无法推导出某个引用的生命周期时,就需要我们手动标明生命周期
Rust 生命周期之所以难,是因为这个概念对于我们来说是全新的,没有其它编程语言的经验可以借鉴。(生命周期很可能是 Rust 中最难的部分)。

悬垂指针和生命周期

生命周期的主要作用是避免悬垂引用,它会导致程序引用了本不该引用的数据:
{ let r; { let x = 5; r = &x; } println!("r: {}", r); }
这段代码有几点值得注意:
  • let r; 的声明方式貌似存在使用 null 的风险,实际上,当我们不初始化它就使用时,编译器会给予报错
  • r 引用了内部花括号中的 x 变量,但是 x 会在内部花括号 } 处被释放,因此回到外部花括号后,r 会引用一个无效的 x
此处 r 就是一个悬垂指针,它引用了提前被释放的变量 x,可以预料到,这段代码会报错:
error[E0597]: `x` does not live long enough // `x` 活得不够久 --> src/main.rs:7:17 | 7 | r = &x; | ^^ borrowed value does not live long enough // 被借用的 `x` 活得不够久 8 | } | - `x` dropped here while still borrowed // `x` 在这里被丢弃,但是它依然还在被借用 9 | 10 | println!("r: {}", r); | - borrow later used here // 对 `x` 的借用在此处被使用
在这里 r 拥有更大的作用域,或者说活得更久。如果 Rust 不阻止该悬垂引用的发生,那么当 x 被释放后,r 所引用的值就不再是合法的,会导致我们程序发生异常行为,且该异常行为有时候会很难被发现。

借用检查

为了保证 Rust 的所有权和借用的正确性,Rust 使用了一个借用检查器(Borrow checker),来检查我们程序的借用正确性:
{ let r; // ---------+-- 'a // | { // | let x = 5; // -+-- 'b | r = &x; // | | } // -+ | // | println!("r: {}", r); // | } // ---------+
这段代码和之前的一模一样,唯一的区别在于增加了对变量生命周期的注释。这里,r 变量被赋予了生命周期 'ax 被赋予了生命周期 'b,从图示上可以明显看出生命周期 'b 比 'a 小很多。
在编译期,Rust 会比较两个变量的生命周期,结果发现 r 明明拥有生命周期 'a,但是却引用了一个小得多的生命周期 'b,在这种情况下,编译器会认为我们的程序存在风险,因此拒绝运行。
如果想要编译通过,也很简单,只要 'b 比 'a 大就好。总之,x 变量只要比 r 活得久,那么 r 就能随意引用 x 且不会存在危险:
{ let x = 5; // ----------+-- 'b // | let r = &x; // --+-- 'a | // | | println!("r: {}", r); // | | // --+ | } // ----------+
根据之前的结论,我们重新实现了代码,现在 x 的生命周期 'b 大于 r 的生命周期 'a,因此 r 对 x 的引用是安全的。

函数中的生命周期

先来考虑一个例子-返回两个字符串切片中较长的那个,该函数的参数是两个字符串切片,返回值也是字符串切片:
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(a: &str, b: &str) -> &str { if a.len() > b.len() { a } else { b } }
这段longest的实现,符合rust的其他语法标准,但是会产生报错:
error[E0106]: missing lifetime specifier --> src/main.rs:9:33 | 9 | fn longest(x: &str, y: &str) -> &str { | ---- ---- ^ expected named 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` = 帮助: 该函数的返回值是一个引用类型,但是函数签名无法说明,该引用是借用自 `x` 还是 `y` help: consider introducing a named lifetime parameter // 考虑引入一个生命周期 | 9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { | ^^^^ ^^^^^^^ ^^^^^^^ ^^^
这主要是编译器无法知道该函数的返回值到底引用 x 还是 y ,因为编译器需要知道这些,来确保函数调用后的引用生命周期分析
我们在定义该函数时,首先无法知道传递给函数的具体值,因此到底是 if 还是 else 被执行,无从得知。其次,传入引用的具体生命周期也无法知道,因此也不能像之前的例子那样通过分析生命周期来确定引用是否有效。同时,编译器的借用检查也无法推导出返回值的生命周期,因为它不知道 x 和 y 的生命周期跟返回值的生命周期之间的关系是怎样的。
因此,在存在多个引用时,编译器有时会无法自动推导生命周期,此时就需要我们手动去标注,通过为参数标注合适的生命周期来帮助编译器进行借用检查的分析。

生命周期标注语法

生命周期标注并不会改变任何引用的实际作用域,标记的生命周期只是为了取悦编译器,让编译器不要难为我们。
生命周期的语法也颇为与众不同,以 ' 开头,名称往往是一个单独的小写字母,大多数人都用 'a 来作为生命周期的名称。 如果是引用类型的参数,那么生命周期会位于引用符号 & 之后,并用一个空格来将生命周期和引用参数分隔开:
&i32 // 一个引用 &'a i32 // 具有显式生命周期的引用 &'a mut i32 // 具有显式生命周期的可变引用
一个生命周期标注,它自身并不具有什么意义,因为生命周期的作用就是告诉编译器多个引用之间的关系。例如,有一个函数,它的第一个参数 first 是一个指向 i32 类型的引用,具有生命周期 'a,该函数还有另一个参数 second,它也是指向 i32 类型的引用,并且同样具有生命周期 'a。此处生命周期标注仅仅说明,这两个参数 first 和 second 至少活得和'a 一样久,至于到底活多久或者哪个活得更久,抱歉我们都无法得知
fn useless<'a>(first: &'a i32, second: &'a i32) {}

函数签名中的生命周期标注

继续之前的 longest 函数,从两个字符串切片中返回较长的那个:
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() > b.len() { a } else { b } }
需要注意的点如下:
  • 和泛型一样,使用生命周期参数,需要先声明 <'a>
  • xy 和返回值至少活得和 'a 一样久(因为返回值要么是 x,要么是 y)
该函数签名表明对于某些生命周期 'a,函数的两个参数都至少跟 'a 活得一样久,同时函数的返回引用也至少跟 'a 活得一样久。实际上,这意味着返回值的生命周期与参数生命周期中的较小值一致:虽然两个参数的生命周期都是标注了 'a,但是实际上这两个参数的真实生命周期可能是不一样的(生命周期 'a 不代表生命周期等于 'a,而是大于等于 'a)。
在通过函数签名指定生命周期参数时,我们并没有改变传入引用或者返回引用的真实生命周期,而是告诉编译器当不满足此约束条件时,就拒绝编译通过
因此 longest 函数并不知道 x 和 y 具体会活多久,只要知道它们的作用域至少能持续 'a 这么长就行。
当把具体的引用传给 longest 时,那生命周期 'a 的大小就是 x 和 y 的作用域的重合部分,换句话说,'a 的大小将等于 x 和 y 中较小的那个。由于返回值的生命周期也被标记为 'a,因此返回值的生命周期也是 x 和 y 中作用域较小的那个。
例如:
fn main() { let string1 = String::from("long string is long"); { let string2 = String::from("xyz"); let result = longest(string1.as_str(), string2.as_str()); println!("The longest string is {result}."); } }
可以顺利通过编译,而
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}."); }
则会产生报错:
error[E0597]: `string2` does not live long enough --> src/main.rs:6:44 | 6 | result = longest(string1.as_str(), string2.as_str()); | ^^^^^^^ borrowed value does not live long enough 7 | } | - `string2` dropped here while still borrowed 8 | println!("The longest string is {}", result); | ------ borrow later used here
总之,显式的使用生命周期,可以让编译器正确的认识到多个引用之间的关系,最终帮我们提前规避可能存在的代码风险。

深入思考生命周期

使用生命周期的方式往往取决于函数的功能,例如之前的 longest 函数,如果它永远只返回第一个参数 xy 完全没有被使用,因此 y 的生命周期与 x 和返回值的生命周期没有任何关系,意味着我们也不必再为 y 标注生命周期,只需要标注 x 参数和返回值即可。
fn longest<'a>(x: &'a str, y: &str) -> &'a str { x }
函数的返回值如果是一个引用类型,那么它的生命周期只会来源于
  • 函数参数的生命周期
  • 函数体中某个新建引用的生命周期
若是后者情况,就是典型的悬垂引用场景:
fn longest<'a>(x: &str, y: &str) -> &'a str { let result = String::from("really long string"); result.as_str() }
上面的函数的返回值就和参数 xy 没有任何关系,而是引用了函数体内创建的字符串,那么很显然,该函数会报错:
error[E0515]: cannot return value referencing local variable `result` // 返回值result引用了本地的变量 --> src/main.rs:11:5 | 11 | result.as_str() | ------^^^^^^^^^ | | | returns a value referencing data owned by the current function | `result` is borrowed here
主要问题就在于,result 在函数结束后就被释放,但是在函数结束后,对 result 的引用依然在继续。在这种情况下,没有办法指定合适的生命周期来让编译通过,因此我们也就在 Rust 中避免了悬垂引用。
那遇到这种情况该怎么办?最好的办法就是返回内部字符串的所有权,然后把字符串的所有权转移给调用者:
fn longest<'a>(_x: &str, _y: &str) -> String { String::from("really long string") } fn main() { let s = longest("not", "important"); }
至此,可以对生命周期进行下总结:生命周期语法用来将函数的多个引用参数和返回值的作用域关联到一起,一旦关联到一起后,Rust 就拥有充分的信息来确保我们的操作是内存安全的。

结构体中的生命周期

不仅仅函数具有生命周期,结构体其实也有这个概念。细心的同学应该能回想起来,之前为什么不在结构体中使用字符串字面量或者字符串切片,而是统一使用 String 类型?原因很简单,后者在结构体初始化时,只要转移所有权即可,而前者,抱歉,它们是引用,它们不能为所欲为。
既然之前已经理解了生命周期,那么意味着在结构体中使用引用也变得可能:只要为结构体中的每一个引用标注上生命周期即可:
struct ImportantExcerpt<'a> { part: &'a str, } fn main() { let novel = String::from("Call me Ishmael. Some years ago..."); let first_sentence = novel.split('.').next().expect("Could not find a '.'"); let i = ImportantExcerpt { part: first_sentence, }; }
ImportantExcerpt 结构体中有一个引用类型的字段 part,因此需要为它标注上生命周期。结构体的生命周期标注语法跟泛型参数语法很像,需要对生命周期参数进行声明 <'a>。该生命周期标注说明,结构体 ImportantExcerpt 所引用的字符串 str 必须比该结构体活得更久
从 main 函数实现来看,ImportantExcerpt 的生命周期从第 4 行开始,到 main 函数末尾结束,而该结构体引用的字符串从第一行开始,也是到 main 函数末尾结束,可以得出结论结构体引用的字符串活得比结构体久,这符合了编译器对生命周期的要求,因此编译通过。
与之相反,下面的代码就无法通过编译:
#[derive(Debug)] struct ImportantExcerpt<'a> { part: &'a str, } fn main() { let i; { let novel = String::from("Call me Ishmael. Some years ago..."); let first_sentence = novel.split('.').next().expect("Could not find a '.'"); i = ImportantExcerpt { part: first_sentence, }; } println!("{:?}",i); }
观察代码,可以看出结构体比它引用的字符串活得更久,引用字符串在内部语句块末尾 } 被释放后,println! 依然在外面使用了该结构体,因此会导致无效的引用,不出所料,编译报错:
error[E0597]: `novel` does not live long enough --> src/main.rs:10:30 | 10 | let first_sentence = novel.split('.').next().expect("Could not find a '.'"); | ^^^^^^^^^^^^^^^^ borrowed value does not live long enough ... 14 | } | - `novel` dropped here while still borrowed 15 | println!("{:?}",i); | - borrow later used here

生命周期消除

实际上,对于编译器来说,每一个引用类型都有一个生命周期,那么为什么在我们使用过程中,很多时候都无需标注生命周期?例如:
fn first_word(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return &s[0..i]; } } &s[..] }
该函数的参数和返回值都是引用类型,尽管我们没有显式的为其标注生命周期,编译依然可以通过。其实原因不复杂,编译器为了简化用户的使用,运用了生命周期消除大法
对于 first_word 函数,它的返回值是一个引用类型,那么该引用只有两种情况:
  • 从参数获取
  • 从函数体内部新创建的变量获取
如果是后者,就会出现悬垂引用,最终被编译器拒绝,因此只剩一种情况:返回值的引用是获取自参数,这就意味着参数和返回值的生命周期是一样的。道理很简单,我们能看出来,编译器自然也能看出来,因此,就算我们不标注生命周期,也不会产生歧义。
实际上,在 Rust 1.0 版本之前,这种代码果断不给通过,因为 Rust 要求必须显式的为所有引用标注生命周期:
fn first_word<'a>(s: &'a str) -> &'a str {
在写了大量的类似代码后,Rust 社区抱怨声四起,包括开发者自己都忍不了了,最终揭锅而起,这才有了我们今日的幸福。
生命周期消除的规则不是一蹴而就,而是伴随着 总结-改善 流程的周而复始,一步一步走到今天,这也意味着,该规则以后可能也会进一步增加,我们需要手动标注生命周期的时候也会越来越少,hooray!
在开始之前有几点需要注意:
  • 消除规则不是万能的,若编译器不能确定某件事是正确时,会直接判为不正确,那么你还是需要手动标注生命周期
  • 函数或者方法中,参数的生命周期被称为 输入生命周期,返回值的生命周期被称为 输出生命周期

三条消除规则

编译器使用三条消除规则来确定哪些场景不需要显式地去标注生命周期。其中第一条规则应用在输入生命周期上,第二、三条应用在输出生命周期上。若编译器发现三条规则都不适用时,就会报错,提示你需要手动标注生命周期。
  1. 每一个引用参数都会获得独自的生命周期
    1. 例如一个引用参数的函数就有一个生命周期标注: fn foo<'a>(x: &'a i32),两个引用参数的有两个生命周期标注:fn foo<'a, 'b>(x: &'a i32, y: &'b i32), 依此类推。
  1. 若只有一个输入生命周期(函数参数中只有一个引用类型),那么该生命周期会被赋给所有的输出生命周期,也就是所有返回值的生命周期都等于该输入生命周期
    1. 例如函数 fn foo(x: &i32) -> &i32x 参数的生命周期会被自动赋给返回值 &i32,因此该函数等同于 fn foo<'a>(x: &'a i32) -> &'a i32
  1. 若存在多个输入生命周期,且其中一个是 &self 或 &mut self,则 &self 的生命周期被赋给所有的输出生命周期
    1. 拥有 &self 形式的参数,说明该函数是一个 方法,该规则让方法的使用便利度大幅提升。
规则其实很好理解,但是,爱思考的读者肯定要发问了,例如第三条规则,若一个方法,它的返回值的生命周期就是跟参数 &self 的不一样怎么办?总不能强迫我返回的值总是和 &self 活得一样久吧?! 问得好,答案很简单:手动标注生命周期,因为这些规则只是编译器发现你没有标注生命周期时默认去使用的,当你标注生命周期后,编译器自然会乖乖听你的话。
例子 1
fn first_word(s: &str) -> &str { // 实际项目中的手写代码
首先,我们手写的代码如上所示时,编译器会先应用第一条规则,为每个参数标注一个生命周期:
fn first_word<'a>(s: &'a str) -> &str { // 编译器自动为参数添加生命周期
此时,第二条规则就可以进行应用,因为函数只有一个输入生命周期,因此该生命周期会被赋予所有的输出生命周期:
fn first_word<'a>(s: &'a str) -> &'a str { // 编译器自动为返回值添加生命周期
此时,编译器为函数签名中的所有引用都自动添加了具体的生命周期,因此编译通过,且用户无需手动去标注生命周期,只要按照 fn first_word(s: &str) -> &str { 的形式写代码即可。
例子 2 
再来看一个例子:
fn longest(x: &str, y: &str) -> &str { // 实际项目中的手写代码
首先,编译器会应用第一条规则,为每个参数都标注生命周期:
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
但是此时,第二条规则却无法被使用,因为输入生命周期有两个,第三条规则也不符合,因为它是函数,不是方法,因此没有 &self 参数。在套用所有规则后,编译器依然无法为返回值标注合适的生命周期,因此,编译器就会报错,提示我们需要手动标注生命周期:
error[E0106]: missing lifetime specifier --> src/main.rs:1:47 | 1 | fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str { | ------- ------- ^ expected named 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` note: these named lifetimes are available to use --> src/main.rs:1:12 | 1 | fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str { | ^^ ^^ help: consider using one of the available lifetimes here | 1 | fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &'lifetime str { | +++++++++
不得不说,Rust 编译器真的很强大,还贴心的给我们提示了该如何修改,虽然。。。好像。。。。它的提示貌似不太准确。这里我们更希望参数和返回值都是 'a 生命周期。

方法中的生命周期

先来回忆下泛型的语法:
struct Point<T> { x: T, y: T, } impl<T> Point<T> { fn x(&self) -> &T { &self.x } }
实际上,为具有生命周期的结构体实现方法时,我们使用的语法跟泛型参数语法很相似:
struct ImportantExcerpt<'a> { part: &'a str, } impl<'a> ImportantExcerpt<'a> { fn level(&self) -> i32 { 3 } }
其中有几点需要注意的:
  • impl 中必须使用结构体的完整名称,包括 <'a>,因为生命周期标注也是结构体类型的一部分
  • 方法签名中,往往不需要标注生命周期,得益于生命周期消除的第一和第三规则
下面的例子展示了第三规则应用的场景:
impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part(&self, announcement: &str) -> &str { println!("Attention please: {}", announcement); self.part } }
首先,编译器应用第一规则,给予每个输入参数一个生命周期:
impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &str { println!("Attention please: {}", announcement); self.part } }
需要注意的是,编译器不知道 announcement 的生命周期到底多长,因此它无法简单的给予它生命周期 'a,而是重新声明了一个全新的生命周期 'b
接着,编译器应用第三规则,将 &self 的生命周期赋给返回值 &str
impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'a str { println!("Attention please: {}", announcement); self.part } }
最开始的代码,尽管我们没有给方法标注生命周期,但是在第一和第三规则的配合下,编译器依然完美的为我们亮起了绿灯。
如果将方法返回的生命周期改为'b
impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'b str { println!("Attention please: {}", announcement); self.part } }
此时,编译器会报错,因为编译器无法知道 'a 和 'b 的关系。 &self 生命周期是 'a,那么 self.part 的生命周期也是 'a,但是好巧不巧的是,我们手动为返回值 self.part 标注了生命周期 'b,因此编译器需要知道 'a 和 'b 的关系。
有一点很容易推理出来:由于 &'a self 是被引用的一方,因此引用它的 &'b str 必须要活得比它短,否则会出现悬垂引用。因此说明生命周期 'b 必须要比 'a 小,只要满足了这一点,编译器就不会再报错:
impl<'a: 'b, 'b> ImportantExcerpt<'a> { fn announce_and_return_part(&'a self, announcement: &'b str) -> &'b str { println!("Attention please: {}", announcement); self.part } }
稍微解释下:
  • 'a: 'b,是生命周期约束语法,跟泛型约束非常相似,用于说明 'a 必须比 'b 活得久
  • 可以把 'a 和 'b 都在同一个地方声明(如上),或者分开声明但通过 where 'a: 'b 约束生命周期关系

静态生命周期

在 Rust 中有一个非常特殊的生命周期,那就是 'static,拥有该生命周期的引用可以和整个程序活得一样久。
在之前我们学过字符串字面量,提到过它是被硬编码进 Rust 的二进制文件中,因此这些字符串变量全部具有 'static 的生命周期:
let s: &'static str = "我没啥优点,就是活得久,嘿嘿";
这时候,有些聪明的小脑瓜就开始开动了:当生命周期不知道怎么标时,对类型施加一个静态生命周期的约束 T: 'static 是不是很爽?这样我和编译器再也不用操心它到底活多久了。
嗯,只能说,这个想法是对的,在不少情况下,'static 约束确实可以解决生命周期编译不通过的问题,但是问题来了:本来该引用没有活那么久,但是你非要说它活那么久,万一引入了潜在的 BUG 怎么办?
因此,遇到因为生命周期导致的编译不通过问题,首先想的应该是:是否是我们试图创建一个悬垂引用,或者是试图匹配不一致的生命周期,而不是简单粗暴的用 'static 来解决问题。
但是,话说回来,存在即合理,有时候,'static 确实可以帮助我们解决非常复杂的生命周期问题甚至是无法被手动解决的生命周期问题,那么此时就应该放心大胆的用,只要你确定:你的所有引用的生命周期都是正确的,只是编译器太笨不懂罢了
总结下:
  • 生命周期 'static 意味着能和程序活得一样久,例如字符串字面量和特征对象
  • 实在遇到解决不了的生命周期标注问题,可以尝试 T: 'static,有时候它会给你奇迹

深入生命周期

不太聪明的生命周期检查

在 Rust 语言学习中,一个很重要的部分就是阅读一些你可能不经常遇到,但是一旦遇到就难以理解的代码,这些代码往往最令人头疼的就是生命周期,这里我们就来看看一些本以为可以编译,但是却因为生命周期系统不够聪明导致编译失败的代码。

例子1

#[derive(Debug)] struct Foo; impl Foo { fn mutate_and_share(&mut self) -> &Self { &*self } fn share(&self) {} } fn main() { let mut foo = Foo; let loan = foo.mutate_and_share(); foo.share(); println!("{:?}", loan); }
上面的代码中,foo.mutate_and_share() 虽然借用了 &mut self,但是它最终返回的是一个 &self,然后赋值给 loan,因此理论上来说它最终是进行了不可变借用,同时 foo.share 也进行了不可变借用,那么根据 Rust 的借用规则:多个不可变借用可以同时存在,因此该代码应该编译通过。
事实上,运行代码后,你将看到一个错误:
error[E0502]: cannot borrow `foo` as immutable because it is also borrowed as mutable --> src/main.rs:12:5 | 11 | let loan = foo.mutate_and_share(); | ---------------------- mutable borrow occurs here 12 | foo.share(); | ^^^^^^^^^^^ immutable borrow occurs here 13 | println!("{:?}", loan); | ---- mutable borrow later used here
编译器的提示在这里其实有些难以理解,因为可变借用仅在 mutate_and_share 方法内部有效,出了该方法后,就只有返回的不可变借用,因此,按理来说可变借用不应该在 main 的作用范围内存在。
对于这个反直觉的事情,让我们用生命周期来解释下,可能你就很好理解了:
struct Foo; impl Foo { fn mutate_and_share<'a>(&'a mut self) -> &'a Self { &'a *self } fn share<'a>(&'a self) {} } fn main() { 'b: { let mut foo: Foo = Foo; 'c: { let loan: &'c Foo = Foo::mutate_and_share::<'c>(&'c mut foo); 'd: { Foo::share::<'d>(&'d foo); } println!("{:?}", loan); } } }
以上是模拟了编译器的生命周期标注后的代码,可以注意到 &mut foo 和 loan 的生命周期都是 'c
还记得生命周期消除规则中的第三条吗?因为该规则,导致了 mutate_and_share 方法中,参数 &mut self 和返回值 &self 的生命周期是相同的,因此,若返回值的生命周期在 main 函数有效,那 &mut self 的借用也是在 main 函数有效。
这就解释了可变借用为啥会在 main 函数作用域内有效,最终导致 foo.share() 无法再进行不可变借用。
总结下:&mut self 借用的生命周期和 loan 的生命周期相同,将持续到 println 结束。而在此期间 foo.share() 又进行了一次不可变 &foo 借用,违背了可变借用与不可变借用不能同时存在的规则,最终导致了编译错误。
上述代码实际上完全是正确的,但是因为生命周期系统的“粗糙实现”,导致了编译错误,目前来说,遇到这种生命周期系统不够聪明导致的编译错误,我们也没有太好的办法,只能修改代码去满足它的需求,并期待以后它会更聪明。

例子2

#![allow(unused)] fn main() { use std::collections::HashMap; use std::hash::Hash; fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V where K: Clone + Eq + Hash, V: Default, { match map.get_mut(&key) { Some(value) => value, None => { map.insert(key.clone(), V::default()); map.get_mut(&key).unwrap() } } } }
这段代码不能通过编译的原因是编译器未能精确地判断出某个可变借用不再需要,反而谨慎的给该借用安排了一个很大的作用域,结果导致后续的借用失败:
error[E0499]: cannot borrow `*map` as mutable more than once at a time --> src/main.rs:13:17 | 5 | fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V | -- lifetime `'m` defined here ... 10 | match map.get_mut(&key) { | - ----------------- first mutable borrow occurs here | _________| | | 11 | | Some(value) => value, 12 | | None => { 13 | | map.insert(key.clone(), V::default()); | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ second mutable borrow occurs here 14 | | map.get_mut(&key).unwrap() 15 | | } 16 | | } | |_________- returning this value requires that `*map` is borrowed for `'m`
分析代码可知在 match map.get_mut(&key) 方法调用完成后,对 map 的可变借用就可以结束了。但从报错看来,编译器不太聪明,它认为该借用会持续到整个 match 语句块的结束(第 16 行处),这便造成了后续借用的失败。
类似的例子还有很多,由于篇幅有限,就不在这里一一列举,如果大家想要阅读更多的类似代码,可以看看<<Rust 代码鉴赏>>一书。

无界生命周期

不安全代码(unsafe)经常会凭空产生引用或生命周期,这些生命周期被称为是 无界(unbound) 的。
无界生命周期往往是在解引用一个裸指针(裸指针 raw pointer)时产生的,换句话说,它是凭空产生的,因为输入参数根本就没有这个生命周期:
fn f<'a, T>(x: *const T) -> &'a T { unsafe { &*x } }
上述代码中,参数 x 是一个裸指针,它并没有任何生命周期,然后通过 unsafe 操作后,它被进行了解引用,变成了一个 Rust 的标准引用类型,该类型必须要有生命周期,也就是 'a
可以看出 'a 是凭空产生的,因此它是无界生命周期。这种生命周期由于没有受到任何约束,因此它想要多大就多大,这实际上比 'static 要强大。例如 &'static &'a T 是无效类型,但是无界生命周期 &'unbounded &'a T 会被视为 &'a &'a T 从而通过编译检查,因为它可大可小,就像孙猴子的金箍棒一般。
我们在实际应用中,要尽量避免这种无界生命周期。最简单的避免无界生命周期的方式就是在函数声明中运用生命周期消除规则。若一个输出生命周期被消除了,那么必定因为有一个输入生命周期与之对应

生命周期约束 HRTB

生命周期约束跟特征约束类似,都是通过形如 'a: 'b 的语法,来说明两个生命周期的长短关系。

'a: 'b

假设有两个引用 &'a i32 和 &'b i32,它们的生命周期分别是 'a 和 'b,若 'a >= 'b,则可以定义 'a:'b,表示 'a 至少要活得跟 'b 一样久。
struct DoubleRef<'a,'b:'a, T> { r: &'a T, s: &'b T }
例如上述代码定义一个结构体,它拥有两个引用字段,类型都是泛型 T,每个引用都拥有自己的生命周期,由于我们使用了生命周期约束 'b: 'a,因此 'b 必须活得比 'a 久,也就是结构体中的 s 字段引用的值必须要比 r 字段引用的值活得要久。

T: 'a

表示类型 T 必须比 'a 活得要久:
struct Ref<'a, T: 'a> { r: &'a T }
因为结构体字段 r 引用了 T,因此 r 的生命周期 'a 必须要比 T 的生命周期更短(被引用者的生命周期必须要比引用长)。
在 Rust 1.30 版本之前,该写法是必须的,但是从 1.31 版本开始,编译器可以自动推导 T: 'a 类型的约束,因此我们只需这样写即可:
struct Ref<'a, T> { r: &'a T }
来看一个使用了生命周期约束的综合例子:
struct ImportantExcerpt<'a> { part: &'a str, } impl<'a: 'b, 'b> ImportantExcerpt<'a> { fn announce_and_return_part(&'a self, announcement: &'b str) -> &'b str { println!("Attention please: {}", announcement); self.part } }
上面的例子中必须添加约束 'a: 'b 后,才能成功编译,因为 self.part 的生命周期与 self的生命周期一致,将 &'a 类型的生命周期强行转换为 &'b 类型,会报错,只有在 'a >= 'b 的情况下,'a 才能转换成 'b

闭包函数的消除规则

先来看一段简单的代码:
fn fn_elision(x: &i32) -> &i32 { x } let closure_slision = |x: &i32| -> &i32 { x };
error: lifetime may not live long enough --> src/main.rs:39:39 | 39 | let closure = |x: &i32| -> &i32 { x }; // fails | - - ^ returning this value requires that `'1` must outlive `'2` | | | | | let's call the lifetime of this reference `'2` | let's call the lifetime of this reference `'1`
明明两个一模一样功能的函数,一个正常编译,一个却报错,错误原因是编译器无法推测返回的引用和传入的引用谁活得更久!
真的是非常奇怪的错误,学过上一节的读者应该都记得这样一条生命周期消除规则:如果函数参数中只有一个引用类型,那该引用的生命周期会被自动分配给所有的返回引用。我们当前的情况完美符合, function 函数的顺利编译通过,就充分说明了问题。
先给出一个结论:这个问题,可能很难被解决,建议大家遇到后,还是老老实实用正常的函数,不要秀闭包了
对于函数的生命周期而言,它的消除规则之所以能生效是因为它的生命周期完全体现在签名的引用类型上,在函数体中无需任何体现:
fn fn_elision(x: &i32) -> &i32 {..}
因此编译器可以做各种编译优化,也很容易根据参数和返回值进行生命周期的分析,最终得出消除规则。
可是闭包,并没有函数那么简单,它的生命周期分散在参数和闭包函数体中(主要是它没有确切的返回值签名):
let closure_slision = |x: &i32| -> &i32 { x };
编译器就必须深入到闭包函数体中,去分析和推测生命周期,复杂度因此急剧提升:试想一下,编译器该如何从复杂的上下文中分析出参数引用的生命周期和闭包体中生命周期的关系?
由于上述原因(当然,实际情况复杂的多),Rust 语言开发者目前其实是有意针对函数和闭包实现了两种不同的生命周期消除规则。
用 Fn 特征解决闭包生命周期
fn main() { let closure_slision = fun(|x: &i32| -> &i32 { x }); assert_eq!(*closure_slision(&45), 45); // Passed ! } fn fun<T, F: Fn(&T) -> &T>(f: F) -> F { f }

NLL(Non-Lexical Lifetime)

引用的生命周期正常来说应该从借用开始一直持续到作用域结束,但是这种规则会让多引用共存的情况变得更复杂:
fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; println!("{} and {}", r1, r2); // 新编译器中,r1,r2作用域在这里结束 let r3 = &mut s; println!("{}", r3); }
按照上述规则,这段代码将会报错,因为 r1 和 r2 的不可变引用将持续到 main 函数结束,而在此范围内,我们又借用了 r3 的可变引用,这违反了借用的规则:要么多个不可变借用,要么一个可变借用。
好在,该规则从 1.31 版本引入 NLL 后,就变成了:引用的生命周期从借用处开始,一直持续到最后一次使用的地方
按照最新的规则,我们再来分析一下上面的代码。r1 和 r2 不可变借用在 println! 后就不再使用,因此生命周期也随之结束,那么 r3 的借用就不再违反借用的规则,皆大欢喜。
再来看一段关于 NLL 的代码解释:
let mut u = 0i32; let mut v = 1i32; let mut w = 2i32; // lifetime of `a` = α ∪ β ∪ γlet mut a = &mut u;// --+ α. lifetime of `&mut u` --+ lexical "lifetime" of `&mut u`,`&mut u`, `&mut w` and `a`use(a);// | | *a = 3;// <-----------------+ | ...// | a = &mut v;// --+ β. lifetime of `&mut v` |use(a);// | | *a = 4;// <-----------------+ | ...// | a = &mut w;// --+ γ. lifetime of `&mut w` |use(a);// | | *a = 5;// <-----------------+ <--------------------------+
这段代码一目了然,a 有三段生命周期:αβγ,每一段生命周期都随着当前值的最后一次使用而结束。

Reborrow再借用

先来看一段代码:
#[derive(Debug)] struct Point { x: i32, y: i32, } impl Point { fn move_to(&mut self, x: i32, y: i32) { self.x = x; self.y = y; } } fn main() { let mut p = Point { x: 0, y: 0 }; let r = &mut p; let rr: &Point = &*r; println!("{:?}", rr); r.move_to(10, 10); println!("{:?}", r); }
以上代码,大家可能会觉得可变引用 r 和不可变引用 rr 同时存在会报错吧?但是事实上并不会,原因在于 rr 是对 r 的再借用。
对于再借用而言,rr 再借用时不会破坏借用规则,但是你不能在它的生命周期内再使用原来的借用 r,来看看对上段代码的分析:
fn main() { let mut p = Point { x: 0, y: 0 }; let r = &mut p; // reborrow! 此时对`r`的再借用不会导致跟上面的借用冲突let rr: &Point = &*r; // 再借用`rr`最后一次使用发生在这里,在它的生命周期中,我们并没有使用原来的借用`r`,因此不会报错println!("{:?}", rr); // 再借用结束后,才去使用原来的借用`r` r.move_to(10, 10); println!("{:?}", r); }
再来看一个例子:
use std::vec::Vec; fn read_length(strings: &mut Vec<String>) -> usize { strings.len() }
如上所示,函数体内对参数的二次借用也是典型的 Reborrow 场景。
那么下面让我们来做件坏事,破坏这条规则,使其报错:
fn main() { let mut p = Point { x: 0, y: 0 }; let r = &mut p; let rr: &Point = &*r; r.move_to(10, 10); println!("{:?}", rr); println!("{:?}", r); }
果然,破坏永远比重建简单 :) 只需要在 rr 再借用的生命周期内使用一次原来的借用 r 即可!

生命周期消除规则补充

我们介绍了三大基础生命周期消除规则,实际上,随着 Rust 的版本进化,该规则也在不断演进,这里再介绍几个常见的消除规则:

impl 块消除

impl<'a> Reader for BufReader<'a> { // methods go here// impl内部实际上没有用到'a }
如果你以前写的impl块长上面这样,同时在 impl 内部的方法中,根本就没有用到 'a,那就可以写成下面的代码形式。
impl Reader for BufReader<'_> { // methods go here }
'_ 生命周期表示 BufReader 有一个不使用的生命周期,我们可以忽略它,无需为它创建一个名称。
歪个楼,有读者估计会发问:既然用不到 'a,为何还要写出来?如果你仔细回忆下上一节的内容,里面有一句专门用粗体标注的文字:生命周期参数也是类型的一部分,因此 BufReader<'a> 是一个完整的类型,在实现它的时候,你不能把 'a 给丢了!

生命周期约束消除

// Rust 2015struct Ref<'a, T: 'a> { field: &'a T } // Rust 2018struct Ref<'a, T> { field: &'a T }
在本节的生命周期约束中,也提到过,新版本 Rust 中,上面情况中的 T: 'a 可以被消除掉,当然,你也可以显式的声明,但是会影响代码可读性。关于类似的场景,Rust 团队计划在未来提供更多的消除规则,但是,计划未来就等于未知。

一个复杂的例子

下面是一个关于生命周期声明过大的例子,会较为复杂。
struct Interface<'a> { manager: &'a mut Manager<'a> } impl<'a> Interface<'a> { pub fn noop(self) { println!("interface consumed"); } } struct Manager<'a> { text: &'a str } struct List<'a> { manager: Manager<'a>, } impl<'a> List<'a> { pub fn get_interface(&'a mut self) -> Interface { Interface { manager: &mut self.manager } } } fn main() { let mut list = List { manager: Manager { text: "hello" } }; list.get_interface().noop(); println!("Interface should be dropped here and the borrow released"); // 下面的调用会失败,因为同时有不可变/可变借用// 但是Interface在之前调用完成后就应该被释放了 use_list(&list); } fn use_list(list: &List) { println!("{}", list.manager.text); }
运行后报错:
error[E0502]: cannot borrow `list` as immutable because it is also borrowed as mutable // `list`无法被借用,因为已经被可变借用 --> src/main.rs:40:14 | 34 | list.get_interface().noop(); | ---- mutable borrow occurs here // 可变借用发生在这里 ... 40 | use_list(&list); | ^^^^^ | | | immutable borrow occurs here // 新的不可变借用发生在这 | mutable borrow later used here // 可变借用在这里结束
这段代码看上去并不复杂,实际上难度挺高的,首先在直觉上,list.get_interface() 借用的可变引用,按理来说应该在这行代码结束后,就归还了,但是为什么还能持续到 use_list(&list) 后面呢?
这是因为我们在 get_interface 方法中声明的 lifetime 有问题,该方法的参数的生命周期是 'a,而 List 的生命周期也是 'a,说明该方法至少活得跟 List 一样久,再回到 main 函数中,list 可以活到 main 函数的结束,因此 list.get_interface() 借用的可变引用也会活到 main 函数的结束,在此期间,自然无法再进行借用了。
要解决这个问题,我们需要为 get_interface 方法的参数给予一个不同于 List<'a> 的生命周期 'b,最终代码如下:
struct Interface<'b, 'a: 'b> { manager: &'b mut Manager<'a> } impl<'b, 'a: 'b> Interface<'b, 'a> { pub fn noop(self) { println!("interface consumed"); } } struct Manager<'a> { text: &'a str } struct List<'a> { manager: Manager<'a>, } impl<'a> List<'a> { pub fn get_interface<'b>(&'b mut self) -> Interface<'b, 'a> where 'a: 'b { Interface { manager: &mut self.manager } } } fn main() { let mut list = List { manager: Manager { text: "hello" } }; list.get_interface().noop(); println!("Interface should be dropped here and the borrow released"); // 下面的调用可以通过,因为Interface的生命周期不需要跟list一样长 use_list(&list); } fn use_list(list: &List) { println!("{}", list.manager.text); }

&'staticT:'static

Rust 的难点之一就在于它有不少容易混淆的概念,例如 &str 、str 与 String, 再比如本文标题那两位。不过与字符串也有不同,这两位对于普通用户来说往往是无需进行区分的,但是当大家想要深入学习或使用 Rust 时,它们就会成为成功路上的拦路虎了。
'static 在 Rust 中是相当常见的,例如字符串字面值就具有 'static 生命周期:
fn main() { let mark_twain: &str = "Samuel Clemens"; print_author(mark_twain); } fn print_author(author: &'static str) { println!("{}", author); }
除此之外,特征对象的生命周期也是 'static
除了 &'static 的用法外,我们在另外一种场景中也可以见到 'static 的使用:
use std::fmt::Display; fn main() { let mark_twain = "Samuel Clemens"; print(&mark_twain); } fn print<T: Display + 'static>(message: &T) { println!("{}", message); }
在这里,很明显 'static 是作为生命周期约束来使用了。 那么问题来了, &'static 和 T: 'static 的用法到底有何区别?

&'static

&'static 对于生命周期有着非常强的要求:一个引用必须要活得跟剩下的程序一样久,才能被标注为 &'static
对于字符串字面量来说,它直接被打包到二进制文件中,永远不会被 drop,因此它能跟程序活得一样久,自然它的生命周期是 'static
但是,&'static 生命周期针对的仅仅是引用,而不是持有该引用的变量,对于变量来说,还是要遵循相应的作用域规则 :
use std::{slice::from_raw_parts, str::from_utf8_unchecked}; fn get_memory_location() -> (usize, usize) { // “Hello World” 是字符串字面量,因此它的生命周期是 `'static`. // 但持有它的变量 `string` 的生命周期就不一样了,它完全取决于变量作用域,对于该例子来说,也就是当前的函数范围 let string = "Hello World!"; let pointer = string.as_ptr() as usize; let length = string.len(); (pointer, length) // `string` 在这里被 drop 释放 // 虽然变量被释放,无法再被访问,但是数据依然还会继续存活 } fn get_str_at_location(pointer: usize, length: usize) -> &'static str { // 使用裸指针需要 `unsafe{}` 语句块 unsafe { from_utf8_unchecked(from_raw_parts(pointer as *const u8, length)) } } fn main() { let (pointer, length) = get_memory_location(); let message = get_str_at_location(pointer, length); println!( "The {} bytes at 0x{:X} stored: {}", length, pointer, message ); // 如果大家想知道为何处理裸指针需要 `unsafe`,可以试着反注释以下代码 // let message = get_str_at_location(1000, 10); }
上面代码有两点值得注意:
  • &'static 的引用确实可以和程序活得一样久,因为我们通过 get_str_at_location 函数直接取到了对应的字符串
  • 持有 &'static 引用的变量,它的生命周期受到作用域的限制,大家务必不要搞混了

T:'static

相比起来,这种形式的约束就有些复杂了。
首先,在以下两种情况下,T: 'static 与 &'static 有相同的约束:T 必须活得和程序一样久。
use std::fmt::Debug; fn print_it<T: Debug + 'static>( input: T) { println!( "'static value passed in is: {:?}", input ); } fn print_it1( input: impl Debug + 'static ) { println!( "'static value passed in is: {:?}", input ); } fn main() { let i = 5; print_it(&i); print_it1(&i); }
以上代码会报错,原因很简单: &i 的生命周期无法满足 'static 的约束,如果大家将 i 修改为常量,那自然一切 OK。
见证奇迹的时候,请不要眨眼,现在我们来稍微修改下 print_it 函数:
use std::fmt::Debug; fn print_it<T: Debug + 'static>( input: &T) { println!( "'static value passed in is: {:?}", input ); } fn main() { let i = 5; print_it(&i); }
这段代码竟然不报错了!原因在于我们约束的是 T,但是使用的却是它的引用 &T,换而言之,我们根本没有直接使用 T,因此编译器就没有去检查 T 的生命周期约束!它只要确保 &T 的生命周期符合规则即可,在上面代码中,它自然是符合的。
再来看一个例子:
use std::fmt::Display; fn main() { let r1; let r2; { static STATIC_EXAMPLE: i32 = 42; r1 = &STATIC_EXAMPLE; let x = "&'static str"; r2 = x; // r1 和 r2 持有的数据都是 'static 的,因此在花括号结束后,并不会被释放 } println!("&'static i32: {}", r1); // -> 42 println!("&'static str: {}", r2); // -> &'static str let r3: &str; { let s1 = "String".to_string(); // s1 虽然没有 'static 生命周期,但是它依然可以满足 T: 'static 的约束 // 充分说明这个约束是多么的弱。。 static_bound(&s1); // s1 是 String 类型,没有 'static 的生命周期,因此下面代码会报错 r3 = &s1; // s1 在这里被 drop } println!("{}", r3); } fn static_bound<T: Display + 'static>(t: &T) { println!("{}", t); }

static 到底针对谁?

大家有没有想过,到底是 &'static 这个引用还是该引用指向的数据活得跟程序一样久呢?
答案是引用指向的数据,而引用本身是要遵循其作用域范围的,我们来简单验证下:
fn main() { { let static_string = "I'm in read-only memory"; println!("static_string: {}", static_string); // 当 `static_string` 超出作用域时,该引用不能再被使用,但是数据依然会存在于 binary 所占用的内存中 } println!("static_string reference remains alive: {}", static_string); }
以上代码不出所料会报错,原因在于虽然字符串字面量 "I'm in read-only memory" 的生命周期是 'static,但是持有它的引用并不是,它的作用域在内部花括号 } 处就结束了。
作为经验之谈,可以这么来:
  • 如果你需要添加 &'static 来让代码工作,那很可能是设计上出问题了
  • 如果你希望满足和取悦编译器,那就使用 T: 'static,很多时候它都能解决问题
一个小知识,在 Rust 标准库中,有 48 处用到了 &'static ,112 处用到了 T: 'static ,看来取悦编译器不仅仅是菜鸟需要的,高手也经常用到 :)
 
Rust 中的智能指针2023年12月学习计划