前言
很多rust教程里,把生命周期标记'a视为rust中最难理解的一部分,尤其某些函数、结构体,必须声明生命周期标记,否则会报错,这个情况尤其让很多人难以理解,对着官方教程里的longest函数空流泪。
其实生命周期理解困难的本质在于:你写&可以省略生命周期标记。正是这个官方给你偷懒的一个语法糖,但却造成了生命周期有了编译器魔法的难理解。你只要不偷懒,给每个引用都加上生命周期标记,你就知道为什么longest函数为什么要加上'a了。
要理解生命周期,我们首先要树立一个观念:Rust 里的每一个“引用(&)”,其实头顶上都顶着一个生命周期标记。
1.不省略生命标记,写echo函数
比如我们非常简单的一个echo的函数,你会这么写:
fn main() {
let a = "Hello, world!";
let result = echo(a);
println!("{}", result);
}
fn echo(input: &str) -> &str {
input
}
非常简单对吧,其实你这里省略了引用的生命周期,真正完整的写法应该是:
fn echo<'a>(input: &'a str) -> &'a str {
input
}
返回值的生命周期,一定来自于输入参数的生命周期。这句话好理解吧?
这里的输入参数就一个input,它的生命周期是'a,所以返回的也是'a。
在编译器魔法里:如果只有一个输入参数,那么这个标签会自动赋给所有输出参数。所以当你写下fn echo(input: &str) -> &str的时候,编译器会把它自动脑补成fn echo<'a>(input: &'a str) -> &'a str。
2.理解longest函数
文档里有个longest函数,教程里经典的生命周期教学,但是理解起来并不容易:
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 {
if x.len() > y.len() { x } else { y }
}
这里为什么要有生命周期呢?其实我们只需要还原它真正的写法,你就知道为什么了:
-
可能性1
'b>='a,那么返回值的时间周期肯定要取min('a, 'b)='a:
fn longest<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
-
可能性2
'a>='b,那么返回值的生命周期肯定要取min('a,'b)='b:
fn longest<'a: 'b, 'b>(x: &'a str, y: &'b str) -> &'b str {
if x.len() > y.len() { x } else { y }
}
合并下这两种情况:
fn longest<'min_lifetime>(x: &'min_lifetime str, y: &'min_lifetime str) -> &'min_lifetime str {
if x.len() > y.len() { x } else { y }
}
这里的 'min_lifetime 不再是某一个变量的实际寿命,它代表的是 所有相关引用的重叠安全区。只要 x 和 y 的寿命都覆盖了这个区域,代码就是安全的。
再缩略一点:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
就是教程里的那个形式了。
3.min_lifetime最小公约数的局限性
我相信看了第2个例子,你会有个疑问,如果函数标记用的是多个引用输入参数的生命周期的最小公约数:为什么编译器不自动用min_lifetime来处理存在多个引用输入的函数呢?
因为多个引用输入参数中,可能有的完全不参与返回值引用的计算,比如我们有个偏心的函数get_first,是只返回第一个参数,第二个参数没用。
如果编译器“自作聪明”自动加了 min_lifetime,编译器会把它脑补成:
fn get_first<'a>(first: &'a str, _second: &'a str) -> &'a str {
first
}
这会导致一个非常荒唐的结果:
fn main() {
let s1 = String::from("long long lifetime");
let res;
{
let s2 = String::from("near death lifetime");
res = get_first(&s1, &s2); // 编译器:res 现在的寿命等于 s2 的寿命
} // s2 死了,res 也被编译器判了死刑
println!("{}", res); // 报错!尽管 res 指向的 s1 还活得好好的。
}
这里我们就会看到min_lifetime的副作用了。要想在这个环境下成功运行,应该你自己手动写完整的生命周期才行:
fn get_first<'a, 'b>(first: &'a str, _second: &'b str) -> &'a str {
first
}
生命周期的本质,是你给编译器做的一个承诺。实际上每个引用,都带有生命周期,如果你不理解的话,你只需要不怕麻烦地给每个引用参数都加上生命周期标记,你就很容易理解这其中的门道了。
4.结构体里&self的生命周期
我们写结构体的时候,比如:
struct Student {
name: &str,
age: u8,
}
这里只要有一个引用,就会提示报错,一定要你写上生命周期标记:
struct Student<'a> {
name: &'a str,
age: u8,
}
这是因为函数是瞬时状态,可以推导类型;而结构体是一个长期的“容器”,必须强制要求这个“容器”的生命周期不能长于内部引用的生命周期。
多引用参数的结构体如下:
struct Student<'a, 'b> {
name: &'a str,
description: &'b str,
age: u8,
}
impl<'a, 'b> Student<'a, 'b> {
fn longest_str(&self) -> &str {
if self.name.len() > self.description.len() {
self.name
} else {
self.description
}
}
}
我们在这里实现了和longest类似的一个longest_str,这里其实真正去掉缩略生命周期之前的标记是这样的:
impl<'a, 'b> Student<'a, 'b> {
fn longest_str<'c>(&'c self) -> &'c str {
if self.name.len() > self.description.len() {
self.name
} else {
self.description
}
}
}
'c = self的生命周期,在编译器的规则里:方法返回值默认绑定&self,这里头隐含了一个条件,就是'c ≤ 'a && 'c ≤ 'b,这样才能保证安全。'c并不是min('a, 'b),只是受约束 'c ≤ min('a,'b)。
5.impl后面为什么要写生命周期?
其实生命周期标记,本质上跟范型没什么区别。
struct Student<T, U> {
name: T,
description: U,
age: u8,
}
impl<T, U> Student<T, U> {
// ...
}
这里头,impl 后面必须写 <T, U>,表示我为所有T、U类型版本的Student都实现这个方法。
要记住!!
生命周期 'a, 'b 本质就是 泛型类型的一种特殊形式。
可以说在编译器里面,&'a str和&'b str是不同类型,哪怕他们的空间类型一致,但是因为时间维度不同,可以视作不同类型。
假设我们不想给所有生命周期的Student实现方法,而是只想给生命周期为'static(也就是和整个程序活得一样长的静态生命周期)的Student实现某个特有的方法,我们会这么写:
struct Student<'a, 'b> {
name: &'a str,
description: &'b str,
age: u8,
}
impl Student<'static, 'static> {
fn only_static(&self) {
println!("This Student lives forever!");
}
}
下面我们在main函数里验证一下:
fn main() {
let static_student = Student {
name: "Alice", // &'static str
description: "A top student", // &'static str
age: 20,
};
static_student.only_static();
}
这里会完美运行,因为static_student满足Student<'static, 'static>的类型签名。
但是如果我们传入的不是&'static str:
fn main() {
let local_name = String::from("Bob");
let normal_student = Student {
name: local_name.as_str(), // 这里的生命周期被限制在 main 函数的作用域内,并非 'static
description: "Just a normal guy",
age: 21,
};
normal_student.only_static();
}
这里会直接报错:
|
17 | let local_name = String::from("Bob");
| ---------- binding `local_name` declared here
...
20 | name: local_name.as_str(), // 这里的生命周期被限制在 main 函数的作用域内,并非 'static
| ^^^^^^^^^^ borrowed value does not live long enough
...
25 | normal_student.only_static();
| ---------------------------- argument requires that `local_name` is borrowed for `'static`
...
30 | }
| - `local_name` dropped here while still borrowed
这个例子我们可以看到,生命周期标记甚至可以作为“类型签名”的一部分参与方法解析。
6.深入对比生命周期与范型
1. 单通用实现
还是拿上面的Student做例子:
// 类型泛型:为所有类型的 Student 实现
impl<T, U> Student<T, U> {
fn hello(&self) { /* ... */ }
}
// 生命周期泛型:为所有生命周期的 Student 实现
impl<'a, 'b> Student<'a, 'b> {
fn hello(&self) { /* ... */ }
}
2. 单特化实现
// 类型特化:只给存储字符串和整数的 Student 实现
impl Student<String, i32> {
fn special_logic(&self) { /* ... */ }
}
// 生命周期特化:只给引用静态字符串的 Student 实现
impl Student<'static, 'static> {
fn only_static(&self) {
println!("I live forever!");
}
}
3.生命周期+范型混合
3.1 通用实现
struct Student<'a, T> {
name: &'a str,
data: T,
}
// 这里在 impl 后面声明了两个变量:'a (活多久) 和 T (是什么)
impl<'a, T> Student<'a, T> {
// 构造函数
fn new(name: &'a str, data: T) -> Self {
Self { name, data }
}
// 打印方法
// 注意:即使 data 的类型 T 不确定,只要 T 实现了 Display 就能打印
// 但这里我们先写一个最基础的
fn get_name(&self) -> &'a str {
self.name
}
}
3.2 特化实现
- 特化范型
// 注意:这里 impl 后面只剩 <'a> 了,因为 T 已经被确定为 i32
impl<'a> Student<'a, i32> {
fn add_to_data(&mut self, val: i32) {
self.data += val;
}
}
- 特化生命周期
// 注意:这里 impl 后面什么都不加!
// 因为生命周期是具体的 'static,类型是具体的 String
impl Student<'static, String> {
fn promote_to_permanent(&self) {
println!("{} is a permanent student with data: {}", self.name, self.data);
}
}
可以看到在rust中,手动特化一个具体的生命周期几乎只有 'static 这一种情况。
感慨rust的类型系统实在是复杂又强大。