👈 8 集合类型

生命周期就是引用的有效作用域,大多情况下由编译器自动推导。

悬垂指针

生命周期的作用是避免悬垂引用,后者引用了本不该引用的数据:

{
	let r;
	{
		let x = 5;
		r = &x;
	}
	println!("{r}");
}

无法过编:

let x = 5;
    - binding `x` declared here
 
r = &x;
    ^^ borrowed value does not live long enough
 
}
- `x` dropped here while still borrowed
 
println!("{r}");
           - borrow later used here

r 的作用域更大,“活得更久”,x 被释放后,r 所引用的值不再是合法的,变为悬垂引用。

借用检查

为这段代码标注生命周期:

{
    let r;           // ---------+-- 'a
    {                //          |
        let x = 5;   // -+-- 'b  |
        r = &x;      //  |       |
    }                // -+       |
    println!("{r}"); //          |
}                    // ---------+

r 对应生命周期 'ax 对应生命周期 'b'b'a 短,因此无法过编。要解决此问题,只需让 'b 的结束不早于 'a

{
    let x = 5;       // ----------+-- 'b
    let r = &x;      // --+-- 'a  |
    println!("{r}"); //   |       |
}                    // --+-------+

函数中的生命周期

实现 longest(),接收 2 个 &str,返回较长者:

fn main() {
    let s1 = String::from("abc");
    let s2 = "mn";
    let s3 = max(s1.as_str(), s2);
}
 
fn max(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

很优雅,但是无法过编:

fn max(x: &str, y: &str) -> &str {
          ----     ----     ^ expected named lifetime parameter

这是因为编译器不知道 max() 的返回值到底引用了 x 还是 y,它需要明确这一点来确保函数调用后的引用生命周期分析。

这就是存在多个引用、编译器无法自动推导生命周期的情况,此时需要手动标注。

生命周期标注语法

生命周期标注并不改变任何引用的实际作用域。

可以理解为:有时编译器过于“聪明”,自以为什么都懂,拒绝执行代码,需要用生命周期标注告诉它“别自做主张,听我的就好”。

生命周期以 ' 开头,习惯命名为一个小写字母,通常为 'a

&i32        // 引用
&'a i32     // 具有显式生命周期的引用
&'a mut i32 // 具有显式生命周期的可变引用

与泛型类似,要使用生命周期参数,需先声明 <'a>

fn func<'a>(p1: &'a i32, p2: &'a i32) { }

func() 有 2 个参数,都是指向 i32 类型的引用,生命周期都为 'a。这个生命周期标注说明了 p1p2 至少和 'a 活得一样久,至于究竟是多久、哪个更久,都无从得知。

x: &'a T 表明参数 x 的生命周期不短于 'a

修正 max()

fn max<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

生命周期标注表明 xy 至少和 'a 活得一样久,因此返回值和 'a 活得一样久。

通过函数签名指定生命周期参数时,并没有改变传入引用或者返回引用的真实生命周期,而是告诉编译器当不满足此约束条件时应拒绝过编。

max() 不知道 xy 具体会活多久,只知道它们的作用域至少能持续 'a 这么长。当把实际的引用传递给 max() 时,'a 的大小就确定为 xy 作用域的重合部分(即 xy 中较小者),由于返回值的生命周期标注也为 'a,因此其生命周期也为 xy 中较小者。

再看一例:

fn main() {
    let s1 = String::from("abc");
    {
        let s2 = String::from("mn");
        let s3 = max(s1.as_str(), s2.as_str());
        println!("{s3}");
    }
}

s1 的作用域在 main() 结束,s2 的作用域在内部花括号结束,'a 取二者中较小者,因此 'a 的生命周期等于 s2 的生命周期,同样地,max() 返回的生命周期也为 'a,所以 s3 的生命周期等于 s2 的生命周期。观察代码,s2s3 的作用域都在内部花括号结束。因此,通过生命周期标注,我们将肉眼观察到的结论告知了编译器。

再反证结论的正确性:

fn main() {
    let s1 = String::from("abc");
    let s3;
    {
        let s2 = String::from("mn");
        s3 = max(s1.as_str(), s2.as_str());
    }
    println!("{s3}");
}

无法过编,因为:

  • s3 必须活到 println!() 处,其生命周期为 'a,因此 'a 也必须持续到 println!()
  • max() 内,s2 的生命周期为 'a,因此它也应该活到 println!(),但实际上没活到。

虽然在事实上,s1.len() > s2.len(),经由 max() 调用,s3 实际引用了 s1,且 s1 活到了 println!(),但是编译器认为这是一个“可能出错”的情况,保守起见,它仍会抛出错误。

使用生命周期标注的方式往往取决于函数的具体功能。如果 max() 永远会返回 x,那么只需标注 x 和返回值的生命周期:

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
	x
}

此时,y 没有被使用,其生命周期与返回值的生命周期毫无关系,因此无需标注。

如果函数返回一个引用类型,那么其生命周期只可能来源于:

  • 参数的生命周期。
  • 函数体中新建引用的生命周期。

第 2 种就是典型的悬垂引用:

fn max<'a>(x: &str, y: &str) -> &'a str {
    let ret = String::from("a new string");
    ret.as_str()
}

无法过编:

ret.as_str()
---^^^^^^^^^
|
returns a value referencing data owned by the current function

如果尝试返回一个属于函数的变量引用,这个变量在函数结束后就被释放,但对它的引用仍存在,无论如何修改生命周期标注都无法过编,这就是 Rust 避免悬垂引用的机制。

这种情况下,最好的处理方式是返回 String,将其所有权交给调用者。

生命周期语法用来将函数的多个引用参数和返回值的作用域关联到一起,一旦关联到一起后,Rust 就拥有充分的信息来确保我们的操作是内存安全的。

结构体中的生命周期

之前只在结构体中使用过 String,而不使用字面量或切片,因为前者在结构体初始化时能够转移所有权,而后者是引用,不能为所欲为。

但结构体中也可以使用引用,只需为每个引用标注生命周期:

struct Note<'a> {
    content: &'a str,
}
 
