跳转到内容

Rust 生命周期

很多人学 Rust,第一次真正“撞墙”就是在生命周期这里。这一页会讲得轻松一些:目标只是让你认出生命周期是什么、编译器为什么会向你要它,而不是一口气掌握所有相关规则。

在借用那一页你已经知道,引用(&)可以让你使用一个值而不拥有它。但引用只有在它指向的值还存在时才有效。Rust 必须确保你永远不会拿到一个指向“已经被清理掉的东西”的引用。这类 bug 叫悬垂引用(dangling reference),在 C 这样的语言里是程序崩溃的经典原因。

大多数时候,Rust 通过观察代码里的作用域就能自己搞清楚,你根本不用操心(这正是你在作用域那一页看到的)。但有时候,尤其是在函数里,编译器没法自己推断出来,就会请你把它写清楚。这就是生命周期注解(lifetime annotation)。

下面这个函数返回两个字符串 slice 中较长的那个:

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

它编译不过,错误信息是这样的:

error[E0106]: missing lifetime specifier

意思是“缺少生命周期说明符”。问题在于:返回的引用可能借自 a,也可能借自 b,Rust 光看函数本身分辨不出是哪一个,所以它没法保证你拿到的引用在你使用时依然有效。

修复的方法是给生命周期起个名字(按照惯例,用一个小写字母,比如 'a),然后告诉 Rust:返回值的生命周期和两个参数绑在一起:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
fn main() {
let s1 = String::from("hello");
let s2 = String::from("hi");
let result = longest(&s1, &s2);
println!("较长的是:{result}");
}

输出:

较长的是:hello
▶ 动手试一试

把 'a 看成一个标签,而不是一个值:它并不规定实际的时长。它只是告诉 Rust:“这些引用至少要在同一段代码里都保持有效。”然后编译器会检查每一个调用的地方,确认这件事真的成立。

没有这个注解,编译器真的分辨不出 longest 返回的引用会不会比它来源的某个字符串活得更久。有了生命周期注解,编译器就能在编译期证明(运行时零开销):你拿到的引用在使用时一定有效。

你接下来会在结构体里遇到生命周期。到目前为止,结构体的每个字段都拥有自己的数据,比如 String。字段也可以是引用,但这样一来,结构体只有在它指向的数据还存在时才有效。Rust 要求你用生命周期把这一点写出来:

struct Highlight<'a> {
text: &'a str, // 借用别处的某个 String
}
fn main() {
let book = String::from("Call me Ishmael. Some years ago...");
let first = Highlight { text: &book[..15] };
println!("摘录:{}", first.text);
println!("整本书一共 {} 个字节", book.len());
}

输出:

摘录:Call me Ishmael
整本书一共 34 个字节
▶ 动手试一试

把 Highlight<'a> 读作“一个在生命周期 'a 内借用了某样东西的 Highlight”。&book[..15] 是一个 slice:它借用 book 的前 15 个字节,而不复制它们。书里的文字保留英文,因为 slice 是按字节切的,一个汉字占 3 个字节,换成中文就切不对了。因为 first 只是借用,所以之后 book 还能继续用。

有了生命周期,Rust 就能抓住下面这种错误:String 已经被丢弃了,结构体却还指向它:

struct Highlight<'a> {
text: &'a str,
}
fn main() {
let highlight;
{
let book = String::from("Call me Ishmael. Some years ago...");
highlight = Highlight { text: &book[..15] };
} // book 在这里被丢弃……
println!("{}", highlight.text); // ……但 highlight 还指向它
}
error[E0597]: `book` does not live long enough
--> src/main.rs:9:40
|
8 | let book = String::from("Call me Ishmael. Some years ago...");
| ---- binding `book` declared here
9 | highlight = Highlight { text: &book[..15] };
| ^^^^ borrowed value does not live long enough
10 | } // book 在这里被丢弃……
| - `book` dropped here while still borrowed
11 |
12 | println!("{}", highlight.text); // ……但 highlight 还指向它
| -------------- borrow later used here

这条错误的意思是“book 活得不够久”:book 在 } 处被丢弃时还处于被借用的状态,而这个借用在后面又被用到了。

一个实用的小建议:如果结构体里的生命周期让你很头疼,就改成存一个拥有所有权的 String,而不是 &str。这样会复制一份文字,但结构体不再依赖任何别的东西,生命周期也就消失了。很多有经验的 Rust 程序员一开始都这么写,等到有理由时才换成引用。

你实际写 'a 的次数,会比这一页给你的印象少得多。大量 Rust 代码根本不需要显式的生命周期,因为在大多数日常场景下,编译器都能自己推断出来。你主要会在函数返回引用、而编译器又推断不出引用从哪里来时遇到它。到那时只要记住:这不是 bug,而是编译器在请你确认一件它自己证明不了的事。

写一个函数 shortest,接收两个字符串 slice,返回较短的那个。如果两个一样长,就返回第一个。用 "elephant" 和 "cat" 调用它。

提示:它和上面的 longest 形状一样。函数名后面需要 <'a>,两个参数和返回类型上都要写 'a。

查看答案
fn shortest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() <= b.len() { a } else { b }
}
fn main() {
let big = String::from("elephant");
let small = String::from("cat");
println!("较短的是:{}", shortest(&big, &small));
println!("一样长时取第一个:{}", shortest("dog", "cow"));
}

输出:

较短的是:cat
一样长时取第一个:dog
▶ 动手试一试

返回的 slice 可能来自 a,也可能来自 b,所以 Rust 需要 'a 才能知道:结果只在两个输入都有效时才有效。用 <= 而不是 <,一样长时就会选 a。

定义一个结构体 Quote,它有两个 &str 字段:text 和 author。创建一个实例:text 借用 String "Fearless concurrency is a goal of Rust" 的前 20 个字节,author 是字面量 "The Rust Book"。把它打印成 Fearless concurrency (The Rust Book)。

提示:和上面的 Highlight 一样,这个结构体需要生命周期:struct Quote<'a>,两个字段都用 &'a str。&speech[..20] 就是前 20 个字节的 slice。

查看答案
struct Quote<'a> {
text: &'a str,
author: &'a str,
}
fn main() {
let speech = String::from("Fearless concurrency is a goal of Rust");
let quote = Quote {
text: &speech[..20],
author: "The Rust Book",
};
println!("{} ({})", quote.text, quote.author);
}

输出:

Fearless concurrency (The Rust Book)
▶ 动手试一试

quote 借用了 speech,所以它不能比 speech 活得更久。字面量 "The Rust Book" 直接写在程序里,整个运行期间都存在,所以它轻松满足 'a 的要求。试着把 speech 放进一个内部的 { } 块里创建,然后在块结束后打印 quote,就像上面那个出错的例子一样,看看编译器怎么拦住你。

  • 生命周期告诉编译器引用之间是什么关系,好让它排除悬垂引用。
  • 只有当编译器自己推断不出来时才需要手写,最常见的是返回引用的函数。
  • 'a 并不规定时长,它把不同引用的生命周期联系起来,让编译器去检查。
  • 持有引用的结构体也需要生命周期(struct Highlight<'a>),而且不能比它借用的数据活得更久。改存拥有所有权的 String 就能避开这个问题。
  • 这完全是编译期的检查,不会增加任何运行时开销。

小测验

1. 生命周期解决的是什么问题?

2. 为什么 fn longest(a: &str, b: &str) -> &str 不加生命周期注解就编译不过?

3. 加上生命周期注解会带来运行时开销吗?

4. 按照这一页的说法,什么时候最常需要显式写出生命周期?

得分: 0 / 4