进阶篇-生命周期

  • 木头
  • 更新于 2023-03-10 15:43
  • 阅读 1514

生命周期的目标就是为了防止出现悬垂引用

Rust中的每一个引用都有其 生命周期(lifetime),也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的,正如大部分时候类型也是可以推断的一样。类似于当因为有多种可能类型的时候必须注明类型,也会出现引用的生命周期以一些不同方式相关联的情况,所以 Rust需要我们使用泛型生命周期参数来注明他们的关系,这样就能确保运行时实际使用的引用绝对是有效的。

生命周期避免了悬垂引用

生命周期的主要目标是避免悬垂引用(dangling references),后者会导致程序引用了非预期引用的数据:

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

外部作用域声明了一个没有初值的变量 r,而内部作用域声明了一个初值为 5 的变量x。在内部作用域中,我们尝试将 r的值设置为一个 x 的引用。接着在内部作用域结束后,尝试打印出r的值。这段代码不能编译因为 r 引用的值在尝试使用之前就离开了作用域。如下是错误信息:

$ cargo run
   Compiling life_cycle v0.1.0 (/rsut/life_cycle)
error[E0597]: `x` does not live long enough
 --> src/main.rs:5:13
  |
5 |         r = &x;
  |             ^^ borrowed value does not live long enough
6 |     }
  |     - `x` dropped here while still borrowed
7 |     println!("{}", r);
  |                    - borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `life_cycle` due to previous error

变量 x并没有 “存在的足够久”。其原因是 x 在到达第 6 行内部作用域结束时就离开了作用域。不过r 在外部作用域仍是有效的;作用域越大我们就说它 “存在的越久”。如果 Rust 允许这段代码工作,r 将会引用在x离开作用域时被释放的内存,这时尝试对r做任何操作都不能正常工作。

借用检查器

Rust 编译器有一个 借用检查器(borrow checker),它比较作用域来确保所有的借用都是有效的:

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

这里将r的生命周期标记为 'a 并将x的生命周期标记为'b。内部的 'b块要比外部的生命周期 'a小得多。在编译时,Rust 比较这两个生命周期的大小,并发现r拥有生命周期'a,不过它引用了一个拥有生命周期 'b的对象。程序被拒绝编译,因为生命周期 'b 比生命周期 'a要小:被引用的对象比它的引用者存在的时间更短

没有产生悬垂引用例子:

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

这里 x 拥有生命周期'b,比 'a要大。这就意味着 r 可以引用 xRust 知道 r中的引用在 x 有效的时候也总是有效的。

函数中的泛型生命周期

让我们来编写一个返回两个字符串 slice 中较长者的函数。这个函数获取两个字符串slice并返回一个字符串 slice

