写给 JavaScript 开发者的 Rust 入门指南 Part 4 - 生命周期

水会枯竭,火会燃尽,人会衰朽,世间万物都有其寿命

学习一门底层语言,犹如修炼内功,帮助我们以更底层的视角看待编程,写出健壮安全的代码。修炼 Rust 绝非一朝一夕,本系列文章旨在从 JavaScript 开发者的角度,对比和厘清 Rust 中那些有趣的,难啃的,或独有的概念,帮助同胞们迈出学习 Rust 的第一步。

完整文章传送门:

写给 JavaScript 开发者的 Rust 入门指南 Part 1 - 内存

写给 JavaScript 开发者的 Rust 入门指南 Part 2 - 所有权

写给 JavaScript 开发者的 Rust 入门指南 Part 3 - 借用

写给 JavaScript 开发者的 Rust 入门指南 Part 4 - 生命周期📍您在这儿

水会枯竭,火会燃尽,人会衰朽,世间万物都有其寿命。在 Rust 世界里,有的值生在栈上(Stack),有的值活在堆里(Heap),有的值被拥有(所有权),有的值被出借(借用)。这一切的活动的背后,都受一只无形大手的影响 --「生命周期」。

我认为,「生命周期」是入门 Rust 时最令人不解的东西。一来,这是 Rust 独有的,其他语言不存在的新概念,无从参考;再者,理解「生命周期」需要站在编译器的角度思考问题,脑回路不那么直接。我在开始接触「生命周期」时,最困惑的不是怎么用,而是到底为什么要有这个东西?没有这东西不可以吗?所以,有必要详细探究一下 -- 为什么要有生命周期?

为什么要生命周期

所谓生命周期,就是一个值从创建到销毁的整个过程。虽说每个值都有其生命周期,但我们在编码的时候却不必时刻在意。因为大多数场景下,值的生死存亡是自动的:对 JS 来说,有垃圾回收机制来替我们操心;在 Rust 里,所有权机制保障变量离开作用域的时候值被销毁。

那为什么要专门撰文讲述「生命周期」呢?回答这个问题,我们要回顾一下借用机制的核心原则:

「一个借用(引用),不能活得比出借者(引用的值)长」

以上这句话是 Rust 的铁律,一个绝对不能违背的钢铁原则。Rust 的借用检查机制(Borrow Checking)会时时刻刻确保这个原则被遵守 -- 通过追踪值的「生命周期」:

生命周期的表示

以上代码,Rust 编译器会默默给作用域内的每个值标注生命周期,过程大概是这样:变量 v 的生命周期就叫做 'a 吧, 变量 x 的生命周期就叫做 'b。由于 ’a 的「活动范围」明显大于 'b,也就是说,v(引用)比 x(引用的值)活得长(v outlive x),这会导致 v 在 x 死亡后,指向一个错误的值。违反了我们的「钢铁原则」,万万不行🙅,报错。

注意,字母 a,b 不是重点,只是随便选两个字母,代表两个不同的生命周期。a,b 前面的单引号 ' 在 Rust 中读「tick」,专门用来标注生命周期。所以这两个生命周期读起来是「tick a」,「tick b」。很有意思,tick 是秒针跳动的声音,用来指代生命周期很生动。tick、tick、tick...感受到值的生命在慢慢流逝了么。不错,把生命周期读出来,就是理解的第一步。

站在人类的角度

借用检查在一个作用域内,运行良好。但如果碰到这个情况👇:

fn smaller(m: &i32, n: &i32) -> &i32 {
if m <= n {
m
} else {
n
}
}

smaller 函数接收两个整数的引用作为参数,返回较小的那一个。这个函数的入参和返回值都是引用。函数逻辑看上去一目了然,然而,如果你尝试调用它,编译器却无情地抛出错误:

error[E0106]: missing lifetime specifier
--> src/any.rs:1:35
|
1 | fn smaller(m: &i32, n: &i32) -> &i32 {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `m` or `n`
help: consider introducing a named lifetime parameter
|
1 | fn smaller<'a>(m: &'a i32, n: &'a i32) -> &'a i32 {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.

Rust 的报错信息就像学霸的笔记,不但告诉你出错了,还会告诉你错在哪里,甚至教你怎么修复。我们来逐句分析:

首先,Rust 告诉我们,主要问题是「missing lifetime specifier」,翻译过来就是「缺失生命周期标识」。接着在两个参数和返回值下面做了标记,意思是这三个地方,需要显式地标记出生命周期参数(expected named lifetime parameter)。紧接着通过 help 信息告诉我们它(编译器)的疑惑:

help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from m or n

咦?这个函数的返回值是一个借用。但是从函数的声明,我无法确定到底借用的是 m 还是 n。

这里要展开讲讲。编译器在编译时,最小单位是函数。Rust 在编译这个函数的时候,它所知的一切信息,都在这个函数声明里了,包括入参,返回值,函数体内的逻辑。它并不知道今后这个函数在调用的时候,到底会面临什么样的情况。

具体到这个例子,编译器只知道参数 m 和 n 是 i32 整数类型的引用,最终返回其中一个引用,到底会返回哪一个?只有函数调用的当下才知道, 无奈编译器管不到运行时, 但它内心时刻惦记着:「一个借用(引用),不能活得比出借者(引用的值)长」,所以凡是遇到入参和返回值都是引用(借用)的情况,它会特别担心,此刻,它最怕发生的事情是:函数在调用的时候,返回值活得比参数 m,n 长。比如这个情况:

fn main() {
let v1 = vec![4,5,6];
let res;
{
let v2 = vec![1,2,3];
res = smaller(&v1[0], &v2[0]);
}
println!("The smaller one is {:?}",res);
}

我们用肉眼和人脑来分析一下这个情况:smaller 传入动态数组 v1 和 v2 的第一个元素的引用(&i32 类型)。一眼可以看出 v1 和 v2的生命周期是不同的,v1 的生命周期贯穿了整个 main 函数,而 v2 的生命周期只在花括号内的短短两行代码之内,花括号一结束(}),v2 就不存在了。所以 v1 的生命长于(outlive)v2。smaller 函数内部返回了 v1 和 v2 中元素的引用(m 或 n),可以看出这个情况返回的是参数 n(1<4),也就是 v2 中元素的引用 -- &v2[0],返回值被赋值给了花括号之外的变量 res,变量 res 的生命周期也贯穿了整个 main 函数,比 v2 活得长。意识到大问题了么?请一起吟唱借用的「钢铁原则」:

「一个借用(引用),不能活得比出借者(引用的值)长」

动态数组 v2 的引用 -- &v2[0], 也就是函数的返回值,活得竟然比 v2 还长。花括号一结束,v2 销毁,res(也就是&v2[0])就变成了迷途指针(Dangling Pointer),找不到回家的路。万万不可🙅。

站在编译器的角度

作为人类,只有把 smaller 函数放在具体的调用环境下分析,才能看出其中的风险。但在编译的时候,编译器干巴巴盯着 smaller 函数,是没有这些调用的上下文的,况且 smaller 函数可能在任何地方,任何情况下调用。编译器如何保证参数 m,n 和返回值(3个引用类型),在调用的时候是安全的呢?不会违背「一个借用(引用),不能活得比出借者(引用的值)长」的原则呢?它办不到,于是寻求我们的帮助,要我们为它标注生命周期,告诉它参数和返回值之间的关系。请继续看上文中的报错信息:

help: consider introducing a named lifetime parameter
|
1 | fn smaller<'a>(m: &'a i32, n: &'a i32) -> &'a i32 {
| ++++ ++ ++ ++

编译器建议我们手动给 smaller 函数标注生命周期,而且贴心地给我们标注好了!感动!我们来详细看看:smaller 后面尖括号里的 <'a>, 意思是声明了一个生命周期参数 'a,如果你熟悉 TypeScript 的话,会知道函数后尖括号里边定义的是「范型参数」,Rust 也一样,但除了可以在尖括号里声明范型参数,还可以声明生命周期参数(Lifetime Parameter)。

申明好了就可以用了,接着把生命周期 'a 加在了参数 m,n 以及函数返回值的前面,紧挨着引用符号&后边,变成 &'a。整体要表达的意思是:smaller 函数有这么一个生命周期 'a, 其参数 m,n 至少活得有 'a 这么长,且返回值也不能活得超过 'a,也就意味着,返回值不能比参数 m,n 活得长,最多也就一样长,都是 'a 这么长。目前为止,就这么多信息,有点抽象对不对。

'a 叫做「生命周期参数」,参数之所以是参数,是因为只有在调用时,根据传入的值,才能真正确定下来。生命周期参数在函数定义的时候,仅仅描述了函数的入参之间,入参和返回值之间的「相对关系」。现在,smaller 函数有了生命周期标记 'a , 再回到这个例子,来看看 Rust 编译器是怎么通过生命周期标记来保证引用安全的:

// 带着生命周期标注的 smaller
fn smaller<'a>(m: &'a i32, n: &'a i32) -> &'a i32 {
if m <= n {
m
} else {
n
}
}
fn main() {
let v1 = vec![4,5,6];
let res;
{
let v2 = vec![1,2,3];
res = smaller(&v1[0], &v2[0]);
}
println!("The smaller one is {:?}",res);
}

前面我们用肉眼和人脑分析出这段代码存在危险。现在我们用编译器的思考方式再来看看:smaller 函数传入 v1 和 v2 中元素的引用,通过生命周期标注可知,这两个参数的生命周期都是 'a,但 v1 和 v2 的生命周期明明不一样啊??v1 活得比 v2 长啊🤔。这时候,聪明的编译器会取较小的那个生命周期,也就是 v2 的生命周期,赋给生命周期参数 'a。为什么取较小的那个?因为编译器的目标是保证引用不能活得比引用的值长。就这个例子来说,返回值不能活得比参数长(因为返回值借用了其中一个参数)。想想看,一个引用,如果连较小的那个生命周期都活不过,就一定活不过较大的那个生命周期,说明就安全了。所以,smaller 的生命周期参数 'a 就是变量 v2 的生命周期,也等同于 v2 所在的花括号内的生命周期。别忘了,smaller 函数的返回值也标记了 'a ,意味着,返回值的生命周期也是 v2 的生命周期,也就是 v2 所在花括号内的生命周期,换句话说,返回值不能活得超过花括号。可是,代码中,返回值却赋值给了变量 res,变量 res 的生命周期明显比花括号长,万万不可🙅,报错。

再梳理一下编译器的思考链路:

因为:

条件1:'a = v2 的生命周期

条件2:'a = 返回值的生命周期

可得出👇

结论1:返回值的生命周期 = v2 的生命周期(或者说返回值生命周期不超过 v2 的生命周期)

又可知

条件3: v2 的生命周期等于 v2 所在花括号的生命周期

可得出👇

结论2: 返回值不能活得超过 v2 所在花括号

编译器的思考逻辑是数学的,逻辑的,和人脑的有所不同,但殊途同归。

想要通过编译,只需要稍微改变一下调用的情况:

// 以下代码可以成功编译✅
fn smaller<'a>(m: &'a i32, n: &'a i32) -> &'a i32 {...}
fn main() {
let v1 = vec![1,2,3];
{
let v2 = vec![4,5,6];
let res = smaller(&v1[0], &v2[0]);
println!("The smaller one is {:?}",res);
}
}

改变后的代码,变量 res 和变量 v2 的生命周期是一样的,都是花括号包裹的范围,所以 smaller 的返回值不会超过 v2 的生命周期,符合 smaller 的生命周期标注。编译通过✅。

以上就是编译器作为机器的思考过程,编译器不是人脑,如果没有给 smaller 函数标注生命周期,编译器就无法确定参数 m,n 和返回值之间生命周期的「相对关系」,也就无法在函数调用的上下文执行借用检查(Borrow Checking),自然无法保证「一个借用(引用),不能活得比出借者(引用的值)长」的原则了。所以,编译器在某些情况下需要生命周期标注的帮助。

值得注意的是,「生命周期标注」只是「标注」,是编码者给编译器提供信息,帮助它在编译阶段顺利执行借用检查,这些标注不会影响和改变函数的入参和返回值的真实的生命周期。编译器总是默默奉献,劳苦功高,竟然也有需要我们帮助的时候,简直是人机合作的典范了,不要吝啬,用标注报答它吧。

总结一下:

  • 生命周期标注是为了保证引用的安全,我们只会给引用(借用)标注生命周期
  • 定义函数时,给入参和返回值标注生命周期,是定义它们之间生命周期的相对关系
  • 在函数调用的上下文,根据传入的实参才可确定生命周期参数的值,如果违反了预先定义好的相对关系,编译失败
  • 生命周期标注只是标注,不改变数据的真实生命周期

函数的生命周期

给函数标注生命周期参数是最常见的场景,有多种情况:

没有参数直接返回引用

一个函数直接返回一个引用,是最明显的引用错误:

fn hello<'a>() -> &'a str {
let s= String::from("hello");
&s // error: cannot return reference to local variable `s`
}

函数 hello 在没有任何入参的情况下,返回了一个引用。在 Rust 中这意味着:返回的引用肯定来自于函数内部的某个变量,也就是内部变量 s,变量 s 在函数调用结束后就销毁了,一定会导致返回的引用 &s 指向错误的内存地址。编译器看到这种没有入参,却返回一个引用的情况,会直接报错🙅。

小贴士:对于 JavaScript 开发者来说,这里可能存在疑惑:为什么函数直接返回引用肯定来自于函数内部的某个变量?在 JS 中函数可以访问到定义时所在作用域的任何变量,不一定要是函数内部啊🤔:

let s = "hello";
function hello() {
return s // 访问到外部变量 s
}
hello() // 返回 "hello"

是的,Rust 的函数不可以访问函数以外的数据。难道 Rust 没有闭包功能吗?不是的,闭包是 Rust 的杀手锏,只不过 Rust 闭包需要专门的召唤方式,这是另一个话题了。

返回的引用和参数无关

同理,如果函数传入的参数和返回的引用无关,也会造成引用错误:

fn smaller<'a>(m: &'a i32, n: &'a i32) -> &'a i32 {
let number = 100;
&number // error: cannot return reference to local variable `number`
}

这个 smaller 函数有模有样地标注了参数和返回值的生命周期,实际上入参和返回值没有任何关系,而是直接返回了内部变量 number 的引用,这本质上和上一种情况没有区别,会造成引用错误,Rust 坚决反对这种挂羊头卖狗肉的行为🙅。

多个不同的生命周期

上文中,我们给 smaller 函数的两个参数标注了同一个生命周期 'a, 在调用的上下文,'a会取传入的实参中生命周期较小的那个。如果需要,也可以声明多个不同的生命周期参数:

fn smaller<'a, 'b>(m: &'a i32, n: &'b i32) -> &'a i32 {...}

声明多个不同的生命周期参数,只需要把多个生命周期参数在尖括号里用逗号隔开。我们分别把生命周期 ’a’b 标注给了参数 m 和 n。意图表达,m 和 n 有两个不同的生命周期。很遗憾,这段代码无法通过编译:

1 | fn smaller<'a, 'b>(m: &'a i32, n: &'b i32) -> &'a i32 {
| ------- -------
| |
| this parameter and the return type are declared with different lifetimes...
...
5 | n
| ^ ...but data from `n` is returned here

编译器一眼就看出了问题,m 和 n 有不同的生命周期,但是返回值却随意地标注了其中一个('a), 但明明 m 和 n 都有可能被返回啊,如果返回的是另一个,生命周期不就错乱了吗?这里要给返回值标注什么呢?我们说过,生命周期标注描述的是入参之间,入参和返回值之间的「相对关系」,我们这里想要表达的核心诉求是 --返回值(引用)不能活得超过入参(引用的值)。由于返回值来自于入参,那就要求返回值的生命周期要小等于所有的入参的生命周期,这等价于:返回值的生命周期要小等于所有参数生命周期中最小的那一个。所以要想知道返回值的引用如何标注,就要知道 'a'b 哪一个的生命周期比较小,这个信息我们需要「喂给」编译器:

生命周期约束

// 编译通过✅
fn smaller<'a: 'b, 'b>(m: &'a i32, n: &'b i32) -> &'b i32 {...}

请注意尖括号里的语法 -- 'a: 'b。意思是:生命周期 'a 比生命周期 'b 活得长,不妨直接把中间的 : 号理解成 > 号。这叫生命周期约束语法,用来指定生命周期参数的长短关系。我们用较短的生命周期 'b 标注了函数的返回值。意思是:函数的返回值要小等于入参的生命周期中较小的那一个,任务完成,编译通过✅。

在这个场景中,m 和 n 标注成 'a'b 或者'b'a 无关紧要,也不会影响调用时候的检查,对于编译器来说,有效信息有两个:

  1. 'b'a 是两个不同的生命周期,'b 是较短的那一个
  2. 返回值不能活得超过'b'a 中较小的那一个,也就是'b

晕了晕了,一开始全部标注成 'a 就没这么多事,怎么想标注清楚这么难啊!请不要气馁,对此你只要心中有数即可,最佳应对策略是:永远先尝试简单的标注,不行了再说,兜兜转转也总会标注好的。

部分参数生命周期标注

如果函数返回值只和其中部分引用类型的参数有关,可以只标注部分参数的生命周期:

fn add_string<'a>(m: &'a mut String, n: &str) -> &'a String {
m.push_str(n);
m
}

函数 add_string 接受两个参数:m 的类型是 &mut String -- 字符串的可变借用;n 的类型是 &str -- 字符串字面量。这个函数功能是给第一个参数 m 拼接上第二个参数 n,最终返回拼接后的字符串引用。可以看见,函数内部修改了可变借用 m 并把它返回,我们可以认为返回值只和参数 m 有关,所以只需要标注了 m 和返回值的生命周期。

静态生命周期

上述例子中,参数 n 是字符串字面量(String Literal),在 Rust 中,let s = "hello" 这样声明的字符串,就叫字符串字面量。字符串字面量在编译之后会打包到程序包的可执行文件里去(比如 exe 文件),在程序初始化的时候加载到内存中,直到程序关闭才销毁,也就是说字符串字面量的生命周期和整个程序一样长。程序初始化时,把字符串字面量存到某个内存地址,这个地址就和这个字符串绑定了,在程序结束之前都不会变,这个字符串拥有固定的,静态的内存地址。我们可以说,字符串字面量拥有静态生命周期。总结来说,拥有静态生命周期的数据,有固定的内存地址,活得和整个程序一样长。

小帖士:字符串字面量的类型是 &str。之所以是一个引用,是因为字符串(str)本身是无法直接访问的,只能通过一个引用访问这个字符串的固定地址,所以类型是 &str。

再看看 add_string 函数,没有标注生命周期的参数 n 就是一个字符串字面量,具备静态生命周期,我们也可以显示标注 n 的生命周期(虽然毫无必要)👇:

fn add_string<'a>(m: &'a mut String, n: &static str) -> &'a String

