Rust 系列(一)生命周期(基础理解)
在rust语言中,生命周期的概念和使用是重难点之一。让我们一步步来深入了解这个内容。
一、基本理解
我们先来看下面一段代码:
fn main() {
let x;
{
let y = 5;
x = &y;
} // #1
println!("x: {}", x); // #2
}
编译后输出如下:
error[E0597]: `y` does not live long enough
--> src/main.rs:6:13
|
6 | x = &y;
| ^^ borrowed value does not live long enough
7 | }
| - `y` dropped here while still borrowed
8 | println!("x: {}", x);
| - borrow later used here
从编译错误输出可以看出,变量y
的作用域在#1
处结束,变量x
拥有一个更大的作用域,在#2
处结束,但是x
引用了y
,导致当y
的作用域结束时,x
指向的y
的内存空间被释放,属于无效引用。这个例子其实是一个典型的悬垂引用。rust中的生命周期标注就是要避免这种悬垂引用产生的内存安全问题。
我们来看下面这个具体的生命周期标注的例子是如何避免悬垂引用的。
fn main() {
let x = String::from("xxxx");
let result;
{
let y = String::from("xx");
result = longest(x.as_str(), y.as_str());
}
println!("{}", result);
}
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
首先,直观理解我们可以看出,result最终应该引用的是变量x
,而不是y
,所以不应该出现悬垂引用问题。但是站在函数longest(x: &str, y: &str)
调用方的角度来看,并不能保证调用方在传给该函数参数后,计算结果总是保证变量x
的长度更长,也就是说代码依然可能返回y
。所以编译上述代码时,编译错误是关于longest(x: &str, y: &str)
函数的,main
方法中没有编译错误:
error[E0106]: missing lifetime specifier
--> src/main.rs:11:33
|
11 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ 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 `x` or `y`
help: consider introducing a named lifetime parameter
|
11 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
上述编译错误表明,我们需要显式给longest(x: &str, y: &str)
函数标注<'a>
也就是生命周期标注。该标注表明:两个函数入参和返回值参数,至少都应该活得和a
一样长;而a
具体表明的生命周期应该是等于两个入参和返回值中生命周期最短的那个。
请注意,生命周期标注并不会改变参数的实际生命周期,只是站在函数本身来说,函数的调用方应该保证函数入参和返回值应该具有的生命周期范围;如果不能保证那么编译器将拒绝该函数编译通过,继而无法执行。因此生命周期标注仅仅是为了告诉编译器,帮我检查该函数调用方是否符合函数要求的生命周期而已。
现在我们按照提示修改longest(x: &str, y: &str)
函数如下,main
函数不做修改(请注意,生命周期标注属于参数类型的一部分,所以函数名后需要有类似泛型的标注,来表明'a
是在说明参数类型):
fn main() {
let x = String::from("xxxx");
let result;
{
let y = String::from("xx");
result = longest(x.as_str(), y.as_str());
} // #1
println!("{}", result); // #2
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
再次编译后输出如下错误:
error[E0597]: `y` does not live long enough
--> src/main.rs:6:30
|
6 | result = longest(&x, y.as_str());
| ^^^^^^^^^^ borrowed value does not live long enough
7 | }
| - `y` dropped here while still borrowed
8 | println!("{}", result);
| ------ borrow later used here
此时main
函数会出现编译错误,提示变量y
活得不够久,也就是作用域结束太早,出现了悬垂引用问题。
我们来分析一下:首先longest<'a>(x: &'a str, y: &'a str) -> &'a str
函数签名表明所有入参和返回值的生命周期应该是三者中最小的那个。此时,函数调用方中x
变量在#2
处被释放,y
在#1
处被释放,所以'a
表示的生命周期应该是y
变量的生命周期;同时,因为返回值的生命周期标注也是'a
,所以返回值的生命周期也应该是y
变量的生命周期。但是从代码可以看出,显然result
作为函数的返回值,最终是在#2
处被释放,需要的生命周期大于y
变量的生命周期,所以最终编译器判定该函数调用方的参数和返回值不满足函数体所标注的生命周期,编译失败。
如果我们将main
函数稍作修改如下:
fn main() {
let x = String::from("xxxx"); // #1
let result;
{
let y = String::from("xx");
result = longest(x.as_str(), y.as_str());
println!("{}", result);
} // #2
}
此时函数入参x
和y
以及返回值result
皆在#2
处被释放,实际生命周期跟函数标注所需要的生命周期相同,因此编译通过。
有一个问题需要注意,我们讨论的所谓“生命周期”,指的是变量被释放的位置,而非变量在整个程序中实际存活的时间。例如上述代码中,变量x
是在#1
处被定义,存活时间明显比变量y
和result
要长,但是他们同时在#2
处被释放,因此满足longest
函数的生命周期标注。 另外,我们在讨论生命周期的问题时,关注的是与所有权转移相关的情况,而如果单纯的是引用拷贝,不涉及所有权转移的情况下,生命周期问题不会影响编译过程。例如修改代码如下:
fn main() {
let x = "xxx";
let result;
{
let y = "xx";
result = longest(x, y);
}
println!("{}", result);
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
此时x
和y
都是字符串切片类型&str
,变量result
最终只是获取到了x
或y
字符串切片引用拷贝,不涉及所有权转移,因此上述生命周期的分析过程不再适用,也不会出现编译问题。
二、深入理解生命周期标注
如上文所述,函数的生命周期标注并不会影响参数和返回值的实际生命周期,只是为了让编译器知道调用者应该满足的条件,否则编译失败。
另外,返回值的生命周期来源只能有两种:
- 函数入参的生命周期
- 函数内部自建引用的生命周期
函数入参的生命周期比较好理解,由于函数生命周期标注能够将函数的入参和返回值关联起来,那么当返回值只与某一部分函数入参相关时,我们无需将所有函数入参都进行生命周期标注。例如如下代码:
fn main() {
let x = String::from("xxx");
let result;
{
let y = String::from("xx");
result = longest(x.as_str(), y.as_str());
}
println!("{}", result);
}
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
println!("{}, {}", x, y);
x
}
由于函数longest<'a>(x: &'a str, y: &str)
返回值仅与参数x
有关,参数y
无需进行生命周期标注。
而使用函数内部自建引用的生命周期进行返回值的生命周期标注,就会产生悬垂引用的问题:
fn longest<'a>(x: &str, y: &str) -> &'a str {
let t = String::from("xx");
&t
}
编译错误如下:
error[E0515]: cannot return reference to local variable `t`
--> src/main.rs:13:5
|
13 | &t
| ^^ returns a reference to data owned by the current function
三、结构体生命周期标注
与函数体的生命周期标注类似,结构体也需要对引用类型进行生命周期标注:
struct Foo<'a> {
test: &'a str,
}
fn main() {
let x = String::from("xxx");
let foo = Foo { test: x.as_str() };
println!("{}", foo.test);
}
该生命周期标注表明,引用类型test
不能比结构体引用被释放得更早。如果我们将代码修改如下:
struct Foo<'a> {
test: &'a str,
}
fn main() {
let foo;
{
let x = String::from("xxx");
foo = Foo { test: x.as_str() };
} // #1
println!("{}", foo.test); // #2
}
变量x
在#1
处被释放,而结构体引用foo
在#2
处被释放,x
存活的时间比结构体引用要短,所以编译报错:
error[E0597]: `x` does not live long enough
--> src/main.rs:9:27
|
9 | foo = Foo { test: x.as_str() };
| ^^^^^^^^^^ borrowed value does not live long enough
10 | } // #1
| - `x` dropped here while still borrowed
11 | println!("{}", foo.test); // #2
| -------- borrow later used here
四、生命周期标注消除规则
rust规定每一个引用类型的参数或返回值都必须具有生命周期。但是日常在代码编写过程中,我们并不一定需要显式标注。rust为我们提供了三条消除规则,以便在某些情况下避免手动标注生命周期(生命周期还是有的,只是无需开发者标注而已)。其中第一条是针对输入参数,第二条和第三条针对输出参数。
(一)每一个引用参数都具有各自的生命周期(针对输入参数)
例如函数fn foo(x: &'a str)
只存在一个输入参数,编译器会自动给其标注'a
生命周期;当函数存在多个输入参数,它们各自也会被编译器标注各自的生命周期:fn foo(x: &'a str, y: &'b str)
(二)如果函数只存在一个输入参数,那么该输入参数的生命周期将自动被赋给所有输出参数
例如函数fn foo(x: &'a str) -> &'a str {...}
只存在一个输入参数x
,则其生命周期'a
将被自动赋给输出参数也即返回值。
(三)如果函数存在多个输入参数,但是包含了
&self
或者&mut self
,那么该self
引用的生命周期将被赋给所有输出参数
存在&self
或&mut self
输入参数的函数称为方法(method
)。例如方法fn new(x: &'a self, y: &i32) -> &'a i32 {...}
,输出参数具有输入参数&self
相同的生命周期'a
。此处需要再次强调,这是编译器默认标注的生命周期,假如方法实际的输出参数生命周期跟&self
并不相同,则需要进行显式标注。具体细节在下一节阐述。
五、方法的生命周期标注
由于生命周期属于参数类型的一部分,所以方法的生命周期标注类似于泛型的使用。在泛型中,我们可以做如下定义:
struct Foo<T> {
x: T,
}
impl<T> Foo<T> {
fn x(&self) -> &T {
&self.x
}
}
类似地,当结构体包含了引用类型的参数时,为其实现方法的语法也应该注意生命周期的标注。
struct Foo<'a> {
x: &'a str,
}
impl<'a> Foo<'a> {
fn x(&self, y: &str) -> &str {
println!("{}", y);
&self.x
}
}
但是注意,具体的方法在默认情况下,不需要进行额外的生命周期标注也能通过编译器编译,这是由于上一节中提到的生命周期标注消除规则的第一条和第三条,编译器为方法自动标注了生命周期。具体过程如下:首先根据第一条规则,编译器会给方法的每个输入参数都各自标记生命周期'a
和'b
;然后根据第三条规则,为输出参数标记输入参数&self
的生命周期'a
:
impl<'a> Foo<'a> {
fn x<'b>(&'a self, y: &'b str) -> &'a str {
println!("{}", y);
&self.x
}
}
由于上述标注是编译器在编译时自动进行的,我们实际代码就可以简化如下,方法签名中不包含任何生命周期标注:
impl<'a> Foo<'a> {
fn x(&self, y: &str) -> &str {
println!("{}", y);
&self.x
}
}
但是假如我们实际代码要求返回值的生命周期不为'a
,而是'b
,也就是说现在如果我们显式给输入参数和输出参数标注生命周期,且与编译器默认的标注不同时:
impl<'a> Foo<'a> {
fn x<'b>(&'a self, y: &'b str) -> &'b str {
println!("{}", y);
&self.x
}
}
会得到如下编译错误:
error: lifetime may not live long enough
--> src/main.rs:8:9
|
5 | impl<'a> Foo<'a> {
| -- lifetime `'a` defined here
6 | fn x<'b>(&'a self, y: &'b str) -> &'b str {
| -- lifetime `'b` defined here
7 | println!("{}", y);
8 | &self.x
| ^^^^^^^ method was supposed to return data with lifetime `'b` but it is returning data with lifetime `'a`
|
= help: consider adding the following bound: `'a: 'b`
由于&self
的生命周期为'a
,那么&self.x
的生命周期也为'a
,所以当&self.x
作为方法返回值时,返回值的生命周期也应该为'a
,但是此时我们给方法返回值标注的生命周期为'b
。假如'b
生命周期比'a
长,也就是说返回给方法调用者的&self.x
活得比&self
更久。而当&self
引用被释放后,原本&self.x
也应该不存在,可是现在实际上的引用&self.x
的生命周期'b
依然存活,因为我们假定的是'b
长于'a
,所以这种假定就是不合理的。因此我们需要假定并告知编译器'a
长于'b
。这里有两种写法:
impl<'a: 'b, 'b> Foo<'a> {
fn x(&'a self, y: &'b str) -> &'b str {
println!("{}", y);
&self.x
}
}
其中'a: 'b
表明生命周期'a
长于'b
;另一种写法是:
impl<'a, 'b> Foo<'a>
where
'a: 'b,
{
fn x(&'a self, y: &'b str) -> &'b str {
println!("{}", y);
&self.x
}
}
六、泛型 + 生命周期标注
最后我们来看一个相对综合的例子,结合了泛型和生命周期的标注语法:
fn longest<'a, T>(x: &'a str, y: &'a str, z: T) -> &'a str
where
T: Display,
{
println!("{}", z);
if x.len() > y.len() {
x
} else {
y
}
}
转载自:https://juejin.cn/post/7226689042406424632