深入了解Rust的生命周期

楔子

Rust 的每个引用都有自己的生命周期,生命周期指的是引用保持有效的作用域。大多数情况下,引用是隐式的、可以被推断出来的,但当引用可能以不同的方式互相关联时,则需要手动标注生命周期。

fn main() {
    let r;
    {
        let x = 5;
        r = &x;
    }  // 此处 r 不再有效
    println!("{}", r);
}

执行的时候会报出如下错误:borrowed value does not live long enough,意思就是借用的值存活的时间不够长。因为把 x 的引用给 r 之后,x 就被销毁了,那么 r 就成为了一个悬空引用。

而 Rust 会通过借用检查器,来检查借用是否合法,显然上述代码在执行打印语句的时候,r 已经不合法了。

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

这段代码也是不合法的,原因就是返回值要么是 x 要么是 y,但具体是哪一个不知道,并且它们的生命周期也都不知道。所以无法通过比较作用域,来判断返回的引用是否是一致有效的,而借用检查器也是做不到的,原因就是它不知道返回值的生命周期是跟 x 有关系还是跟 y 有关系。事实上,这个跟函数体的逻辑也没有关系,函数的声明就决定了它做不到这一点。

因此我们需要引入生命周期。

生命周期标注语法

首先生命周期标注并不会改变引用的生命长度,当指定了生命周期参数,函数可以接收带有任何生命周期的引用。生命周期的标注:描述了多个引用的生命周期间的关系,但不影响生命周期本身。

现在光读起来可能有点绕,别急,一会儿会解释。

生命周期参数名以 ' 开头,并且名字非常短,通常为 a;标注位置在 & 后面,只有 & 才需要生命周期。因为你引用了一个值,那么这个值的存活时间需要知道,不然人家都被销毁了还傻傻地用。

  • &i32:一个引用;
  • &'a i32:带有显式生命周期的引用;
  • &'a mut i32:带有显式生命周期的可变引用;

其实单个生命周期标注本身没有什么意义,它是为了向 Rust 描述多个具有生命周期的参数之间的关系。并且生命周期和泛型一样,也要声明在尖括号内。

// 签名里面的生命周期必须要有
// 相当于告诉 Rust 有这么一个生命周期 'a
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

此时代码是合法的,但是注意:我们并没有改变传入的值和返回的值的生命周期,我们只是向借用检查器指出了一些用于检查非法调用的一些约束而已,而借用检查器并不需要知道 x、y 的具体存活时长。

而事实上如果函数引用外部的变量,那么单靠 Rust 确定函数和返回值的生命周期几乎是不可能的事情。因为函数传递什么参数都是我们决定的,这样的话函数在每次调用时使用的生命周期都可能发生变化,正因如此我们才需要手动对生命周期进行标注。

// 准确来说 'a 指的就是 x 和 y 生命周期重叠的那一部分
// 而返回值的生命周期不能超重叠的部分
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let x = String::from("hello");
    {
        let y = String::from("satori");
        let result = longest(&x, &y);
        println!("result = {}", result);
        // result = satori
    }
}

目前是没有问题的,因为 x 和 y 的生命周期重叠的部分是 y,然后返回值 result 和 y 也是一样的。但如果我们把代码改一下,将 println! 语句移到花括号外面:

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

