Rust 系列(一)生命周期(基础理解)

lxf2023-12-23 06:00:02

在rust语言中,生命周期的概念和使用是重难点之一。让我们一步步来深入了解这个内容。

一、基本理解

我们先来看下面一段代码

fn main() {
    let x;
    {
        let y = 5;
        x = &y;
    } // #1
    println!("x: {}", x); // #2
}

编译后输出如下:

error[E0597]: `y` does not live long enough
 --> src/main.rs:6:13
  |
6 |         x = &y;
  |             ^^ borrowed value does not live long enough
7 |     }
  |     - `y` dropped here while still borrowed
8 |     println!("x: {}", x);
  |                       - borrow later used here

从编译错误输出可以看出,变量y的作用域在#1处结束,变量x拥有一个更大的作用域,在#2处结束,但是x引用了y,导致当y的作用域结束时,x指向的y内存空间被释放,属于无效引用。这个例子其实是一个典型的悬垂引用。rust中的生命周期标注就是要避免这种悬垂引用产生的内存安全问题。

我们来看下面这个具体的生命周期标注的例子是如何避免悬垂引用的。

fn main() {
    let x = String::from("xxxx");
    let result;
    {
        let y = String::from("xx");
        result = longest(x.as_str(), y.as_str());
    }
    println!("{}", result);
}

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

首先,直观理解我们可以看出,result最终应该引用的是变量x,而不是y,所以不应该出现悬垂引用问题。但是站在函数longest(x: &str, y: &str)调用方的角度来看,并不能保证调用方在传给该函数参数后,计算结果总是保证变量x的长度更长,也就是说代码依然可能返回y。所以编译上述代码时,编译错误是关于longest(x: &str, y: &str)函数的,main方法中没有编译错误:

error[E0106]: missing lifetime specifier
  --> src/main.rs:11:33
   |
11 | 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
   |
11 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
   |           ++++     ++          ++          ++

上述编译错误表明,我们需要显式给longest(x: &str, y: &str)函数标注<'a>也就是生命周期标注。该标注表明:两个函数入参和返回值参数,至少都应该活得和a一样长;而a具体表明的生命周期应该是等于两个入参和返回值中生命周期最短的那个

请注意,生命周期标注并不会改变参数的实际生命周期,只是站在函数本身来说,函数的调用方应该保证函数入参和返回值应该具有的生命周期范围;如果不能保证那么编译器将拒绝该函数编译通过,继而无法执行。因此生命周期标注仅仅是为了告诉编译器,帮我检查该函数调用方是否符合函数要求的生命周期而已。

现在我们按照提示修改longest(x: &str, y: &str)函数如下,main函数不做修改(请注意,生命周期标注属于参数类型的一部分,所以函数名后需要有类似泛型的标注,来表明'a是在说明参数类型):

fn main() {
    let x = String::from("xxxx");
    let result;
    {
        let y = String::from("xx");
        result = longest(x.as_str(), y.as_str());
    } // #1
    println!("{}", result); // #2
}

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

再次编译后输出如下错误:

error[E0597]: `y` does not live long enough
 --> src/main.rs:6:30
  |
6 |         result = longest(&x, y.as_str());
  |                              ^^^^^^^^^^ borrowed value does not live long enough
7 |     }
  |     - `y` dropped here while still borrowed
8 |     println!("{}", result);
  |                    ------ borrow later used here

此时main函数会出现编译错误,提示变量y活得不够久,也就是作用域结束太早,出现了悬垂引用问题。

我们来分析一下:首先longest<'a>(x: &'a str, y: &'a str) -> &'a str函数签名表明所有入参和返回值的生命周期应该是三者中最小的那个。此时,函数调用方中x变量在#2处被释放,y#1处被释放,所以'a表示的生命周期应该是y变量的生命周期;同时,因为返回值的生命周期标注也是'a,所以返回值的生命周期也应该是y变量的生命周期。但是从代码可以看出,显然result作为函数的返回值,最终是在#2处被释放,需要的生命周期大于y变量的生命周期,所以最终编译器判定该函数调用方的参数和返回值不满足函数体所标注的生命周期,编译失败。

如果我们将main函数稍作修改如下:

fn main() {
    let x = String::from("xxxx"); // #1
    let result;
    {
        let y = String::from("xx");
        result = longest(x.as_str(), y.as_str());
        println!("{}", result);
    } // #2
}

此时函数入参xy以及返回值result皆在#2处被释放,实际生命周期跟函数标注所需要的生命周期相同,因此编译通过。

有一个问题需要注意,我们讨论的所谓“生命周期”,指的是变量被释放的位置而非变量在整个程序中实际存活的时间。例如上述代码中,变量x是在#1处被定义,存活时间明显比变量yresult要长,但是他们同时在#2处被释放,因此满足longest函数的生命周期标注。 另外,我们在讨论生命周期的问题时,关注的是与所有权转移相关的情况,而如果单纯的是引用拷贝,不涉及所有权转移的情况下,生命周期问题不会影响编译过程。例如修改代码如下:

fn main() {
    let x = "xxx";
    let result;
    {
        let y = "xx";
        result = longest(x, y);
    }
    println!("{}", result);
}

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

此时xy都是字符串切片类型&str,变量result最终只是获取到了xy字符串切片引用拷贝,不涉及所有权转移,因此上述生命周期的分析过程不再适用,也不会出现编译问题。

二、深入理解生命周期标注

如上文所述,函数的生命周期标注并不会影响参数和返回值的实际生命周期,只是为了让编译器知道调用者应该满足的条件,否则编译失败。

另外,返回值的生命周期来源只能有两种:

  • 函数入参的生命周期
  • 函数内部自建引用的生命周期

函数入参的生命周期比较好理解,由于函数生命周期标注能够将函数的入参和返回值关联起来,那么当返回值只与某一部分函数入参相关时,我们无需将所有函数入参都进行生命周期标注。例如如下代码:

fn main() {
    let x = String::from("xxx");
    let result;
    {
        let y = String::from("xx");
        result = longest(x.as_str(), y.as_str());
    }
    println!("{}", result);
}

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

由于函数longest<'a>(x: &'a str, y: &str)返回值仅与参数x有关,参数y无需进行生命周期标注。

而使用函数内部自建引用的生命周期进行返回值的生命周期标注,就会产生悬垂引用的问题:

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let t = String::from("xx");
    &t
}

编译错误如下:

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

三、结构体生命周期标注

与函数体的生命周期标注类似,结构体也需要对引用类型进行生命周期标注:

struct Foo<'a> {
    test: &'a str,
}

fn main() {
    let x = String::from("xxx");
    let foo = Foo { test: x.as_str() };
    println!("{}", foo.test);
}

该生命周期标注表明,引用类型test不能比结构体引用被释放得更早。如果我们将代码修改如下:

struct Foo<'a> {
    test: &'a str,
}

fn main() {
    let foo;
    {
        let x = String::from("xxx");
        foo = Foo { test: x.as_str() };
    } // #1
    println!("{}", foo.test); // #2
}

变量x#1处被释放,而结构体引用foo#2处被释放,x存活的时间比结构体引用要短,所以编译报错

error[E0597]: `x` does not live long enough
  --> src/main.rs:9:27
   |
9  |         foo = Foo { test: x.as_str() };
   |                           ^^^^^^^^^^ borrowed value does not live long enough
10 |     } // #1
   |     - `x` dropped here while still borrowed
11 |     println!("{}", foo.test); // #2
   |                    -------- borrow later used here

四、生命周期标注消除规则

rust规定每一个引用类型的参数或返回值都必须具有生命周期。但是日常在代码编写过程中,我们并不一定需要显式标注。rust为我们提供了三条消除规则,以便在某些情况下避免手动标注生命周期(生命周期还是有的,只是无需开发者标注而已)。其中第一条是针对输入参数,第二条和第三条针对输出参数。

(一)每一个引用参数都具有各自的生命周期(针对输入参数)

