每日一R「05」生命周期

250 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第05天,点击查看活动详情

01-生命周期

Rust 中的值或其引用,根据其作用域的大小,可以划分为两类:

  1. 静态生命周期,即贯穿整个进程的生命周期。具有这类生命周期的值一般包括全局变量、静态变量、字符串字面量、使用 Box::leak 从堆中泄漏出去的值。静态生命周期一般通过 ‘static 或 &’static 表示,这种写法在 Rust 中称为生命周期标注。
  2. 动态生命周期,如果值或其引用是在某个作用域(例如函数作用域)中定义的,那么则称其具有动态生命周期。动态生命周期的标注一般表示为 ‘a 或 &’b,a 或 b 或者其他的什么符号都不重要。

课程中有一幅图总结了 Rust 中的生命周期与栈、堆上值的关系:

Untitled.png

  • 分配在栈上的值的生命周期与栈帧的生命周期绑定在一起;
  • 分配在堆上的值,通过所有权、栈帧中的胖指针与栈帧的生命周期绑定在一起;
  • 堆上通过 Box::leak 显式泄漏出去的值、全局变量、静态变量、字符串字面量、代码等内容与进程生命周期一致;

引用的生命周期是 Rust 中非常重要的一块内容,编译器也正是通过引用的生命周期检查来解决悬垂指针问题的。接下来我们来看一下编译器是如何识别引用的生命周期的,以及如何避免悬垂指针的。

02-悬垂指针和生命周期标注

悬垂指针本质是引用了已经释放的值,或者换个角度讲就是试图引用比自身生命周期短的值,思考如下的示例:

Untitled 1.png

变量的 r 的生命周期 ‘a 从声明开始,到 println! 结束。变量 x 的生命周期 ‘b 从变量声明开始,到第一个 } 结束。r = &x; 尝试将 r 指向 x。当 } 后,x 因为离开作用域而被释放,那 r 对 x 的引用也将不再合法。所以 r 就变成了一个悬垂指针。这在程序中是非常危险的。如果我们继续尝试读取 r,则可能会导致程序不可预测的行为。

为了保证 Rust 的所有权和借用的正确性,Rust 使用了一个借用检查器(Borrow checker),来检查我们程序的借用正确性。在编译阶段,Rust 编译器会比较变量的生命周期。如果检查不通过,则认为我们编写的程序存在风险。

大多数情况下,编译器可以通过上下文推断出变量的生命周期。但在某些情况下,编译器并不能推断出来,这时候就需要认为地对生命周期进行标注。一个常用的例子就是比较字符串切片长度的例子:

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

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

在编译时,编译器并无法推断出入参 x、y 和返回值之间的生命周期关系,所以会抛异常。根据我们的方法实现,返回值要么是 x,要么是 y,这取决于哪个的长度更长。这也就意味着,返回值的生命周期至少要跟 x 和 y 中较短的那个声明周期一致。我们把入参和返回值的生命周期都标注为 ‘a 就意味着,’a 是他们三个至少要满足的生命周期。

生命周期标注并不会改变任何引用的实际作用域。在通过函数签名指定生命周期参数时,我们并没有改变传入引用或者返回引用的真实生命周期,而是告诉编译器当不满足此约束条件时,就拒绝编译通过。

再考虑如下代码:

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str()); // borrowed value does not live long enough
    }
    println!("The longest string is {}", result); // ------ borrow later used here
}

采用之前的标注,上述代码运行会报错。我们来分析下报错原因:

  • 先来看下longest 函数的两个入参的生命周期,假设 string1 的生命周期为 ‘a,string2 的生命周期为 ‘b,明显 ‘a 长于(overlive) ‘b。
  • 根据前面的推断,longest 函数返回值的生命周期至少跟两个入参中较短的那个一样长,所以返回值的生命周期应该至少跟 ‘b 一样长
  • result 生命周期 ‘c,从定义开始,到 println! 结束,这个显然比 ‘b 要长。
  • 当 result = longest(); 时,属于前面介绍的悬垂指针情况,将较长生命周期的引用指向较短生命周期的值,所以报错。

本节课程中有一个课后习题,我觉得应该好好分析下其过程:

// 这种标注有问题吗?
pub fn strtok<'a>(s: &'a mut &str, delimiter: char) -> &'a str {
    if let Some(i) = s.find(delimiter) {
        let prefix = &s[..i];
        let suffix = &s[(i + delimiter.len_utf8())..];
        *s = suffix;
        prefix
    } else {
        let prefix = *s;
        *s = "";
        prefix
    }
}

fn main() {
    let s = "hello world".to_owned(); // 1
    let mut s1 = s.as_str(); // 2
    let hello = strtok(&mut s1, ' '); // 3
    println!("hello is: {}, s1: {}, s: {}", hello, s1, s); // 4
    // cannot borrow `s1` as immutable because it is also borrowed as mutable
}

strtok 函数中有两个引用参数和一个引用返回值,按照上述的标注方式,返回值应该至少跟可变引用的生命周期一样长。

  • s 的生命周期 ‘a,从1→4,s1 的生命周期 ‘b 从2→4,hello 的生命周期 ‘c 从3→4,即 ‘a > ‘b > ‘c。

  • s1 可变借用 &mut 的生命周期这里暂时记为 ‘d。

  • 根据函数中的标注,’d 至少要跟返回值 hello 的生命周期 ‘c 一样长,即’d 从3→4。

  • 而第4行,println! 中又出现了 s1 的只读借用,可变借用与只读借用不可共存,所以报错。

  • 解决办法也很简单,把 s1 与 hello 分开打印。

    println!("hello is: {}, s: {}", hello, s); // 4
    println!("s1: {}", s1); // 5
    

    分开打印为什么能行呢?s1 可变借用的生命周期到4就结束了,5行开始就没有活跃的可变借用了,所以可行了。

上面这个过程比较难以理解,我也是看了好多遍才逐渐明朗。

Rust 会尝试对函数中引用类型的参数和返回值进行生命周期推断,推断的规则如下:

  • 所有引用类型的参数都有独立的生命周期 'a 、'b 等。
  • 如果只有一个引用型输入,它的生命周期会赋给所有输出。
  • 如果有多个引用类型的参数,其中一个是 self,那么它的生命周期会赋给所有输出。

本节课程链接:《10|生命周期:你创建的值究竟能活多久?


历史文章推荐