fn main() {
    let x = "hello".to_string();
    let result;
    {
        let y = "satori".to_string();
        result = longest(&x, &y);
    }
    println!("result = {}", result);  

此时就报错了:borrowed value does not live long enough。相信你已经猜到了,因为 x、y 生命周期重叠的部分是 y,返回值 result 的生命周期不能超过它。但当前明显超过了,所以报错。

所以说生命周期标注对变量没有什么影响,它只是给了借用检查器一个可以用来判断的约束罢了。

总结一下就是:生命周期用来关联函数参数和返回值之间的联系,一旦它们取得了某种联系,那么 Rust 就获得了足够多的信息来保证内存安全的操作,并且阻止那些出现悬空指针或者其它导致内存安全的行为。

到目前为止,你也许还不太了解生命周期,别着急,我们继续往下看。

结构体中的生命周期标注

struct 里面可以放任意类型,但是不能放引用,比如下面的结构体定义就是错误的。

struct Girl {
    name: &str,
    age: i32
}

结构体如果是合法的,那么它内部的所有成员值都要是合法的。但现在 name 是一个引用,所以结构体实例化的时候一定会引用某个字符串,这就使得字符串存活是结构体实例存活的前提。

但在实际编码中,这两者的存活时间没有什么关系,有可能你在使用结构体实例访问 name 成员的时候,它引用的字符串都已经被销毁了。所以 Rust 不允许我们这么做,我们之前是将 name 的类型指定为 String,也就是让结构体持有全部数据的所有权。

而如果非要将类型指定为引用的话,那么必须指定生命周期。

// 实例.name 会引用外部的一个字符串,所以要指定生命周期
// 表示字符串的存活时间一定比结构体实例要长
// 否则字符串没了,而实例还在,那么就会出现悬空引用
#[derive(Debug)]
struct Girl<'a> {
    name: &'a str,
    age: i32
}

fn main() {
    let g;
    {
        let name = String::from("古明地觉");
        g = Girl{name: &name, age: 16};
    }
    println!("{:?}", g);
}

因为指定了生命周期,在编译的时候借用检查器就可以检测出存活时间是否合法。首先 g 的存活时间是整个 main 函数,而 name 的存活时间是内部的花括号那一段作用域,比 g 的存活时间短,因此编译出错。

所以通过生命周期标注,Rust 在编译期间就能通过借用检查器检测出引用是否合法,Rust 不会将这种错误留到运行时。

生命周期的省略

当一个函数返回了一个引用时,往往需要指定生命周期,而它的目的就是为了保证返回的引用是合法的。如果不合法,在编译阶段就能找出来。

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

函数参数出现了引用,返回值也有引用,应该指定生命周期呀。是的,在早期版本这段代码是编译不过的,它需要你这么写:

fn f<'a>(s: &'a str) -> &'a str {
    "xxx"
}

但是久而久之,Rust 团队发现对于这种场景实在没有必要一遍又一遍的重复编写生命周期,并且这种只有一个参数完全是可以预测的,有明确的模式。于是 Rust 团队就将这些模式写入了借用检查器,可以自动进行推导,而无需显式地写上生命周期标注。

所以在 Rust 引用分析中编入的模式被称为生命周期省略规则:

  • 这些规则无需开发者来遵守;
  • 对于一些特殊情况,由编译器来考虑;
  • 如果你的代码符合这些规则,就无需显式标注生命周期;

如果生命周期在函数/方法的参数中,则被称为输入生命周期;在函数/方法的返回值中,则被称为输出生命周期。而 Rust 要能够在编译期间基于输入生命周期,来确定输出生命周期,如果能够确定,那么便是合法的。

而当我们省略生命周期时,Rust 就会基于内置的省略规则进行推断,如果推断完成后发现引用之间的关系还是模糊不清,就会出现编译错误。而解决办法就需要我们手动标注生命周期了,表明引用之间的相互关系。

那么 Rust 省略规则到底是怎样的呢?

  • 规则一:每个引用类型的参数都有独自的生命周期;
  • 规则二:如果只有一个参数具有生命周期,或者说只有一个输入生命周期,那么该生命周期会赋值给所有的输出生命周期;
  • 规则三:如果有多个输入生命周期,但其中一个是 &self 或 &mut self,那么 self 的生命周期会赋值给所有的输出生命周期;

如果编译器在应用完上述三个规则后,能够计算出返回值的生命周期,则可以省略,否则不能省略。这些规则同样适用于 fn 定义和 impl 块,我们来举几个例子,感受一下整个过程。