'static 标注表示数据具有静态的生命周期。使用 'static 的时候不用提前在尖括号里面声明,直接标注就好。在这个例子中,因为 add_string 函数的返回值和 n 没有关系, 这样标注毫无意义。我们换一个场景:

// 以下代码无法编译通过🙅
fn foo(n: &'static i32) { ... }
let v = 88;
foo(&v);

foo 函数的参数 n 是一个具有静态生命周期的 i32 整数类型,但是传入的实参 &v 在离开作用域之后就销毁了,明显不是静态生命周期,所以编译不通过。

// 以下代码可以编译通过✅
fn foo(n: &'static i32) { ... }
static V: i32 = 88; // 用 static 声明静态变量
foo(&V);

如果想要通过编译,就需要保证传入的整数引用具备静态生命周期。通过 static 关键字声明的变量叫做静态变量,具备静态的生命周期。静态变量可以理解成全局变量,在程序运行的整个过程都可以访问到。所以传入静态变量引用 &V(静态变量大写是 Rust 的风格),可以通过编译。

值得注意的是,虽然 static 关键字声明的变量具备静态生命周期,但静态生命周期的变量不一定出自 static 声明,比如字符串字面量不需要 static 声明,就拥有静态生命周期。拥有静态生命周期的数据活得和整个程序一样长,仅仅指数据本身不是和数据绑定的变量,变量在离开作用域的时候依然会被销毁:

{
let s = "Hello Rust";
println!("{}", s);
// s 离开作用域被销毁
}
// 变量 s 没有了,但 "Hello Rust" 这几个字符串依然存在内存中,直到程序终结

结构体的生命周期

目前为止,生命周期标注一直围绕着函数。其实结构体也常常需要标注生命周期。设想一个结构体内部的字段,引用了其他数据:

// 以下代码无法通过编译🙅
struct Point {
x: &i32,
y: &i32
}

结构体 Point 内部的 x,y 属性是引用类型,也就是说这个结构体引用了外部的其他数据,不知道你有没有嗅到危险的气息:如果引用的数据都不在了,比如 x 引用的数据已经被销毁了,结构体还活着,继续访问 x 就会出错。所以,Rust 力图保证:

「结构体不能活得超过其内部引用的值」

所以,当结构体内部有引用数据,也需要标注生命周期:

// 编译通过✅
struct Point<'a> {
x: &'a i32,
y: &'a i32
}

这个标注告诉编译器:结构体 Point 的实例不能活得超过其 x,y 引用的数据,最多一起活 'a 这么长。如果:

struct Point<'a> {
x: &'a i32,
y: &'a i32
}
fn main() {
let p;
{
let x = 10;
let y = 10;
p = Point {x: &x, y: &y};
// x,y 销毁,但实例 p 还活着
}
println!("{}", p.x) // 访问出错
}