fn main() {
    let sentence = "Hello";
    let note = Note { content: sentence };
}

'a 表明 content 所引用的字符串 str 至少与结构体活得一样久。

消除生命周期

实际上,每个引用类型都有生命周期,只不过大多时候会被编译器简化:

fn first_word(s: &str) -> &str {
	let bytes = s.as_bytes();
	for (i, &item) in bytes.iter().enumerate() {
		if item == b' ' { // 前缀 b 表示 byte(u8) 字面量
			return &s[0..i];
		}
	}
	&s[..]
}

first_word() 的返回值类型为引用,该引用只会来源于参数或函数内部的变量。对于后者,会由于悬垂引用而无法过编,因此只能来源于前者,意味着返回值的生命周期与参数相同,编译器能看出来,因此简化了生命周期标注。

编译器尝试应用 3 条消除规则,如果应用完毕后仍无法确定所有变量的生命周期,则必须手动标注:

  • 对每个引用类型参数标注自己的生命周期:
fn foo(x: &i32) // before
fn foo<'a>(x: &'a i32) // after
 
fn boo(x: &i32, y: &i32) // before
fn boo<'a, 'b>(x: &'a i32, y: &'b i32) // after
  • 如果只有一个引用类型参数,所有返回值的生命周期与之相同:
fn foo(x: &i32) -> &i32 // before
fn foo<'a>(x: &'a i32) -> &'a i32 // after
  • 如果输入参数之一为 &self&mut self,也即这是一个方法,所有返回值的生命周期与之相同。

示例:

fn max(x: &str, y: &str) -> &str
// 应用第 1 条规则
fn max<'a, 'b>(x: &'a str, y: &'b str) -> &str

此时,后 2 条规则都无法应用,但是返回值的生命周期尚未确定,因此需要手动标注。

方法中的生命周期

为有生命周期的结构体实现方法的语法类似泛型语法:

struct Note<'a> {
    content: &'a str,
}
 
impl<'a> Note<'a> {
	fn warn(&self) -> i32 {
		3
	}
}

由于生命周期消除的第 1、3 条规则,方法签名往往无需生命周期标注,例如:

impl<'a> Note<'a> {
    fn warn_and_return(&self, warning: &str) -> &str {
        println!("Attention: {warning}");
        self.content
    }
}

编译器应用第 1 条规则:

impl<'a> Note<'a> {
    fn warn_and_return(&'a self, warning: &'b str) -> &str {
        println!("Attention: {warning}");
        self.content
    }
}

应用第 3 条:

impl<'a> Note<'a> {
    fn warn_and_return(&'a self, warning: &'b str) -> &'a str {
        println!("Attention: {warning}");
        self.content
    }
}

如果我们手动为返回值标注 'b

impl<'a> Note<'a> {
    fn warn_and_return(&'a self, warning: &'b str) -> &'b str {
        println!("Attention: {warning}");
        self.content
    }
}

无法过编,因为 &self 的生命周期为 'a,那么 self.content 也为 'a,但我们为其手动标注了 'b,编译器不知道 'a'b 的关系,此时可以手动声明 'b'a 短:

impl<'a: 'b, 'b> Note<'a> {
    fn warn_and_return(&'a self, warning: &'b str) -> &'b str {
        println!("Attention: {}", warning);
        self.content
    }
}

'a: 'b 是生命周期约束语法,类似泛型约束,意思是 'a'b 久。

静态生命周期

'static 是一个特殊的生命周期,拥有它的引用和整个程序活得一样久。

字符串字面量被硬编码到 Rust 二进制文件中,因此它们都具有 'static 生命周期。

'static 约束经常用以解决生命周期不过编的问题,但可能引入潜在 bug。应首先反思是否创建了一个悬垂引用,或者试图匹配不一致的生命周期,而不是无脑用 'static 解决问题。除非非常确定所有引用的生命周期都是正确的、只是编译器太过严苛。

👉 10 返回值和错误处理