Rust 生命周期:借用为什么必须标注关系

 

概述

生命周期(lifetimes)是 Rust 初学者最容易卡住的主题之一,因为它不像“变量”“函数”“结构体”那样直观。

先记住这句最重要的话:

生命周期不是用来延长引用寿命的,而是用来描述引用之间关系的。

这篇文章只讲生命周期本身,目标是把以下问题讲清楚:

  • 为什么 Rust 需要生命周期
  • 借用检查器在防什么问题
  • 为什么 longest 这种函数需要生命周期注解
  • 生命周期省略规则到底帮你省掉了什么
  • 'static 是什么,它为什么经常被误用

核心问题

Rust 允许你借用值,但不允许你拿着一个已经失效的引用继续用。

例如下面这段代码:

// 这些代码不能编译!
{
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {}", r);
}

问题在于:

  • x 在内部作用域结束时就被销毁了
  • r 却还想在外层作用域里继续引用它

如果 Rust 允许这段代码通过,r 就会变成悬垂引用。

这正是生命周期要防的问题。


借用检查器到底在检查什么

Rust 编译器里有一个很重要的机制:借用检查器(borrow checker)。

它不会“运行程序看看会不会出错”,而是在编译阶段检查:

  • 这个引用借用了谁
  • 被借用的值会活多久
  • 引用会活多久
  • 引用的有效范围是否越界

如果引用活得比被引用值还久,编译器就会拒绝。

用作用域图理解

无效示例:

// 这些代码不能编译!
{
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {}", r); //          |
}                         // ---------+

这里:

  • r 的作用域是 'a
  • x 的作用域是 'b

'b 明显比 'a 短,所以 r 不能引用 x

有效示例:

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

这次 x 活得比 r 久,所以引用有效。


最小示例:为什么 longest 不能直接写

很多人第一次真正接触生命周期,是在这个函数:

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

这段代码不能编译。

为什么不能编译

因为编译器知道:

  • 返回值是个引用

但它不知道:

  • 返回值到底和 x 的生命周期相关
  • 还是和 y 的生命周期相关
  • 还是两者中较短的那个相关

换句话说,编译器不是不知道“类型”,而是不知道“引用关系”。


生命周期注解是在描述关系

正确写法是:

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

这句到底在说什么

它不是说:

  • “把 xy 都延长到 'a

而是在说:

  • x 至少在 'a 这段时间内有效
  • y 至少在 'a 这段时间内有效
  • 返回值也只保证在 'a 这段时间内有效

所以真正的含义是:

返回值的有效期不会超过两个输入引用重叠的那段时间。

这就是为什么生命周期注解是在“描述关系”,而不是“操控寿命”。


有效与无效的调用

有效示例

fn main() {
    let string1 = String::from("long string is long");

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

这里 result 的使用没有超出 string2 的作用域,所以是安全的。

无效示例

// 这些代码不能编译!
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());
    }
    println!("The longest string is {}", result);
}

这里的问题是:

  • result 可能引用的是 string2
  • string2 已经在内部作用域结束时被销毁了

所以编译器必须拒绝。


返回值必须和输入引用有关联

这是生命周期最重要的规则之一。

看这个例子:

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

这里可以工作,因为返回值明确来自 x,所以只需要和 x 建立关系。

再看这个错误例子:

// 这些代码不能编译!
fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}

为什么不行?

因为:

  • result 是函数内部创建的局部值
  • 函数结束时它就被销毁了
  • 你却想返回一个指向它的引用

所以当函数想返回引用时,返回值通常必须和某个输入引用建立关系;否则就很容易产生悬垂引用。

什么时候该改成返回拥有所有权的值

如果函数内部创建了新数据,并且要把它交给调用者,通常就不该返回引用,而应该返回拥有所有权的值:

fn make_string() -> String {
    String::from("really long string")
}

结构体中的生命周期

结构体里如果存引用,也需要标注生命周期:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

这表示:

  • ImportantExcerpt 里保存了一个引用
  • 这个结构体实例不能活得比 part 指向的数据更久

使用示例:

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel
        .split('.')
        .next()
        .expect("Could not find a '.'");

    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

只要 novel 还活着,first_sentencei.part 就有效。


生命周期省略是什么

很多地方你没写生命周期,代码也能编译。这不是因为生命周期不存在,而是因为编译器按照一组规则帮你补出来了。

这组规则叫生命周期省略(lifetime elision)。