fn main() {
    let s1 = String::from("abcde");
    let s2 = String::from("ab");

    let r = longest(s1.as_str(), s2.as_str());
    println!("{}", r);
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

它并不能编译,相应地会出现如下有关生命周期的错误:

$ cargo run
   Compiling life_cycle v0.1.0 (/rsut/life_cycle)
error[E0106]: missing lifetime specifier
 --> src/main.rs:8:33
  |
8 | 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`
help: consider introducing a named lifetime parameter
  |
8 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `life_cycle` due to previous error

提示文本揭示了返回值需要一个泛型生命周期参数,因为Rust并不知道将要返回的引用是指向xy。事实上我们也不知道,因为函数体中if块返回一个x的引用而 else 块返回一个 y的引用!

当我们定义这个函数的时候,并不知道传递给函数的具体值,所以也不知道到底是 if还是 else会被执行。我们也不知道传入的引用的具体生命周期。借用检查器自身同样也无法确定,因为它不知道 xy 的生命周期是如何与返回值的生命周期相关联的。为了修复这个错误,我们将增加泛型生命周期参数来定义引用间的关系以便借用检查器可以进行分析。

生命周期注解语法

生命周期注解并不改变任何引用的生命周期的长短。相反它们描述了多个引用生命周期相互的关系,而不影响其生命周期。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。

生命周期注解有着一个不太常见的语法:生命周期参数名称必须以撇号(')开头,其名称通常全是小写,类似于泛型其名称非常短。大多数人使用 'a 作为第一个生命周期注解。生命周期参数注解位于引用的 &之后,并有一个空格来将引用类型与生命周期注解分隔开。

我们有一个没有生命周期参数的 i32 的引用,一个有叫做 'a 的生命周期参数的i32的引用,和一个生命周期也是 'ai32 的可变引用:

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

单个的生命周期注解本身没有多少意义,因为生命周期注解告诉 Rust 多个引用的泛型生命周期参数如何相互联系的。让我们在longest 函数的上下文中理解生命周期注解如何相互联系。

例如如果函数有一个生命周期 'ai32 的引用的参数first。还有另一个同样是生命周期 'ai32 的引用的参数 second。这两个生命周期注解意味着引用 firstsecond 必须与这泛型生命周期存在得一样久。

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

为了在函数签名中使用生命周期注解,需要在函数名和参数列表间的尖括号中声明泛型生命周期(lifetime)参数,就像泛型类型(type)参数一样。

我们希望函数签名表达如下限制:也就是这两个参数和返回的引用存活的一样久。(两个)参数和返回的引用的生命周期是相关的:

fn main() {
    let s1 = String::from("abcde");
    let s2 = String::from("ab");
    let r = longest(s1.as_str(), s2.as_str());
    println!("{}", r);
}

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

现在函数签名表明对于某些生命周期 'a,函数会获取两个参数,他们都是与生命周期 'a 存在的一样长的字符串 slice。函数会返回一个同样也与生命周期 'a 存在的一样长的字符串 slice。它的实际含义是longest函数返回的引用的生命周期与函数参数所引用的值的生命周期的较小者一致。这些关系就是我们希望Rust 分析代码时所使用的。

记住通过在函数签名中指定生命周期参数时,我们并没有改变任何传入值或返回值的生命周期,而是指出任何不满足这个约束条件的值都将被借用检查器拒绝。注意 longest 函数并不需要知道 xy具体会存在多久,而只需要知道有某个可以被'a替代的作用域将会满足这个签名。

当具体的引用被传递给 longest时,被 'a 所替代的具体生命周期是 x的作用域与y 的作用域相重叠的那一部分。换一种说法就是泛型生命周期 'a的具体生命周期等同于xy的生命周期中较小的那一个。因为我们用相同的生命周期参数 'a 标注了返回的引用值,所以返回的引用值就能保证在xy中较短的那个生命周期结束之前保持有效。

让我们看看如何通过传递拥有不同具体生命周期的引用来限制 longest函数的使用:

fn main() {
    let s1 = String::from("abcde");
    {
        let s2 = String::from("ab");
        let r = longest(s1.as_str(), s2.as_str());
        println!("{}", r);
    }
}

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

在这个例子中,s1 直到外部作用域结束都是有效的,s2 则在内部作用域中是有效的,而 r 则引用了一些直到内部作用域结束都是有效的值。借用检查器认可这些代码;它能够编译和运行,并打印出abcde

接下来,让我们尝试另外一个例子,该例子揭示了 r 的引用的生命周期必须是两个参数中较短的那个:

fn main() {
    let s1 = String::from("abcde");
    let r;
    {
        let s2 = String::from("ab");
        r = longest(s1.as_str(), s2.as_str());
    }
    println!("{}", r);
}

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

如果尝试编译会出现如下错误:

$ cargo run
   Compiling life_cycle v0.1.0 (/rsut/life_cycle)
error[E0597]: `s2` does not live long enough
 --> src/main.rs:6:34
  |
6 |         r = longest(s1.as_str(), s2.as_str());
  |                                  ^^^^^^^^^^^ borrowed value does not live long enough
7 |     }
  |     - `s2` dropped here while still borrowed
8 |     println!("{}", r);
  |                    - borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `life_cycle` due to previous error

错误表明为了保证 println! 中的 r 是有效的,s2 需要直到外部作用域结束都是有效的。Rust 知道这些是因为(longest)函数的参数和返回值都使用了相同的生命周期参数 'a

如果从人的角度读上述代码,我们可能会觉得这个代码是正确的。 s1 更长,因此 r 会包含指向 s1 的引用。因为 s1 尚未离开作用域,对于 println! 来说 s1 的引用仍然是有效的。然而,我们通过生命周期参数告诉 Rust的是: longest 函数返回的引用的生命周期应该与传入参数的生命周期中较短那个保持一致。因此,借用检查器不允许这样的代码,因为它可能会存在无效的引用。

深入理解生命周期

指定生命周期参数的正确方式依赖函数实现的具体功能。例如,如果将 longest函数的实现修改为总是返回第一个参数而不是最长的字符串 slice,就不需要为参数y指定一个生命周期。如下代码将能够编译:

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

我们为参数 x 和返回值指定了生命周期参数 'a,不过没有为参数y指定,因为 y 的生命周期与参数 x和返回值的生命周期没有任何关系。

当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。如果返回的引用 没有指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值。然而它将会是一个悬垂引用,因为它将会在函数结束时离开作用域:

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("hiohjo");
    result.as_str()
}

即便我们为返回值指定了生命周期参数'a,这个实现却编译失败了,因为返回值的生命周期与参数完全没有关联。这里是会出现的错误信息:

$ cargo run
   Compiling life_cycle v0.1.0 (/rsut/life_cycle)
warning: unused variable: `x`
  --> src/main.rs:11:16
   |
11 | fn longest<'a>(x: &str, y: &str) -> &'a str {
   |                ^ help: if this is intentional, prefix it with an underscore: `_x`
   |
   = note: `#[warn(unused_variables)]` on by default

warning: unused variable: `y`
  --> src/main.rs:11:25
   |
11 | fn longest<'a>(x: &str, y: &str) -> &'a str {
   |                         ^ help: if this is intentional, prefix it with an underscore: `_y`

error[E0515]: cannot return reference to local variable `result`
  --> src/main.rs:13:5
   |
13 |     result.as_str()
   |     ^^^^^^^^^^^^^^^ returns a reference to data owned by the current function

For more information about this error, try `rustc --explain E0515`.
warning: `life_cycle` (bin "life_cycle") generated 2 warnings
error: could not compile `life_cycle` due to previous error; 2 warnings emitted

出现的问题是 resultlongest 函数的结尾将离开作用域并被清理,而我们尝试从函数返回一个 result的引用。无法指定生命周期参数来改变悬垂引用,而且Rust 也不允许我们创建一个悬垂引用。在这种情况,最好的解决方案是返回一个有所有权的数据类型而不是一个引用,这样函数调用者就需要负责清理这个值了。

综上,生命周期语法是用于将函数的多个参数与其返回值的生命周期进行关联的。一旦他们形成了某种关联,Rust 就有了足够的信息来允许内存安全的操作并阻止会产生悬垂指针亦或是违反内存安全的行为。

结构体定义中的生命周期注解

目前为止,我们定义的结构体全都包含拥有所有权的类型。也可以定义包含引用的结构体,不过这需要为结构体定义中的每一个引用添加生命周期注解:

#[derive(Debug)]
struct A<'a> {
    name: &'a str,
}
fn main() {
    let s1 = String::from("hello");
    let a = A { name: &s1 };
    println!("{:#?}", a);
}

这个结构体有唯一一个字段name,它存放了一个字符串slice,这是一个引用。类似于泛型参数类型,必须在结构体名称后面的尖括号中声明泛型生命周期参数,以便在结构体定义中使用生命周期参数。这个注解意味着 A 的实例不能比其 name 字段中的引用存在的更久。

这里的main 函数创建了一个 A 的实例,它存放了变量 s1 所拥有的 String 的引用。s1 的数据在 A 实例创建之前就存在。另外,直到 A 离开作用域之后 s1 都不会离开作用域,所以A 实例中的引用是有效的。

生命周期省略(Lifetime Elision)

现在我们已经知道了每一个引用都有一个生命周期,而且我们需要为那些使用了引用的函数或结构体指定生命周期。为什么没有生命周期注解却能编译成功:

fn main() {
    let s1 = String::from("hello");
    let s = get_str(&s1);
    println!("{}", s);
}

fn get_str(s: &str) -> &str {
    s
}

这个函数没有生命周期注解却能编译是由于一些历史原因:在早期版本(pre-1.0)Rust 中,这的确是不能编译的。每一个引用都必须有明确的生命周期。那时的函数签名将会写成这样:

fn get_str<'a>(s: &'a str) -> &'a str {
    s
}

