Rust - 生命周期笔记

242 阅读6分钟

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

生命周期(lifetime)

  • 生命周期的主要目的是:避免悬垂引用。
  • 规则: 任何引用本身都不能比它所引用的对象存活地更久。
  • Rust 的每个引用都有自己的生命周期,使得引用保持有效的作用域。
  • 大多数情况:生命周期是隐式的、可被推断的。
  • 当引用的生命周期可能以不同的方式互相关联时:就要手动标注生命周期。

实现手段

  • Rust 编译器有一个 借用检查器borrow checker),它比较作用域来判断所有的借用是否有效。
  • 使用泛型生命周期参数来定义引用间的关系,以便借用检查器可以进行分析。

编译器通过借用检查器分析相关变量的生命周期来实现这一点。如果引用的生命周期小于被引用的 生命周期,编译成功,否则编译失败。

生命周期的标注语法

  • 生命周期参数命:
    • '开头
    • 通常全小写且非常短
    • 很多人使用'a
  • 生命周期参数标注位置:
    • 在引用的&符号后
    • 使用空格将标注和引用类型分开
  • 在引用的情况下才会出现生命周期标注
  • 最重要的是,生命周期标注会传播借用状态!它是描述多个引用生命周期之间的相互关系,不会改变引用的生命周期长度,也因此单个的生命周期标注没有多少意义。
  • 当指定了泛型生命周期参数,函数可以接受带有任何生命周期的引用。
  • 函数签名中生命周期参数的标注和约定:

    • 在函数签名中泛型生命周期参数声明在:函数名和参数列表间的 <> 中。
    • 泛型生命周期 'a 的具体生命周期是参数列表中 xy 的所传入的引用生命周期中较小的那一个。
    • 当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。
    • 如果返回的引用没有指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值,它将会是一个悬垂引用,因为它将会在函数结束时离开作用域。
    函数的生命周期语法是用于将函数的多个参数与其返回值的生命周期进行关联的。一旦他们形成了某种关联,Rust 就有了足够的信息来允许内存安全的操作,并阻止会产生悬垂指针亦或是违反内存安全的行为。
  • 结构体定义中的生命周期参数的标注:

    • 定义结构体字段的数据类型时:有自持有所有权的数据类型,也可以有不持有所有权的引用类型。技术上,结构体可能不持有任何数据。
    • 在定义结构体时泛型生命周期参数声明在:结构体名称后面的 <> 中。
  • 方法定义中的生命周期参数的标注

    • 在 struct 上使用生命周期实现方法,语法和泛型参数的语法一样
    • 在哪声明和使用生命周期参数,依赖于:
      • 生命周期参数是否和字段、方法的参数或返回值有关
    • struct 字段的生命周期名:
      • 在 impl 后声明
      • 在struct 名后使用
      • 这些生命周期是struct类型的一部分
    • impl 块内的方法签名中:
      • 引用必须绑定于 struct 字段引用的生命周期,或者引用是独立的也可以
      • 生命周期省略规则经常使得方法中的生命周期标注不是必须的
  • 静态生命周期

    • 使用 'static 来标注。
  • 在同一函数中指定泛型类型参数、trait bounds 和生命周期的语法

    • 生命周期参数 'a 和泛型类型参数 T 都位于函数名后的同一<>列表中。

生命周期省略(Lifetime Elision)

  • 定义和约定:

    • 在Rust 引用分析中所编入的模式被称为 生命周期省略规则lifetime elision rules)。
    • 这些规则无需开发者来遵守。
    • 它们是一系列特定的场景,由编译器来考虑。
    • 如果代码符合这些场景,就无需显示标注生命周期。
  • 两个概念:

    • 在函数或方法的参数中称为:输入生命周期。
    • 在函数和方法的返回值中称为:输出生命周期。

规则:

  1. 第一条规则是每一个是引用的参数都有它自己的生命周期参数。换句话说就是,有一个引用参数的函数有一个生命周期参数,有两个引用参数的函数就有两个生命周期参数,依此类推。
  2. 第二条规则是如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数。
  3. 第三条规则是当有多个传入参数引用时,但第一个参数是:&self/&mut self说明是个对象的方法(method),在这种情况下,输入参数的生命周期注释也被赋值给所有输出引用。例如: fn some_method(&self) -> &str 等价于 fn some_method<'a>(&'a self) -> &'a str 第三条规则使得方法更容易读写,因为只需更少的符号。 编译器使用 3 个规则在没有显式标注生命周期的情况下,来确定引用的生命周期:
  • 规则 1 应用于输入生命周期。
  • 规则 2、3 应用于输出生命周期。
  • 如果编译器应用完 3 个规则之后,仍然有无法确定生命周期的引用 → 报错。
  • 这些规则适用于 fn 定义和 impl 块。