先看一个能省略的例子

fn first_word(s: &str) -> &str {
    // ...
}

它之所以能省略,是因为这里只有一个输入引用参数,编译器可以明确判断返回值和它关联。

省略规则最重要的直觉

可以先只记这两条:

  1. 每个输入引用参数,编译器先假设它有自己的生命周期
  2. 如果只有一个输入引用,那返回值通常就和它关联

为什么 longest 不能省略

因为它有两个输入引用:

fn longest(x: &str, y: &str) -> &str

这时编译器无法自动判断返回值究竟跟谁绑定,所以你必须手写生命周期关系。


方法里的生命周期为什么常常不用写

看这个方法:

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

这里不需要手写 &self 的生命周期,是因为省略规则已经能推导。

再看一个返回引用的方法:

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}

为什么也能省略?

因为方法有一个特殊规则:

  • 如果参数里有 &self&mut self
  • 返回值默认优先和 self 的生命周期关联

所以这里编译器可以理解返回值来自 self.part


'static 是什么

'static 表示这个引用在程序整个运行期间都有效。

最常见的例子是字符串字面量:

let s: &'static str = "I have a static lifetime.";

因为字符串字面量直接存放在程序二进制里,所以它们天然可以活到程序结束。

为什么初学者容易误用 'static

很多人一看到生命周期报错,就想“那我能不能直接加 'static”。

大多数时候不应该这么干。

因为真正的问题通常不是“生命周期不够长”,而是:

  • 你返回了不该返回的引用
  • 或者你没有正确表达引用之间的关系

所以 'static 不是修复生命周期报错的万能胶带。


常见误区

误区一:生命周期会延长变量寿命

不会。

生命周期注解只是在描述约束,不会改变值实际活多久。

误区二:生命周期只和函数有关

不是。

结构体里存引用时,同样要处理生命周期。

误区三:只要加上 'static 就能解决问题

大多数时候这是错的。真正应该解决的是引用来源和作用域关系。


总结

概念 说明
生命周期 描述引用之间的有效关系
借用检查器 在编译期检查借用是否安全
生命周期注解 不延长寿命,只表达关系
longest<'a> 典型的“返回引用必须和输入相关联”的例子
结构体生命周期 结构体保存引用时,也必须描述关系
生命周期省略 编译器在常见场景下自动补全生命周期
'static 表示整个程序期间都有效的引用

最佳实践:先问“返回的引用到底来自谁”,再决定生命周期怎么写。不要一上来就背语法。


知识自测

尝试独立回答以下问题,答案在下方。


第 1 题:生命周期的核心作用是什么?

第 2 题:为什么说生命周期不是用来“延长引用寿命”的?

第 3 题:借用检查器在检查什么?

第 4 题:为什么 fn longest(x: &str, y: &str) -> &str 不能直接通过编译?

第 5 题fn longest<'a>(x: &'a str, y: &'a str) -> &'a str 这句最关键的含义是什么?

第 6 题:为什么函数返回引用时,返回值通常必须和输入引用有关联?

第 7 题:结构体里保存引用时,为什么也要写生命周期?

第 8 题:为什么 first_word 这种函数常常不需要显式生命周期?

第 9 题'static 是什么?为什么不能拿它到处修生命周期报错?


📖 点击查看答案

第 1 题答案

生命周期的核心作用是描述引用之间的有效关系,并帮助编译器在编译期保证借用安全。


第 2 题答案

因为生命周期注解不会改变值实际存在多久,它只是在函数签名或类型定义里表达约束关系。


第 3 题答案

借用检查器会检查引用是否在其被引用值仍然有效的范围内使用,以及借用关系是否安全。


第 4 题答案

因为编译器不知道返回值到底和 x 还是 y 的生命周期相关,所以无法判断返回引用是否安全。


第 5 题答案

它表示返回值的有效期不能超过两个输入引用共同有效的那段时间。


第 6 题答案

因为如果返回的引用和任何输入都无关,就很可能指向函数内部临时创建、函数结束后就失效的数据。


第 7 题答案

因为结构体实例可能比其中引用的数据活得更久,生命周期注解用来防止这种不安全情况。


第 8 题答案

因为生命周期省略规则在常见简单场景下可以自动推导,不需要手写。


第 9 题答案

'static 表示引用在整个程序期间都有效。它只适用于确实满足这一条件的数据,不能拿来掩盖普通生命周期设计错误。


后续阅读


参考资料