在早期的Rust中必须显式的声明生命周期,后来 Rust团队将很明确的模式进行了注解的简化。

在遵守生命周期省略规则的情况下能明确变量的声明周期,则无需明确指定生命周期。函数或者方法的参数的生命周期称为输入生命周期,而返回值的生命周期称为输出生命周期。

编译器采用三条规则来判断引用何时不需要明确的注解。如果编译器检查完这三条规则后仍然存在没有计算出生命周期的引用,编译器将会停止并生成错误。这些规则适用于fn 定义,以及 impl 块。

  1. 编译器为每一个是引用参数都分配了一个生命周期参数。换句话说就是,有一个引用参数的函数有一个生命周期参数:fn foo<'a>(x: &'a i32),有两个引用参数的函数有两个不同的生命周期参数,fn foo<'a, 'b>(x: &'a i32, y: &'b i32),依此类推。
  2. 如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数:fn foo<'a>(x: &'a i32) -> &'a i32
  3. 如果方法有多个输入生命周期参数并且其中一个参数是&self&mut self,那么self的生命周期被赋予所有输出生命周期参数。

几个例子:

fn print(s: &str);                                      // 省略的
fn print<'a>(s: &'a str);                               // 完整的

fn debug(lvl: usize, s: &str);                          // 省略的
fn debug<'a>(lvl: usize, s: &'a str);                   // 完整的