被省略的不是生命周期,而是生命周期的标注和扩展的生命周期约束。

  • 在 Rust 编译器的早期版本中,不允许省略,并且需要对每个生命周期进行标注。
  • 但随着时间的推移,编译器团队观察到生命周期注释的相同模式被重复,因此修改编译器规则,从而推断它们。
  • 生命周期标注省略减少了代码中的混乱,未来编译器可能会推断出更多模式的生命周期约束。

理清一些困惑

生命周期如此令人困惑的部分原因是在 Rust 的大部分文章中,生命周期这个词被松散地用于指代三种不同的东西:

  • 变量的实际生命周期 -> 每一个初始化了的变量,都有它自己的生命周期,即变量的作用域。例如:
{
    let x: Vec<i32> = Vec::new();//---------------------+
    {//                                                 |
        let y = String::from("Why");//---+              | x的生命周期
        //                               | y的生命周期   |
    }// <--------------------------------+              |
}// <---------------------------------------------------+
  • 生命周期的约束 -> 变量的交互模式而产生的约束,换句话说就是变量之间的相互联系。代码中变量之间的交互模式,对它们的生命周期产生了一些限制。例如,在下面的代码中:x = &y; 这一行就添加了一个约束,即:x的生命周期应该包含在y的生命周期内 ( x ≤ y):
//error:`y` does not live long enough
{
    let x: &Vec<i32>;
    {
        let y = Vec::new();//----+
//                               | y的生命周期
//                               |
        x = &y;//----------------|--------------+
//                               |              |
    }// <------------------------+              | x的生命周期
    println!("x's length is:{}", x.len());//    |
}// <-------------------------------------------+

在此例中,如果没有添加这个约束,println! 代码块中访问的x是一个未初始化的变量,编译不能通过。而添加这个约束,println! 代码块中x是对y的引用,y的寿命不够长,访问的x是一个无效的内存,因为它在前一行就被销毁了,编译不能通过。

如果去掉里面一层的花括号,则编译通过,输出x's length is: 0,x的值是一个对y的引用,而y的引用指向一个值是空的动态数组,因此长度为0

需要注意的是:约束不会改变实际的生存期 —— 例如,x 的生命周期 实际上仍然会扩展到外部块的末尾 ——生命周期标注只是编译器用来禁止悬空引用的工具。在上面的例子中,实际的生命周期不符合规则约束,x 的生命周期已经超出了 y 的生命周期。因此,这段代码无法编译。

  • 生命周期标注 -> 标注符 'a是一个通用的泛型生命周期参数,使得变量可以在不同的方式相互关联时指定特定的作用域。很多时候编译器会(自动)生成所有的生命周期约束。但是随着代码变得越来越复杂,编译器会要求开发者手动添加约束。程序员通过生命周期注释来实现这一点。例如,在下面的代码中,编译器需要知道 print_ret()函数返回的引用是借用了 s1 还是 s2,所以编译器要求程序员显式地添加这个约束:
//error:missing lifetime specifier
//this function's return type contains a borrowed value,
//but the signature does not say whether it is borrowed from `s1` or `s2`
fn print_ret(s1: &str, s2: &str) -> &str {
    println!("s1 is {}", s1);
    s2
}
fn main() {
    let some_str: String = "Some string".to_string();
    let other_str: String = "Other string".to_string();
    let s1 = print_ret(&some_str, &other_str);
}

📒: 思考为什么编译器不能看到输出引用是从 s2 中借来的。

然后,开发者用 'a 标记 s2 和返回的引用,用来告诉编译器,返回值是从 s2 中借来的。

fn print_ret<'a>(s1: &str, s2: &'a str) -> &'a str {
    println!("s1 is {}", s1);
    s2
}
fn main() {
    let some_str: String = "Some string".to_string();
    let other_str: String = "Other string".to_string();
    let s1 = print_ret(&some_str, &other_str);
}

不过要强调的是,仅仅因为 'a 标记在参数 s2 和返回的引用上,并不意味着 s2 和返回的引用都有完全相同的生命周期。相反,这应该被理解为:带有 'a 标记的返回引用是从具有相同标记的参数中借用来的。 由于 s2 进一步借用了 other_str,生命周期约束是返回的引用不能超过 other_str 的 生命周期。这里满足约束,编译成功:

fn print_ret<'a>(s1: &str, s2: &'a str) -> &'a str {
    println!("s1 is {}", s1);
    s2
}
fn main() {
    let some_str: String = "Some string".to_string();
    let other_str: String = "Other string".to_string();//-------------+
    let ret = print_ret(&some_str, &other_str);//---+                 | other_str的生命周期
    //                                              | ret的生命周期   |
}// <-----------------------------------------------+-----------------+

总结

记住,通过用 'a 标记引用,程序员只是在构造一些约束;然后,编译器的工作就是为 'a 找到满足其约束的具体生命周期。 变量的生命周期必须满足编译器和开发者对它们施加的某些约束,然后编译器才能确保代码是合理的。如果没有生命周期这种机制,编译器将无法保证大多数 Rust 程序的安全性。

参考文章:hashrust.com/blog/lifeti…