• 理解Rust中的生命周期
  • 发布于 2个月前
  • 107 热度
    0 评论
  • 孤影人
  • 0 粉丝 17 篇博客
  •   
当人们说学习Rust很难时,他们通常会提到生命周期。然而,很多人95%的代码,没有任何生命周期注释!本文中的建议主要针对常见场景。在某些情况下,你确实需要担心生命周期。如果在嵌入式系统、实时应用程序或其他性能关键型环境中,可能会遇到必须处理生命周期的场景。

什么是生命周期?
生命周期告诉编译器引用的有效期。
fn foo<'a>(bar: &'a str) {
    // ...
}
这里,这段程序告诉编译器:“这个引用 bar 在生命周期'a内有效。“ 然后,编译器将检查该引用在生命周期结束后是否确实没有被使用。Rust有一个生命周期省略的概念,这意味着在很多情况下不必编写生命周期注释。编译器将自动推断它们。

生命周期规则概述:
1,对函数的每个输入引用都有不同的生命周期。
2,如果只有一个输入引用,那么它的生命周期将应用于所有输出引用。
3,如果有多个输入引用,但其中一个是&self或&mut self,则self的生命周期应用于所有输出引用。

这意味着只有当有不止一个输入引用并且它们都不是&self或&mut self时,才需要自己明确写出生命周期。事实证明,生命周期在Rust中无处不在,但大多数情况下它们只是隐式的。生命周期是会传染的!

生命周期的问题在于它们会像病毒一样在代码库中传播。一旦添加了生命周期注释,就必须将其添加到所有调用它的函数中,以及所有调用这些函数的函数中,以此类推。

例如,假设你有一个包含String的结构体:
struct Foo {
    bar: String
}
为避免内存分配,通过使用&str来优化它:
struct Foo<'a> {
    bar: &'a str
}
现在必须给所有使用Foo的函数添加生命周期注释:
fn foo<'a>(foo: &'a Foo) {
    // ...
}
这种情况很快就会失控,函数签名会更复杂,更难以理解函数的作用。重构变得更加困难,因为必须保留生命周期注释。生命周期不是免费的!这很容易让你陷入困境,很难对代码的工作方式做出根本的改变。因此,明确的生命周期应该被视为最后的手段,因为它们增加了技术债务,并疏远了初学者。现在,我认为在没有充分理由的情况下过早地向一段代码中添加生命周期注释是一种反模式。

实际上只有两种情况需要添加生命周期注解:
1,存在性能瓶颈:在热路径中发现了一段运行缓慢的代码,并对其进行了分析,确定瓶颈确实是由于内存分配造成的。在这种情况下,使用生命周期来避免分配是有意义的。(另一种方法是重构代码,使用更好的算法,首先避免热路径。)

2,所依赖的代码需要生命周期注释:对此无能为力,只能寻找不需要生命周期的替代方案。

也不要害怕生命周期
如果依赖于需要生命周期注释的库,该怎么办?Servo的html5ever就是一个例子,这是一个用Rust编写的高性能HTML解析器。它广泛使用生命周期来确保内存安全和性能。当使用这样的库时,无论喜欢与否,都必须处理生命周期问题。了解生命周期的基本知识可以帮助你更有效地应对这些情况。记住,生命周期是用来帮助编写安全和高效的代码的,它们不是什么可怕的东西,而是Rust工具包中的一个强大工具。

一个例子
让我们看一个需要显式添加生命周期的实际示例。我们来看这个函数,它一个返回两个字符串切片中最长的那个。
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
如果我们试图编译它,我们会得到一个错误:
error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:33
  |
1 | 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
  |
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++
问题出在哪里?

设身处地为编译器着想
为了理解这个错误,假设你是Rust编译器。你的工作是确保引用始终有效,并且没有引用的生命周期超过它所指向的数据。在本例中,函数longest接受两个字符串切片,并返回其中一个。

作为编译器,会看到函数签名承诺返回一个引用(&str),但它没有指定它对应于哪个输入引用(x或y)。我们面临一个难题:返回的字符串是否和x或y一样长?这取决于两个字符串中哪一个更长,这只能在运行时确定。

如果不了解这些,就无法确认返回的引用是否有效:需要指定输入和输出之间的关系来保证这一点。如果选择了错误的引用,可能会得到一个悬空引用。这种模糊性使得编译器无法保证返回引用的安全性。需要向编译器提供更多信息来解决这种歧义。

为了解决这个问题,我们需要在函数签名中添加一个生命周期参数:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
通过添加'a,我们指定输入引用x和y具有相同的生存期'a,并且返回的引用也将具有此生生命周期。这使编译器清楚地知道,只要两个输入引用都有效,返回的引用就保证有效。现在,编译器可以安全地检查并确保在整个代码中正确使用了引用。

生命周期作为传达意图的一种方式
生命周期看起来可怕的部分原因是它们通常被命名为“'a”、“'b”或“'c”。这使它们看起来像是某种学术的数学符号。不过,这只是一种惯例,让它们写起来更快,而且想出更好的名字很难!将生命周期视为“标签”可能会有所帮助——可以随意命名它们,以使代码更易于理解。

例如:
// 现在很明显,处理过的数据与输入数据绑定在一起
fn process_input<'input>(data: &'input str) -> &'input str {
    // ...
}
如果需要处理多个引用,或者希望更清楚地表达引用的来源,那么命名生命周期将非常有用。Serde在它的反序列化特性中使用了这种技术,效果非常好:
fn deserialize<'de, D>(deserializer: D) -> Result<Self, D::Error>
where
    D: Deserializer<'de>,
{
    // ...
}
这里,'de表示“此生命周期绑定到反序列化器”。突然间,这种语法变得更有意义了!把生命周期看作是类型签名:大多数时候,它们可以被推断出来,但有时为了避免错误,把它们拼出来会更清楚。此外,这些显式注释还可以作为有用的文档。

使用智能指针来避免生命周期
你可能会认为,如果想避免不必要的复制,必须引入生命周期;例如,在处理大量数据时。但是,也可以使用智能指针,如Rc(引用计数)或Arc(原子引用计数)来共享数据的所有权。这样,就不需要担心显式生命周期,同时使克隆数据的成本接近于零。在很多情况下,这是一种很好的权衡。
// 只分配一次
let hello = Rc::new("Hello".to_string());

// 这是一个廉价的操作
let hello2 = hello.clone();
看下面这个例子:
use std::rc::Rc;

fn longest(x: Rc<String>, y: Rc<String>) -> Rc<String> {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
这只是一个愚蠢的例子,但它展示了如何通过使用引用计数指针来避免生命周期。诀窍在于,只需为字符串分配一次内存,从那时起,引用计数就很廉价了。这样,在许多情况下,可以避免生命周期的复杂性。

总结
Rust中的所有引用(借用)都有生命周期。编译器会跟踪它们,不管它们是否显式。在大多数他情况下,编译器为你做了很好的推断生命周期的工作,应该只在你有很好的理由需要显式注释的时候添加它们,例如,当编译器明确告诉你这样做的时候,当优化性能的时候,或者当你想明确地描述代码与代码之间的关系的时候。

用户评论