生命周期描述的是"引用在什么作用域内是有效的"。
Rust的所有权系统保证:
- 堆上的数据不会被重复释放
- 栈上的引用不会变成悬空引用。
- 生命周期≠存货时间本身
- 生命周期=编译器的约束关系
什么时候需要显式地标注生命周期?
|----------------|----------------|
| 场景 | 是否需要标注 |
| 单个函数内使用引用 | ❌ 不需要 |
| 返回引用 | ✅ 通常需要 |
| 结构体 / 枚举包含引用 | ✅ 必须 |
| 多个输入引用,返回其中某一个 | ✅ 必须 |
12.3.1 悬空引用
生命周期的主要目的是阻止悬空引用,当然,这也是不允许存在的,悬空引用将导致程序引用到非期待的那些数据。参见下面的示例:它包含了内外两层作用域。
rust
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r={r}");
}
外层作用域声明了变量r,但是没有初始化值,内部作用域声明了变量x,赋了初始化值5。在内部作用域r引用了变量x。内部作用域结束,回到外部作用域,我们想要显示变量r的值。编译代码时,编译器报错,因为变量r引用到了不存在的x,x离开了内部作用域已经被销毁了,因此编译器报错,错误信息如下:
bash
error[E0597]: `x` does not live long enough
--> src\main.rs:10:13
|
9 | let x = 5;
| - binding `x` declared here
10 | r = &x;
| ^^ borrowed value does not live long enough
11 | }
| - `x` dropped here while still borrowed
12 | println!("r={r}");
| - borrow later used here
错误信息显示变量x命短。这是因为x的生命周期在内部作用域,r的生命周期在外部作用域,离开了内部作用域,x没了,但是r还可以存活,但是r引用到的x不存在了。因此r不能正确工作了。但是Rust是如何知道的呢?因为它使用了借用检查器。
12.3.2 借用检查器
Rust编译器有一个借用检查器(Borrow Checker),它通过比较作用域来判断所有的借用是否合法。
rust
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
编译器会报错:x does not live long enough。
外部r的生命周期为'a,内部x为'b。因为'b<'a(被引用者必引用者存活时间短),编译不通过。
rust
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {r}"); // | |
// --+ |
}
x的生命周期'b大于变量r的生命周期'a,这意味着r可以引用x,因为Rust知道当x有效时,x也是有效的。
现在你明白了引用的生命周期的范围,并且Rust通过分析生命周期来确保引用的有效性。接下来让我们研究一个函数参数和返回值的泛型类型的生命周期。
12.3.3 在函数中分析的生命周期
我们写一个函数,它的功能是返回两个字符串切片的长度更大的那个。这个函数有两个字符串切片作为参数,并返回其中一个字符串切片。当我哦们实现了longest函数,代码应该返回的字符串是"The longest string is abcd"。
rust
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(str1:&str,str2:&str)->&str{
if str1.len()>str2.len() {
str1
}else{
str2
}
}
如果编译上面的代码,编译器会报错,错误信息如下所示:
bash
|
13 | fn longest(str1:&str,str2:&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 `str1` or `str2`
help: consider introducing a named lifetime parameter
|
13 | fn longest<'a>(str1:&'a str,str2:&'a str)->&'a str{
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `lession12_015` (bin "lession12_015") due to 2 previous errors
错误信息表明返回的类型需要一个泛型生命周期参数,因为Rust不能确定引用是返回x的引用还是y的引用。实际上,我们也不知道,因为if代码块既可能返回x的引用,也可能返回y的引用。
当定义这个函数时,我们并不知道具体需要传入的值,因此我们也不知道执行if的哪个分支。我们也不知道传入参数的生命周期,因此不能确定作用域的范围在哪,返回值的引用在哪是有效的。借用检查器也无法确定它的边界。为了解决此问题,我们可以增加一个泛型生命周期参数来顶一个引用之间的关系来告诉借用检查器可以执行它的分析。
12.3.4 生命周期声明语法
生命周期参数以单引号开头(如:'a,'b),通常是全小写。
语法位置:放在引用运算符&之后,类型之前。
下面是一些示例:i32的无生命周期引用,只读生命周期引用,可变生命周期引用:
- &i32 // 普通引用
- &'a i32 // 只读生命周期引用
- &'a mut i32 // 可变生命周期引用
注意:单独一个变量声明了生命周期并没有太大的含义,因为声明意味着告诉Rust多个带有生命周期声明的引用相互之间的关系。
12.3.5 在函数签名中
为了在函数签名中使用生命周期声明,需要再函数名和参数列表之间增加尖括号,其内填入生命周期参数,就像声明泛型类型参数一样。
我们想要这个签名表示如下的一些限制:返回的引用保持和参数的生命周期一样长。这是参数和返回值之间的关系。我们使用生命周期参数"'a",之后将其增加到每一个引用之前,如下所示:
rust
fn longest<'a>(str1: &'a str, str2: &'a str) -> &'a str {
if str1.len() > str2.len() {
str1
} else {
str2
}
}
这段代码应给可以正常编译并正确运行了。
这个函数签名告诉Rust有一个生命周期叫做"'a",函数的两个参数------两个字符串切片的生命周期都是'a,函数的返回值它的生命周期也是'a,也就是说这仨货活的一样长。这种关系正是我们想要的。
记住当我们在函数签名时指定生命周期,并不会改变传入参数和返回值的生命周期。相反,我们是告诉借用检查器拒绝接受没有指定这些限制的其他值。longest函数并不需要准确的知道x和y能够存活多久。只需要知道满足签名'a的作用域。
当在函数中声明了生命周期,就形成了函数签名,并不需要在函数内进行声明。生命周期声明是函数契约的一部分,如同类型签名一样。包含生命周期契约的函数签名意味着Rust编译器所做的分析可能更加的简单。如果使用了声明的函数在调用时发生了错误,编译器可以更精确的定位到错误的代码和约束条件。相反,如果我们想要对生命周期做出更多的推断,编译器也许指向问题根源更远的地方。
当我们传递具体的引用给longest函数,具体的生命周期被替换为'a,它是x和y生命周期的一部分。换而言之,泛型生命周期'a将是x或y中生命周期的更短者。因为我们声明的返回值的引用也是'a,返回的引用将是x或y中更短的哪个。
看一下下面的代码,传入longest函数的参数有不同的生命周期:
rust
fn main() {
let string1 = String::from("long string is long!");
{
let string2 = String::from("xyz");
let result = longest(string1.as_str(),string2.as_str());
println!("The longest string is {result}");
}
//println!("The longest string is {result}");
}
在这个示例中,string1一直有效,直到代码的结尾处。string2在内部大括号结尾处失效。result的生命周期和string2一直,在内部大括号结尾处失效。编译这段代码可以看到程序正常执行,直到控制台显示:
rust
The longest string is long string is long!
接着,我们测试验证一下result的生命周期是两个参数中更短的那个。我们将result的声明移动到外部作用域中,赋值操作还保留在内部作用域中,最后显示结果在外部作用域中。
rust
fn main() {
let string1 = String::from("long string is long!");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(),string2.as_str());
//println!("The longest string is {result}");
}
println!("The longest string is {result}");
}
编译这段代码,系统报错:
rust
|
17 | let string2 = String::from("xyz");
| ------- binding `string2` declared here
18 | result = longest(string1.as_str(),string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
19 | //println!("The longest string is {result}");
20 | }
| - `string2` dropped here while still borrowed
21 | println!("The longest string is {result}");
| ------ borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `lession12_017` (bin "lession12_017") due to 1 previous error
错误信息显示,result在println宏中是有效的,string2需要将生命周期延长至外部的作用域。Rust可以知道这些是因为函数参数和返回值的生命周期声明是'a。
在我们看来返回的是string1的引用,string1的生命周期一直到代码的结尾处,因此result应该可以被打印出来。但是Rust不是这么干的,它会取得两个参数中生命周期较短的那个作为返回值的生命周期,因此返回的是string2的生命周期,值是string1的引用,因此编译器会报错,因为在println时,result已经不在有效了。
12.3.6 关系
你需要指定生命周期参数的方式取决于你的函数要做什么。例如,如果longest只返回第一个参数,而不是最长的字符串的切片,你就不需要指定第二个参数的生命周期参数。修改后的代码如下所示:
rust
fn longest<'a>(str1: &'a str, str2: & str) -> &'a str {
str1
}
我们指定了str1的生命周期参数和返回值的生命周期参数,没有指定y,因为它和x或返回值没有任何关系。
当从函数中返回一个引用时,返回值的生命周期必须匹配其中一个参数的生命周期。如果没有引用任何一个参数,他就必须引用到函数内创建的某个值。但是,这会引起悬垂引用的问题,因为值离开了函数就相当于离开了作用域尝试下面的代码,看看编译器是否能够通过。
rust
fn longest<'a>(str1: &'a str, str2: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}
从上面的代码可以看出,即使定义了生命周期参数'a给返回的类型,编译器依然会报错,因为返回值的生命周期和参数的生命周期完全没有关系。编译器的错误信息如下:
rust
8 | result.as_str()
| ------^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `result` is borrowed here
问题出在result的作用域范围在函数内,离开了函数,result将会被销毁掉。目前还没有方法可以设置生命周期参数来改变悬垂引用,而且Rust也不会让我们创建悬垂引用。
此种情况下,如果要修复这个问题,可以返回拥有所有权的数据而不是它的引用,以便调用它的函数可以清除这个值。
最终,生命周期语法是用于连接参数和返回值的生命周期。一旦创建了连接,Rust就会有足够的信息来允许内存安全的操作,同时拒绝可以创建悬垂指针的操作,否则会违反内存安全。
12.3.7 在结构定义中使用生命周期
迄今为止,结构体内成员的定义都保留了所有权,我们也可以定义使用引用的结构体,但是在这种情况下,我们需要增加生命周期声明在每一个引用的成员项上。下面的示例中展示了只有一个字符串切片的成员项的结构体:
rust
#[derive(Debug)]
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
println!("{i:?}");
}
这个结构体只有一个字段,它是字符串切片类型,它也是一个引用。当使用泛型数据类型,我们就在尖括号内声明了一个有泛型生命周期参数的名称,然后就可以在结构体内使用这个生命周期参数。这个声明意味着ImportantExcerpt的实例不能比它的成员项的引用值活的更久。
主函数创建了ImportantExcept的实例,它保存了novel字符串一部分的切片,novel在ImportanExcept之前创建,且movel直到ImportantExcept离开了作用域也没有脱离它自己的作用域,因此ImportantExcept中的引用也一直是有效的。
12.3.8 生命周期声明的省略
我们已经获悉函数或结构体中的每一个引用的生命周期都需要进行指定生命周期参数。但是下面的代码并没有使用生命周期声明:
rust
fn first_word(s: &str) -> &str {
let Bytes = s.as_bytes();
for (i, &item) in Bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let reslut = first_word("Hello, world!");
println!("{reslut}");
}
上面的代码不用进行生命周期声明是有历史原因的:在早期的版本,这段代码是不能编译的,因为引用项没有进行显式的生命周期声明。那时函数签名如下所示:
rust
fn first_word<'a>(s: &'a str) -> &'a str {...}
在写了很多类似的代码后,Rust团队发现了其中的必然性和确定性,编译器可以推断出这些情况,因此在相同的情景下不再需要进行显式的声明了。随着时间的推移,会发现更多的确定模板,然后它们会被加入到编译器内,在未来,可能越来越少需要进行显式的声明。
这种写入Rust引用分析的模板称之为"生命周期简写规则"。这些并不是程序员需要遵守的规则;这是变一下需要考虑的一组特殊的情况,如果你的代码符合这些情况,你就不需要显示的声明生命周期。
省略规则并不能提供完整的推断时,即Rust应用这些规则还不能确定引用应该使用什么样的生命周期,编译器则直接报错,让你自己去增加生命周期声明。
在函数或方法的参数上的生命周期称之为输入生命周期,返回值上的生命周期声明称之为输出生命周期。
当没有显示的进行声明时,编译器使用三个规则来搞清楚引用的生命周期。
第一个规则时编译器分配生命周期参数给每一个使用引用的参数。换而言之,只有一个参数只有一个生命周期:fn foo<'a>(x: &'a i32);有两个参数的函数,它会有两个独立的生命周期参数:fn foo<'a, 'b>(x: &'a i32, y: &'b i32);以此类推。
第二个规则是:如果只有一个输入参数的生命周期,则这个生命周期会被分配给所有的输出生命周期参数:fn foo<'a>(x: &'a i32) -> &'a i32。
第三个规则是:如果有多个生命周期参数,但是其中之一是&self或&mut self,则self的生命周期会被分配给所有的输出生命周期参数。第三个规则使得方法读写更加简洁,因为只需要更少的符号。
回过头来再看下面这段代码,我们使用这些规则来搞清楚first_word函数签名中引用的生命周期,没有使用声明的生命周期函数签名如下:
rust
fn first_word(s: &str) -> &str {...}
当编译器应用了第一条规则后,它会给每一个参数和返回值分配它自己的生命周期('a)。如下所示:
rust
fn first_word<'a>(s: &'a str) -> &str {...}
因为只有一个输入参数的生命周期,正好适配第二条规则,第二条规则会吧输入参数的生命周期分配给输出参数,因此函数签名如下所示:
rust
fn first_word<'a>(s: &'a str) -> &'a str {...}
现在函数签名中的所有引用项都分配了生命周期,编译器将会继续它的分析直到不需要给函数签名继续标注生命周期的声明。
接下来,我们再看另外一个示例,这次使用longest函数,当它没有标注生命周期参数时:
rust
fn longest(x: &str, y: &str) -> &str {...}
当我们匹配第一条规则时,每一个参数都有它自己的生命周期参数,它有两个输入参数,因此它有两个生命周期参数:
rust
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {...}
我们可以看到第二条规则不适用,因为它有不止一个生命周期参数,第三条规则也不适用,因为longest是函数,不是方法,因此他也没有self的参数。匹配了三条规则后,仍然不能搞清楚输出参数的生命周期。这就是为什么编译器会报错。编译器匹配了三条省略规则后,依然不能搞清楚输出参数的生命周期是那个。
因为第三条规则只适用于方法签名,接下来我们将看看这种情况下的生命周期,用以了解第三条规则意味着在方法签名中不用频繁的标注生命周期。
12.3.9 在方法定义中的生命周期
在结构体中的实现方法使用生命周期和使用泛型参数的语法相同,声明和使用生命周期参数的位置取决于它们和结构体成员或方法参数和返回值的关联。
生命周期给结构体成本的命名总是需要再impl关键字之后进行声明,也需要在结构体名称之后也需要进行声明,因为它们是结构体类型的一部分。
在impl内部的方法签名中,引用也许关系到结构体内部引用成员的生命周期,也许是独立存在的。此外,生命周期省略规则通常使得不需要在方法签名中使用生命周期声明。查看ImportantExcerpt结构体的实例。
首先,定义了一个叫level的方法,他只有一个参数self,并返回一个i32类型的值,它没有引用任何东西:
rust
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
生命周期参数在impl之后,类型名称之后需要用到它,但是因为省略规则,我们需要对self进行生命周期的声明。
下面是使用了规则三的代码:
rust
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &st) -> &str {
println!("Attention please: {announcement}");
self.part
}
}
因为有两个参数,因此Rust给这两个参数单独的生命周期,又因为第一个参数时self,返回值给了self的生命周期,所有的生命周期都给予了分配。
12.3.10 静态生命周期
还有一个特殊的生命周期称之为'static,它表明被声明的引用可以存活于整个程序的生存期。'static的生命周期可以如下所示被标注:
rust
let s: &'static str = "I have a static lifetime.";
这段文字直接被存储到程序的二进制执行文件中,因此它是一直有效的,也因此它被称之为'static。
你也许在错误信息中被建议使用'static生命周期。但是使用它之前,需要仔细考虑一下是否真的会将其保留到整个程序的生存期内。大部分出现这个建议时,是因为尝试创建悬垂引用,或者没有匹配正确的生命周期。在这种情况下,解决方案是修复那些问题,而不是定义'static生命周期。
12.3.11 泛型类型参数,Trait限定和生命周期
让我们将泛型类型参数,trait限定和生命周期在一个函数内展示:
rust
use std::fmt::Display;
fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
where
T: Display,
{
println!("Announcement! {ann}");
if x.len() > y.len() {
x
} else {
y
}
}
这个longest函数的功能是返回两个字符串切片中的更长的那个,但是它有一个额外的参数ann,它的类型是泛型T,它可以是任何类型,但是该类型必须实现的Display特性,因为该类型被where所限定。这个额外的参数将会在println宏中被显示在屏幕上,因此它实现Display接口是必须的。生命周期也是泛型,它使用参数'a进行声明,它和泛型T一同在函数名之后的尖括号内进行声明。
12.3.12 总结
本章涉及到很多内容,现在你了解了泛型参数、特性(trait)和特性限定(trait bound),泛型生命周期参数,你已经可以写出在不同场景下的无冗余的代码。泛型是你可以将自己的代码适用于不同的类型。特性和特性限定保证你的代码即使是泛型也可以具有所需要的行为。你已经了解到如何使用生命周期声明来确保这种具有灵活性的代码不会出现悬垂引用。所有这些过程都发生在编译期,从而不会影响到实时执行的性能。
无论你信不信,还有很多的相关的内容需要学习。后面的章节还会以另一种方式来使用特性。它会用到更复杂的场景,并且涉及到在非常高级的场景下使用生命周期声明。