变量 x,y 离开花括号作用域就销毁了,但实例 p 还活着,万万不可🙅。结构体的标注原理和函数是一样的,可以举一反三:

// 标注不同的生命周期及限定语法
struct Point<'a: 'b, 'b> {
x: &'a i32,
y: &'b i32
}
// 标注部分生命周期
struct Point<'a> {
x: &'a i32,
y: &'a i32,
z: i32
}
// 标注静态生命周期
struct Point {
x: &'static i32,
y: &'static i32
}

函数和结构体的生命周期标注是最常见的情形,但不限于此二者。本文目标是阐明生命周期的核心概念,掌握了核心,表象种种也自然可以见招拆招了。

生命周期标注省略规则

虽然生命周期标注可以帮助 Rust 理解我们的代码,但也并不需要时时刻刻,勤勤恳恳地标注每一个函数。事实上,Rust 编译器在某些情况下会自动「推断」生命周期,换句话说,Rust 编译器有一套生命周期省略规则(Lifetime Elision Rule):

规则1:只有一个引用类型入参的情况

如果函数的参数只有一个引用,且返回一个引用。Rust 会推断返回值参数的生命周期是一样的。

fn first(v: &Vec<i32>) -> &i32 {
&v[0]
}

函数 first 接受一个动态数组的引用作为参数,返回该数组第一项的引用。这种情况下我们不需要标注任何声明周期,因为 Rust 会自动推断出生命周期为👇:

fn first<'a>(v: &'a Vec<i32>) -> &'a i32 {
&v[0]
}

这非常合情合理。如果一个函数接收一个引用,返回一个引用,那么这个返回值肯定是来自于参数,它们具有相同的生命周期。这种情况下我们可以省略生命周期标注。

如果一个函数有多个参数,但只有其中一个是引用,同样可以省略👇:

fn first(v: &Vec<i32>, n: bool) -> &i32 {...}

可以看到,参数 n 是布尔类型,不是引用,所以返回的引用肯定来自参数 v,依然可以省略标注。

规则2:多个引用类型入参的情况

如果函数有多个引用类型的参数,但懒懒的你并没有标注生命周期。Rust 会默认每一个需要标注的参数,都有不一样的生命周期。

fn foo(s1: &str, s2: &str) -> &str {...}

函数 foo 的参数 s1, s2 都是引用类型,返回的也是引用类型。这种情况,如果不标注生命周期,Rust 会先推断 s1, s2 拥有两个不同的生命周期:

fn foo<'a, 'b>(s1: &'a str, s2: &'b str) -> &str {...}

两个参数推断出了生命周期,那返回值 &str 呢?对不起,编译器在此时无能为力了,因为没有任何其他信息可以辅助它做出返回值生命周期的推断,所以以上代码无法通过编译。你可能充满疑惑🤔,那这条规则有什么用?请接着往下看👇:

规则3:参数中有 &self 或者 &mut self 的情况

这里出现了一个新的概念 self,可以理解成 JavaScript 中的 this。用 JavaScript 举一个例子吧:

class Square {
constructor(length) {
this.length = length;
}
area() {
return this.length * this.length
}
}
let s = new Square(10);
s.area() // 100

一目了然。Square 是一个代表正方形的类。构造函数中传入边长 length。方法 area 用来计算正方形的面积。这个逻辑,如果用 Rust 表达👇:

// 定义 Square 结构体
struct Square {
length: i32,
}
// 为 Square 实现一个 area 方法
impl Square {
fn area(&self) -> i32 {
self.length * self.length
}
}
fn main() {
let s = Square { length: 10 }; // 实例化结构体
let area = s.area(); // 调用实例上的方法
println!("{:?}", area)
}

在这个例子中,struct Square 可以类比 JS 的 class Square。通过impl 关键词为结构体实现方法,impl 是 implement 的简写,「实现,履行」之意。方法 area 接受的参数是 &self, self 可以类比 JS 中的 this,指代结构体 Square 的实例。前面加一个 & 变成 &self, 意思是结构体实例的引用&mut self就是结构体实例的可变引用。假如不是引用,调用方法就会传入结构体实例本身,这个例子中,如果不是传入 &self,而是 self,变量 s 在调用 area 方法后,就会失去所有权,后续不可以访问了,会造成诸多不便,所以开发中 &selfself 多见。