例如函数fn foo(x: &'a str)只存在一个输入参数,编译器会自动给其标注'a生命周期;当函数存在多个输入参数,它们各自也会被编译器标注各自的生命周期:fn foo(x: &'a str, y: &'b str)

(二)如果函数只存在一个输入参数,那么该输入参数的生命周期将自动被赋给所有输出参数

例如函数fn foo(x: &'a str) -> &'a str {...}只存在一个输入参数x,则其生命周期'a将被自动赋给输出参数也即返回值。

(三)如果函数存在多个输入参数,但是包含了&self或者&mut self,那么该self引用的生命周期将被赋给所有输出参数

存在&self&mut self输入参数的函数称为方法(method)。例如方法fn new(x: &'a self, y: &i32) -> &'a i32 {...},输出参数具有输入参数&self相同的生命周期'a。此处需要再次强调,这是编译器默认标注的生命周期,假如方法实际的输出参数生命周期跟&self并不相同,则需要进行显式标注。具体细节在下一节阐述。

五、方法的生命周期标注

由于生命周期属于参数类型的一部分,所以方法的生命周期标注类似于泛型的使用。在泛型中,我们可以做如下定义:

struct Foo<T> {
    x: T,
}

impl<T> Foo<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

类似地,当结构体包含了引用类型的参数时,为其实现方法的语法也应该注意生命周期的标注。

struct Foo<'a> {
    x: &'a str,
}

impl<'a> Foo<'a> {
    fn x(&self, y: &str) -> &str {
        println!("{}", y);
        &self.x
    }
}

但是注意,具体的方法在默认情况下,不需要进行额外的生命周期标注也能通过编译器编译,这是由于上一节中提到的生命周期标注消除规则的第一条和第三条,编译器为方法自动标注了生命周期。具体过程如下:首先根据第一条规则,编译器会给方法的每个输入参数都各自标记生命周期'a'b;然后根据第三条规则,为输出参数标记输入参数&self的生命周期'a

impl<'a> Foo<'a> {
    fn x<'b>(&'a self, y: &'b str) -> &'a str {
        println!("{}", y);
        &self.x
    }
}

由于上述标注是编译器在编译时自动进行的,我们实际代码就可以简化如下,方法签名中不包含任何生命周期标注:

impl<'a> Foo<'a> {
    fn x(&self, y: &str) -> &str {
        println!("{}", y);
        &self.x
    }
}

但是假如我们实际代码要求返回值的生命周期不为'a,而是'b,也就是说现在如果我们显式给输入参数和输出参数标注生命周期,且与编译器默认的标注不同时:

impl<'a> Foo<'a> {
    fn x<'b>(&'a self, y: &'b str) -> &'b str {
        println!("{}", y);
        &self.x
    }
}

会得到如下编译错误:

error: lifetime may not live long enough
 --> src/main.rs:8:9
  |
5 | impl<'a> Foo<'a> {
  |      -- lifetime `'a` defined here
6 |     fn x<'b>(&'a self, y: &'b str) -> &'b str {
  |          -- lifetime `'b` defined here
7 |         println!("{}", y);
8 |         &self.x
  |         ^^^^^^^ method was supposed to return data with lifetime `'b` but it is returning data with lifetime `'a`
  |
  = help: consider adding the following bound: `'a: 'b`

由于&self的生命周期为'a,那么&self.x的生命周期也为'a,所以当&self.x作为方法返回值时,返回值的生命周期也应该为'a,但是此时我们给方法返回值标注的生命周期为'b。假如'b生命周期比'a长,也就是说返回给方法调用者的&self.x活得比&self更久。而当&self引用被释放后,原本&self.x也应该不存在,可是现在实际上的引用&self.x的生命周期'b依然存活,因为我们假定的是'b长于'a,所以这种假定就是不合理的。因此我们需要假定并告知编译器'a长于'b。这里有两种写法:

impl<'a: 'b, 'b> Foo<'a> {
    fn x(&'a self, y: &'b str) -> &'b str {
        println!("{}", y);
        &self.x
    }
}

其中'a: 'b表明生命周期'a长于'b;另一种写法是:

impl<'a, 'b> Foo<'a>
where
    'a: 'b,
{
    fn x(&'a self, y: &'b str) -> &'b str {
        println!("{}", y);
        &self.x
    }
}

六、泛型 + 生命周期标注

最后我们来看一个相对综合的例子,结合了泛型和生命周期的标注语法:

fn longest<'a, T>(x: &'a str, y: &'a str, z: T) -> &'a str
where
    T: Display,
{
    println!("{}", z);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!