fn substr(s: &str, until: usize) -> &str;               // 省略的
fn substr<'a>(s: &'a str, until: usize) -> &'a str;     // 完整的

fn get_str() -> &str;                                   // 错误

fn frob(s: &str, t: &str) -> &str;                      // 错误

fn get_mut(&mut self) -> &mut T;                        // 省略的
fn get_mut<'a>(&'a mut self) -> &'a mut T;              // 完整的

fn args<T: ToCStr>(&mut self, args: &[T]) -> &mut Command                  // 省略的
fn args<'a, 'b, T: ToCStr>(&'a mut self, args: &'b [T]) -> &'a mut Command // 完整的

fn new(buf: &mut [u8]) -> BufWriter;                    // 省略的
fn new<'a>(buf: &'a mut [u8]) -> BufWriter<'a>          // 完整的

方法定义中的生命周期注解

(实现方法时)结构体字段的生命周期必须总是在impl 关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。

impl 块里的方法签名中,引用可能与结构体字段中的引用相关联,也可能是独立的。另外,生命周期省略规则也经常让我们无需在方法签名中使用生命周期注解:

struct A<'a> {
    name: &'a str,
}

impl<'a> A<'a> {
    fn get_n(&self) -> &str {
        self.name
    }
}
fn main() {
    let s1 = String::from("hello");
    let a = A { name: &s1 };
    println!("{:#?}", a.get_n());
}

impl之后和类型名称之后的生命周期参数是必要的,不过因为第一条生命周期规则我们并不必须标注self 引用的生命周期。

适用于第三条生命周期省略规则的例子:

struct A<'a> {
    name: &'a str,
}

impl<'a> A<'a> {
    fn get_n(&self, s2: &str) -> &str {
        println!("s2 : {}", s2);
        self.name
    }
}
fn main() {
    let s1 = String::from("hello");
    let s2 = String::from("hello");
    let a = A { name: &s1 };
    println!("{:#?}", a.get_n(s2.as_str()));
}

这里有两个输入生命周期,所以 Rust应用第一条生命周期省略规则并给予 &selfs2 他们各自的生命周期。接着,因为其中一个参数是 &self,返回值类型被赋予了 &self 的生命周期,这样所有的生命周期都被计算出来了。

静态生命周期

这里有一种特殊的生命周期值得讨论:'static,其生命周期能够存活于整个程序期间。所有的字符串字面值都拥有 'static生命周期,我们也可以选择像下面这样标注出来:

let s: &'static str = "hello";

这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。因此所有的字符串字面值都是 'static的。

结合泛型类型参数、特性和生命周期

让我们简要的看一下在同一函数中指定泛型类型参数、trait bounds生命周期的语法:

use std::fmt::Display;

fn main() {
    let s1 = String::from("abcde");
    let s2 = String::from("ab");
    let r = longest(s1.as_str(), s2.as_str(), "AAA");
    println!("{}", r);
}

fn longest<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
where
    T: Display,
{
    println!("ann :{}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

这个是上面返回两个字符串slice中较长者的 longest 函数,不过带有一个额外的参数 annann 的类型是泛型T,它可以被放入任何实现了where 从句中指定的 Display trait的类型。这个额外的参数会使用 {} 打印,这也就是为什么Display trait bound 是必须的。因为生命周期也是泛型,所以生命周期参数'a 和泛型类型参数T都位于函数名后的同一尖括号列表中。

  • 原创
  • 学分: 4
  • 分类: Rust
  • 标签:
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。
121 订阅 31 篇文章

0 条评论

请先 登录 后评论
木头
木头
0xC020...10cf
江湖只有他的大名,没有他的介绍。