rust[5] - 生命周期

2,681 阅读9分钟

概念

生命周期,简而言之就是引用的有效作用域。通常无需手动的声明生命周期,编译器可以自动进行推导,用类型来类比下:

  • 就像编译器大部分时候可以自动推导类型 <-> 一样,编译器大多数时候也可以自动推导生命周期。
  • 在多种类型存在时,编译器往往要求我们手动标明类型 <-> 当多个生命周期存在,且编译器无法推导出某个引用的生命周期时,就需要我们手动标明生命周期。

引入案例

但是当我们在函数之间传递引用的时候,编译器就很难自动识别出这些问题了,所以Rust要求我们为这些引用显式的指定生命周期标记,如果你不指定生命周期标记,那么编译器将会“鞭策”你。

因此一定切记切记切记:生命周期一定是和引用挂钩的!

struct Foo {
    x: &i32,
}

fn main() {
    let y = &5; 
    let f = Foo { x: y };

    println!("{}", f.x);
}

上面这段代码,编译器会提示你:missing lifetime specifier。这是因为,y这个借用被传递到了 let f = Foo { x: y }所在作用域中。所以需要确保借用y活得比Foo结构体实例长才行,否则,如果借用y被提前释放,Foo结构体实例就会造成悬垂指针了。所以我们需要为其增加生命周期标记。

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

fn main() {
    let y = &5; 
    let f = Foo { x: y };

    println!("{}", f.x);
}

加上生命周期标记以后,编译器中的借用检查器就会帮助我们自动比对参数变量的作用域长度,从而确保内存安全。

再来看一个例子:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
fn main() {
    let a = "hello";
    let result;
    {
        let b = String::from("world");
        result = longest(a, b.as_str());
    }
    println!("The longest string is {}", result);
}

此段代码,编译器会报错:b does not live long enough。这是因为result在外部作用域定义的,result的生命周期是和main函数一样长的,也就是说,在main函数作用域结束之前,result都必须存活。而此时,变量b在花括号定义的作用域中,出了作用域b就会被释放。而根据longest函数签名中的生命周期标注,参数b的生命周期必须和返回值的生命周期一致,所以,借用检查器果断的判断出b does not live long enough。

“显式的指定”,这是Rust的设计哲学之一。这对于新手,尤其是习惯了动态语言的人来说,可能是一个心智负担。显式的指定方便了编译器,但是对于程序员来说略显繁琐。不过为了安全考虑,我们就欣然接受这套规则吧。

Rust 生命周期机制是与所有权机制同等重要的资源管理机制。之所以引入这个概念主要是应对复杂类型系统中资源管理的问题。

  • rust 的每个引用都有自己的生命周期。
  • 生命周期:引用保持有效地作用域。
  • 多数情况:生命周期是隐式的,可以被推断的。
  • 当引用的生命周期可能以不同的方式互相关联的时候,手动标注生命周期。

生命周期的目的:防止悬垂引用

借用检查器(Borrow checker)

#![allow(unused)]
fn main() {
{
    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 且不会存在危险:

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

悬垂引用

引用是对待复杂类型时必不可少的机制,毕竟复杂类型的数据不能被处理器轻易地复制和计算。但引用往往导致极其复杂的资源管理问题,首先认识一下垂悬引用

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

这段代码无法通过编译器的编译,原因是r的引用值提前已经释放掉了。

fn longer(s1: &str, s2: &str-> &str {  
    if s2.len() > s1.len() {
        s2
    } else {
        s1
    }
}

longer 函数取 s1s2 两个字符串切片中较长的一个返回其引用值。但只这段代码不会通过编译,原因是返回值引用可能会返回过期的引用。编译器无法确切的知道该函数的返回值到底引用 x 还是 y ,因为编译器需要知道这些,来确保函数调用后的引用生命周期分析

生命周期标注语法

标记的生命周期只是为了绕过编译器的检测!

  • 生命周期的标注不会改变引用的生命周期长度
  • 当指定了泛型生命周期参数,函数可以接受带有任何生命周期的引用。
  • 生命周期的标注:描述了多个引用的生命周期的关系,但是不影响生命周期。
&i32        // 常规引用
&'a i32     // 含有生命周期注释的引用
&'a mut i32 // 可变型含有生命周期注释的引用

使用生命周期修改上面的函数执行:


// 泛型生命周期声明在最前面 <'a>里面:表示参数和返回值都必须有相同的生命周期’a
// 这俩参数的存活时间必须不能短于‘a
// 返回值字符串切片存活时间也必须不能少于'a
fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s2.len() > s1.len() {
        s2
    } else {
        s1
    }
}

fn main (){
    let s1 = "rust";
    let s2 = "javascript";
    let r = longer(s1, s2);

    println!("r={}", r); // javascript
}

'axy中最小的一个。

  • 指定生命周期参数的方式依赖于函数所做的事情。
  • 函数返回引用时,返回类型的生命周期需要与其中一个的匹配。 生命周期的意义: 这两个参数 s1 和 s2 至少活得和'a 一样久,至于到底活多久或者哪个活得更久,抱歉我们都无法得知

那遇到这种情况该怎么办?最好的办法就是返回内部字符串的所有权,然后把字符串的所有权转移给调用者:

// 把所有权移交给了函数的调用者,这块内存要是想清理的话,就需要调用者来清理
fn longer_str <'a> (x: &'a str, y: &'a str)-> String {
    let str = String::from("anikin");
    str
}

生命周期的省略规则

实际上,对于编译器来说,每一个引用类型都有一个生命周期,那么为什么我们在使用过程中,很多时候无需标注生命周期?例如:

被编码进 Rust 引用分析的模式被称为 生命周期省略规则lifetime elision rules)。这并不是需要程序员遵守的规则;

函数或方法的参数的生命周期被称为 输入生命周期input lifetimes),而返回值的生命周期被称为 输出生命周期output lifetimes)。编译器采用三条规则来判断引用何时不需要明确的注解。第一条规则适用于输入生命周期,后两条规则适用于输出生命周期。

省略的具体三个规则:

  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说明是个对象的方法(method), 那么所有输出生命周期参数被赋予 self 的生命周期。第三条规则使得方法更容易读写,因为只需更少的符号。

结构体中生命周期

不仅仅函数具有生命周期,结构体其实也有这个概念。以下这个案例中标记的意义: 结构体的生命周期和字符串切片引用必须符合相同的生命周期,也就是引用不能小于结构体的存活时间

struct Str<'a> {
 content: &'a str
}

impl<'a> Str<'a> {
    // 方法生命周期标注
    // 和返回值有关
    fn get_content(&self-> &str {
        self.content
    }
}

fn main() {
    let s = Str {
        content: "string_slice"
    };
    println!("s.content = {}", s.content);
}

静态生命周期

字符串字面值是被直接存贮的二进制文件程序里面,所以它总是可用的,因此其生命周期都是'static。 生命周期注释有一个特别的:'static

所有用双引号包括的字符串常量所代表的精确数据类型都是 &'static str'static 所表示的生命周期从程序运行开始到程序运行结束

&'static 对于生命周期有着非常强的要求:一个引用必须要活得跟剩下的程序一样久,才能被标注为 &'static

对于字符串字面量来说,它直接被打包到二进制文件中,永远不会被 drop,因此它能跟程序活得一样久,自然它的生命周期是 'static

但是, &'static 生命周期针对的仅仅是引用,而不是持有该引用的变量,对于变量来说,还是要遵循相应的作用域规则 。

泛型,生命周期,trait bound 联合使用

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