生命周期的定义
在 Rust 中,每个引用都有一个生命周期(lifetime),它表示该引用所指向的值在内存中存在的时间段(也可以理解为代码中引用保持有效的行范围)。生命周期确保引用在其整个生命周期内始终有效。它们的存在是为了保证引用的有效性。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
在上面的代码中,函数 longest 有两个输入参数,它们都是字符串切片的引用。该函数的返回值也是一个字符串切片的引用。由于 Rust 高度关注内存安全,因此引入了生命周期来确保引用的有效性。为了验证返回的引用是否有效,我们首先需要确定它的生命周期。但如何确定呢?
Rust 能够自动推断函数参数和返回值的生命周期,这将在后续章节中讨论。然而,这种推断并非普遍适用;Rust 只能在三种特定场景下自动推断生命周期。上述代码不属于这些情况之一。在这种情况下,我们必须手动标注生命周期。如果没有显式标注,Rust 的借用检查器就无法确定返回值的生命周期,因此也无法验证该引用的有效性。
再次审视这段代码,返回值来自函数参数。那么,是否只要确保返回值与参数具有相同的生命周期就足够了呢?至少在函数调用的作用域内,这可以确保引用是有效的。但由于存在两个参数,它们的生命周期可能不同。返回值应该与哪一个参数的生命周期关联呢?解决方案很简单:返回值应具有与生命周期最短的那个参数相同的生命周期。这样,返回值至少在两个参数都有效的时间段内保持有效。因此,上述代码中的 'a 注解意味着返回值的生命周期是两个 'a 参数生命周期的交集。这确保了返回值的生命周期是明确定义的,使 Rust 能够检查其引用是否有效。
生命周期与内存管理
Rust 使用生命周期来管理内存。当一个变量离开作用域时,其所占用的内存会被释放。如果一个引用指向已经被释放的内存,它就变成了悬垂引用(dangling reference),尝试使用它将导致编译错误。
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {}", r);
}
在上面的代码中,变量 x 在离开作用域时被释放,但变量 r 仍然持有对它的引用。这就创建了一个悬垂引用。Rust 编译器会检测到这个问题并给出错误信息。
为什么需要生命周期?
防止悬垂引用并确保内存安全
如前所述,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<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
在上面的代码中,函数 longest 返回一个字符串切片的引用。编译器会检查返回值的生命周期是否有效。如果返回值是一个悬垂引用,编译器将生成错误。
下面再举一个例子,展示 Rust 如何通过生命周期确保内存安全:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
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);
}
在这段代码中,我们定义了一个名为 longest 的函数,它接收两个字符串切片作为参数,并返回一个字符串切片。该函数使用生命周期参数 'a 来指定输入参数与返回值之间的生命周期关系。
在 main 函数中,我们创建了两个字符串变量 string1 和 string2,并将它们的切片传递给 longest。由于 longest 要求输入参数和返回值具有相同的生命周期,编译器会检查这些切片是否满足这一要求。在这里,string2 的生命周期比 string1 短,因此编译器会报错,警告返回值可能包含一个悬垂引用。这种机制确保了内存安全。
生命周期语法
生命周期注解
在函数定义中,可以使用尖括号来标注生命周期参数。生命周期参数名称必须以单引号开头,例如 'a。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
在上面的代码中,函数 longest 有两个输入参数,它们都是字符串切片的引用。这些引用具有生命周期参数 'a,表示它们必须具有相同的生命周期。返回值也具有生命周期参数 'a,意味着其生命周期与输入参数一致。
生命周期省略规则
在许多情况下,Rust 编译器可以自动推断引用的生命周期,从而允许你省略生命周期注解。
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
在这种情况下,编译器无法确定参数和返回值的生命周期。由于返回值依赖于对两个参数的比较,编译器无法推断应使用哪个参数的生命周期。
当编译器无法确定函数返回值的生命周期时,它会发出错误,要求开发者显式指定生命周期参数。例如,我们可以将 longest 函数修改如下:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
这里,生命周期参数 'a 指定输入参数和返回值必须具有相同的生命周期。这使得编译器能够检查函数实参是否满足生命周期约束,并确保返回值不包含悬垂引用。
然而,在许多情况下,Rust 编译器可以自动推断生命周期。Rust 应用了一组生命周期省略规则(lifetime elision rules)来推导正确的生命周期。这些规则如下:
- 每个引用参数都会获得自己的生命周期参数。例如,
fn foo(x: &i32)会被转换为fn foo<'a>(x: &'a i32)。 - 如果一个函数只有一个输入生命周期参数,则该生命周期会被赋予所有输出生命周期参数。例如,
fn foo<'a>(x: &'a i32) -> &i32会被转换为fn foo<'a>(x: &'a i32) -> &'a i32。 - 如果一个函数有多个输入生命周期参数,但其中一个是
&self或&mut self,则返回值会获得self的生命周期。例如,fn foo(&self, x: &i32) -> &i32会被转换为fn foo<'a, 'b>(&'a self, x: &'b i32) -> &'a i32。
这些规则使 Rust 编译器能够在许多情况下自动推断生命周期。但在复杂场景中,编译器可能仍需要显式的生命周期注解。
生命周期的使用场景
函数参数与返回值
当函数的输入参数或返回值包含引用时,必须使用生命周期来确保这些引用的有效性。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
在上面的代码中,函数 longest 有两个输入参数,它们都是字符串切片的引用。这些引用具有生命周期参数 'a,意味着它们必须共享相同的生命周期。函数的返回值也具有生命周期参数 'a,表明其生命周期与输入参数一致。
结构体定义
当结构体包含引用时,必须使用生命周期来确保引用的有效性。
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt { part: first_sentence };
}
在上面的代码中,结构体 ImportantExcerpt 包含一个字符串切片的引用。该引用具有生命周期参数 'a,表示它必须具有明确定义的生命周期。为了防止悬垂引用,该字符串切片必须与结构体具有相同的生命周期,从而确保只要结构体有效,字符串切片也有效。
生命周期的高级用法
生命周期子类型与多态
Rust 支持生命周期子类型(lifetime subtyping)和多态。生命周期子类型意味着一个生命周期可以是另一个生命周期的子集。
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
在这个例子中,第一个输入参数具有生命周期 'a,而第二个输入参数没有显式的生命周期注解。这意味着第二个输入参数可以具有任意生命周期,并且不会影响返回值。
静态生命周期
Rust 有一个特殊的生命周期叫做 'static,它表示一个引用在整个程序运行期间都有效。
let s: &'static str = "I have a static lifetime.";
在这个例子中,变量 s 是一个具有静态生命周期的字符串切片引用,意味着它在整个程序执行期间都保持有效。
生命周期与借用检查器
借用检查器的作用
Rust 的编译器包含一个借用检查器(borrow checker),用于确保所有引用都遵守借用规则。如果违反了这些规则,编译器将生成错误。
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &mut s;
println!("{}, {}, and {}", r1, r2, r3);
}
在这段代码中,变量 s 在同一作用域内同时拥有不可变引用(r1 和 r2)和可变引用(r3)。这违反了 Rust 的借用规则。编译器会检测到此问题并生成错误。
生命周期检查确保引用在其整个存在期间保持有效。然而,即使具有相同的生命周期,也不一定意味着借用是被允许的。Rust 的借用规则同时考虑了生命周期的有效性和可变性约束。
在上面的代码中,尽管 r1、r2 和 r3 具有相同的生命周期,但它们违反了 Rust 的借用规则,因为它们试图在同一作用域内同时创建对同一变量的不可变引用和可变引用。根据 Rust 的借用规则:
- 你可以同时拥有对一个变量的多个不可变引用。
- 你可以拥有一个可变引用,但在拥有可变引用的同时,不能存在任何其他引用(无论是可变还是不可变)。
这确保了内存安全并防止了数据竞争。
生命周期的局限性
尽管 Rust 使用生命周期来管理内存并确保安全性,但生命周期也存在一些局限性。例如,在某些情况下,编译器无法自动推断出正确的生命周期,需要程序员显式标注。这可能会增加开发者的负担并降低代码的可读性。