写在前面
作为Rust开发者,你是否还没有完全理解引用及其生命周期?是否处于教程一看就会,但在实际开发过程中不知所措?本文将由浅入深,手把手教你彻底理解Rust引用与生命周期。
关于本文的理解门槛
本文主要面向的是已经基本上了解过Rust这门语言,对引用以及生命周期(及其标识)有基本的了解,但对于包含生命周期标识的复杂场景理解吃力的Rust开发者。因此本文不会赘述讨论关于引用的语法形式,像是如果连下面的例子为什么会报错都不清楚原因的话,那么本篇就不太适合阅读了。
rust
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {}", r);
}
包含引用的方法
让我们从一个最简单的例子开始,假设有如下的方法签名:
rust
fn func(num: &i32) -> &i32;
大多数的教程都会告诉你:"入参是一个引用,返回也是一个引用,在这里,返回的引用的生命周期不能超过入参引用的生命周期,... ..."。这样说确实没有错,但这句话本质上是一个结论,对于不熟悉Rust生命周期的人来说,无法清晰地理解其中的逻辑原理,是不会真正掌握这块的内容。
接下来让我们进入正题。回到上面的例子,观察这个方法签名,我们已经知道了这个方法有一个引用作为入参,且返回的也是一个引用。那它们俩有没有关联呢?答案就是:在这种场景中,即使没有生命周期标识,它俩也一定存在关系。
在讲原因前,让我们先理解一个基本的事实:引用不可能凭空产生,它一定是来源于某个实际变量。有了这个基本的事实,让我们再来分析这个方法签名。
首先入参是一个引用。考虑到"引用一定存在来源",那么这个入参引用会来自于什么呢?很容易想到,就是调用该方法时,外部某个变量借用而来的到的引用,作为了此时的入参:
rust
// 一些代码...
let data: i32 = 100;
// 调用方法
func(&data); // <- 方法的入参这个引用,来源于调用方法前某个变量借用而来得到的引用
入参引用我们分析好了,接下里让我们来分析返回值。返回值是一个引用,我们依然套用上面的"引用一定有其来源"来思考,这里返回的引用的来源是什么呢?首先我们考虑这里返回的引用会不会是方法中的局部变量借用而来,很显然不可能。假设代码如下:
rust
fn func(num: &i32) -> &i32 {
let some_data: i32 = 100;
let some_ref: &i32 = &some_data;
some_ref //
}
在这里,我们在方法体内部创建了一个i32类型的变量,得到它的引用,再通过方法返回。然而,some_data
是方法的局部变量,一旦func
方法执行完毕,some_data
变量对应的内存就会被释放,那么返回给外部的some_ref
就成了无效的悬垂引用(Dangling References),引用着一段无效的内存。对于这种情况,Rust编译器可以非常容易的推断出你的代码语义,并禁止这种情况出现,No Way!因此,该方法返回的引用的来源就不可能是一个方法中的局部变量。
稍有经验的读者可能会想到使用
'static
这个特殊的生命周期标识来绕过我们的例子,但请不要着急,在本文的后面我们会提到的。当然,如果你还不太明白'static
,那么太好了,可以完全忽略这段话。
既然不可能是来源于借用一个局部变量得到的结果,那么对于这个例子来说,我们就只能让其和入参进行关联了,例如编写如下的代码:
rust
fn func(num: &i32) -> &i32 {
num // <- 咱们直接把入参引用返回出去
}
虽然这个例子很简单,但是我们可以从中联想:虽然我们无法知道这个例子中func
方法的具体实现,但这里方法的入参引用和出参引用之间,会因为"引用一定要有来源"这一事实,而形成一种关系:

也就是说,num_ref
来源于 num
,而return_ref
又来源于 num_ref
:

既然存在来源关系,那么按照朴素的思维逻辑,被产出者不能存活的比被来源者还久("我"来源于"你",而"你"先没了,那"我"咋办 >_< )。
因此,那我们可以非常自然地给出结论:num_ref
不能存活的比num
久,而return_ref
不能存活的比num_ref
久。也就是说,对于这个方法:
rust
fn func(num: &i32) -> &i32;
也就是说,入参引用的要比返回的引用存活的更长才行。
值得注意的是,在本例中,目前为止,我们完全没有把Rust生命周期那套东西搬出来,仅仅是通过简单的关系逻辑梳理,就能分析出上述"生命周期"的关系。
此外,在这个场景中,我们即使不给方法上添加生命周期标识,也能通过Rust编译器的检查,毕竟,这里单个入参引用和出参引用一定有来源关系。
关于生命周期标记
其实一直以来,笔者都认为"生命周期标记" 这个命名存在一定的误导性。在笔者看来,这个东西更加适合叫做 "引用关系标记",所以在本文,接下来内容中,笔者都将使用 "引用关系标记" 来书写表述。还是上面的例子,当我们手动加上引用关系标记以后如下所示:
rust
fn func<'a>(num: &'a i32) -> &'a i32;
上述方法签名表达了这样一种意思:入参引用与返回的引用存在关联关系,因为它俩都用了同一个 引用关系标记('a) 来标识。当然,我们前面已经分析知道了,在单个引用入参,然后返回引用的场景下,入参引用与出参引用会存在关系。因此,这里的引用关系标记可以移除。
那我们可以是不是可以完全不用引用关系标记呢?让我们用一个非常经典的例子来进一步分析说明:
rust
fn func(num_ref1: &i32, num_ref2: &i32) -> &i32;
上面的方法签名有2个输入引用和1个输出引用。同样基于 "引用一定存在来源" 的思路来分析引用,入参的num1
、num2
和之前一样就是来自调用该方法时,外部某个变量借用而来的引用。
然而,当我们试图分析返回的引用的来源时,会发现有点困难了。返回的&i32
首先不可能是方法内局部变量借用而来,所以依然与入参引用有关,那究竟是与num1
有关还是与num2
有关呢?回答是:没法确定。比如下面的例子:
rust
fn func(num_ref1: &i32, num_ref2: &i32) -> &i32 {
num_ref1
}
这种情况,一看就知道,返回的引用只与num1
这个输入引用有关系。然而,如果这个方法的实现改为了:
rust
/// 伪代码
fn func(num_ref1: &i32, num_ref2: &i32) -> &i32 {
如果运行时,此刻的秒钟为偶数
返回 num_ref1
否则
返回 num_ref2
}
此刻,返回引用究竟与num1
有关还是与num2
有关,就需要根据实际运行时情况而动态变化了,我们只能说:可能与输入引用num1
有关,可能与输入引用num2
有关。这时候关系图就如下所示:

前面也提到,A来源于B,那么A的存活不能超过B,否则,B都没了,A就没有存在的价值了。现在根据上述的关系图,在某些时候,return_ref
会来源于num_ref1
,因此return_ref
的存活不能超过num_ref1
;在另外的某些时候,return_ref
会来源于num_ref2
,因此return_ref
的存活不能超过num_ref2
。既然两种情况都会出现,同时又为了保证无论任何情况下都不会出现悬垂引用 ,我们能很自然的会做出这样的限定:return_ref
不能超过入参num_ref1
和num_ref2
中最短的那个引用的存活时间。
然而,当我们按照上述思路,不加任何的引用关系标记,Rust是编译不通过的。因为我们确实在实际的场景中,会存在这样的逻辑:
rust
fn func(num_ref1: &i32, num_ref2: &i32) -> &i32 {
num_ref1 // <- 确实只与num_ref1有关,跟num_ref2没有任何关系
}
你可能会觉得这可以让Rust编译器来进行分析。然而,方法逻辑的实现千千万万,Rust不能case by case的方式来理解你程序的业务逻辑,进而推断出返回的引用究竟与输入的一堆引用的中哪些有关。
因此,Rust干脆说:"嗨,引用关系你标识出来吧,我只关心引用的存活周期是否满足就好了"。也就是说,Rust编译器在处理引用安全性这方面只做好借用检查与引用生命周期的判断,至于一个方法的输入、输出的引用的关系,程序员标记好即可,这样,Rust编译器只需要关心方法签名就行。
至此,让我们再通过几个例子来巩固目前讲的内容。
示例1:输出引用与输入的n个引用都有关
rust
fn fun1<'a>(num1: &'a i32, num2: &'a i32) -> &'a i32 {
if *num1 > *num2 {
num1
} else {
num2
}
}
这种场景下,我们一般使用同一引用关系标记(这里就是'a
)把它们都"关联"起来。在编译器的视角来看:"噢,这个方法返回的引用与入参的两个引用都有关系,那么作为编译器的我,要保证返回的引用存活时间不能比入参两个引用中最短的那个都存活的更长,这样才能无论哪种情况,都不会出现悬垂引用。"
示例2:输出引用只与输入的某些有关
rust
fn fun2<'a, 'b>(num1: &'a i32, num2: &'b i32) -> &'a i32 {
num1
}
在这个例子中,我们引入了两个引用关系标记('a
、'b
),同时,返回引用标记的是'a
,与输入引用中num1
保持一致。那么编译器在编译过程中进行生命周期检查的时候,其视角就是:"返回引用只与 num1
这个引用参数存在关系,那么我接下来进行检查的时候,只需要检查一下,返回的引用存活周期不要超过num1
这个引用的存活周期即可,其他就不用管了"。同时,这个例子还可以修改为:
rust
fn fun2<'a>(num1: &'a i32, num2: &'_ i32) -> &'a i32 {
num1
}
既然与第二个参数没关系,那第二个参数就写成
'_
吧。
特殊情况
基于前面我们提到的"引用一定有来源",所以一般情况下这种事不会出现的:
rust
fn fun() -> &i32;
方法没有入参,又能返回一个i32
变量的引用,似乎引用的来源只能是方法中局部变量借用而来,但我们知道这是不被允许的。
但我们还可以这样编写:
rust
const NUM: i32 = 5;
fn fun() -> &'static i32 {
&NUM
}
fn main() {
println!("num: {}", fun()); // 可以输出:"num: 5"
}
一个常量,因为其生命周期贯穿整个程序,活得最久,因此我们可以在方法中返回一个常量的引用,但需要注意的是,我们使用'static
这个特殊的生命周期标记。
预告:包含引用的结构体
实际上,除开方法可能会包含输入、输出引用以外。我们还会面临包含引用的结构体。考虑到读者最好对于本文由一个消化,笔者决定将包含饮用的结构体的情况放到下一篇文章中来继续探讨。