// 函数如下,然后开始应用三个规则
fn first_word(s: &str) -> &str{};

// 1. 每个引用类型的参数都有自己的生命周期,满足
//    所以函数相当于变成如下
fn first_word<'a>(s: &'a str) -> &str{};

// 2. 只有一个输入生命周期,该生命周期被赋给所有的输出生命周期
//    显然也是满足的,所以函数变成如下
fn first_word<'a>(s: &'a str) -> &'a str{};

// 3. 不满足,所以无事发生

应用完三个规则之后,计算出了返回值的生命周期,所以合法。

再举个例子:

// 函数如下,然后开始应用三个规则
fn first_word(s1: &str, s2: &str) -> &str{};

// 1. 每个引用类型的参数都有自己的生命周期
//    显然满足,所以函数变成如下
fn first_word<'a, 'b>(s1: &'a str, s2: &'b str) -> &str{};

// 2. 只有一个输入生命周期,该生命周期被赋予所有的输出生命周期
// 但是这里有两个,所以不满足

// 3. 不满足

当编译器使用了 3 个规则之后仍然无法计算出返回值的生命周期时,就会出现编译错误,显然上面代码是会报错的。我们需要手动标注生命周期:

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

从表面上来看 x、y 的生命周期是相同的,都是 'a,但准确来说它表示的是 x、y 生命周期重叠的部分。而返回值的生命周期标注也是 'a,所以此处的含义就表示输出生命周期是两个输入生命周期重叠的部分。

longest 函数这么改的话,是合法的。

方法中的生命周期标注

然后是在方法中标注生命周期,它的语法和泛型是相似的。

// 声明周期的语法类似于泛型
// 必须要先通过 <'a> 进行声明,然后才能使用
struct Girl <'a> {
    name: &'a str,
}

// 在学习泛型的时候我们知道
// 这种方式表示为某个类型实现方法
// 现在则变成生命周期,并且 <'a> 不可以省略
impl <'a> Girl <'a> {
    fn say_hi(&self) -> String {
        String::from("hello world")
    }

    // 此处无需指定生命周期,因为 Rust 可以推断出来
    // 会自动将 self 的生命周期赋值给所有的输出生命周期
    fn get_name(&self, useless_arg: &str) -> &str {
        self.name
    }
}
fn main() {
    let name = String::from("古明地觉");
    let g = Girl{name:&name};

    println!("{}", g.say_hi());  // hello world
    println!("{}", g.get_name(""))  // 古明地觉
}

比较简单,另外程序中还有一个特殊的生命周期叫 'static,它表示整个程序的持续时间。所有的字符串字面量都拥有 'static 生命周期:

fn main() {
    let s: &'static str = "hello";
}

为引用指定 'static 之前需要三思,是否需要引用在整个程序的生命周期内都存活。

同时指定生命周期和泛型

生命周期的指定方式和泛型是一样的,那如果想同时指定生命周期和泛型,应该怎么做呢?

fn largest<'a, T>(x: &'a str, y: &'a str,
                  useless_arg: T) -> &'a str {
    if x > y {
        x
    } else {
        y
    }
}

fn main() {
    let s1 = "hello";
    let s2 = "hellO";
    println!("{}", largest(s1, s2, ""));
    // hello
}

非常简单,但要保证生命周期在前,泛型在后。

以上就是 Rust 的生命周期,它并没有改变 Rust 变量的存活时间,只是给了借用检查器更多的余地去推断引用是否合法。

就目前来说,我们介绍的内容都还很基础,应该很好理解。等把基础说完了,后面会介绍更多关于 Rust 的细节。最后的最后,我们再一起用 Rust 手写一个简易版的 Redis,并和现有的 Redis 做一下性能对比。

原文地址:https://mp.weixin.qq.com/s/zcw6ntonEG_2rLJYzxV9CA