介绍了什么是 self,回到正题。「生命周期标注省略规则」约定,如果参数中有 &self 或者 &mut self,则返回值的生命周期会推断为&self&mut self 的生命周期,也就是实例的生命周期👇

struct Square<'a> {
length: i32,
desc: &'a str
}
// 为 Square 实现一个 area 方法
impl<'a> Square<'a> {
fn area ...
fn info(&self, other: &str) -> &str {
println!("{}", other);
self.desc
}
}

我们为 Square 增加了一个引用类型的属性 desc,结构体存在引用需要标注生命周期,所以标注了 'a。接着我们为 Square 定义了一个 info 方法:参数类型是 &self 和 &str,返回类型是 &str。这个情况首先命中了规则 2:当存在多个引用参数的时候,每个引用参数都有不同的生命周期;接着命中规则 3:如果输入引用中有 &self,&mut self, 返回值的生命周期会推断为 &self&mut self 的生命周期。如果我们不标注,Rust 会推断标注为👇:

// 返回值的生命周期和 self 的生命周期标注为 'x
fn info<'x, 'y>(&'x self, other: &'y str) -> &'x str
// 内心OS:好丑的代码呀,花里胡哨的,还好可以省略😮💨

仔细琢磨一下,一个结构体的方法引用了结构体实例,返回一个引用,这个返回值肯定引用了结构体的实例。由于引用不能活得超过引用的值,所以 Rust 推断返回值和传入实例的生命周期相同,非常合理。

另外需要注意 impl<'a> Square<'a> 这行代码。当我们为具备生命周期标注的结构体实现方法时,需要先在 impl 后面用尖括号声明生命周期,并「传递」到结构体的尖括号里面。好在方法定义大多数情况不需要标注生命周期(命中规则2,3),不然代码可读性真是灾难。

总结

洋洋洒洒说这么多,如果你记不住或无法理解,请至少记住:

  1. 生命周期标注是为了帮助编译器判断引用的生命周期,避免迷途指针(Dangling Pointer)
  2. 一个策略:正常写代码,当编译器提示需要生命周期标注时,再考虑标注

结语

回到最开始的问题:

为什么要有生命周期呢? 因为有借用。

为什么要有借用呢? 因为值有所有权

为什么要有所有权呢? 为了保障内存安全。

如何保障内存安全呢? 保证堆内存上的数据用完及时销毁,指针不会迷路。

顺着问题一路反推,最终抵达 Rust 的初心 -- 「内存安全」。由此出发,Rust 构建了一套独一无二的编码范式,发明了一些无处参考崭新概念,既令人心存敬畏,又不禁心潮澎湃。如果你看到这里,你已经攀爬上了 Rust 入门最险峻的山峰,回头看看走过的路吧,然后继续上路,还有更高的山峰等待着你。