Rust 生命周期背后的直觉是什么?
What is the intuition behind Rust lifetimes?
我已经从许多不同的资源中阅读了 Rust 生命周期的概念,但我仍然无法弄清楚它背后的直觉。考虑这段代码:
#[derive(Debug)]
struct Example<'a> {
name: &'a str,
other_name: String,
}
fn main() {
let a: &'static str = "hello world";
println!("{}", a);
let b: Example = Example {
name: "Hello",
other_name: "World".into(),
};
println!("{:?}", b);
}
在我看来,Rust 中的所有事物都有生命周期。在 let a: &'static str = "hello world";
行中,变量 a
一直保持活动状态直到程序结束,并且 'static
是可选的,即 let a: &str = "hello world";
也是有效的。我的困惑是当我们向其他人添加自定义生命周期时,例如 struct Example
.
struct Example<'a> {
name: &'a str,
other_name: String,
}
为什么我们需要为它附加生命周期 'a
?为什么我们在 Rust 中使用生命周期的简化和直观推理是什么?
In this line let a:&'static str = "hello world";
the variable a
is kept alive till the end of the program
不,事实并非如此。 a
是一个 reference,即它 refer 一些字符串数据。该字符串数据是 'static
,这意味着它在程序结束时仍然有效。然而 a
不需要在程序结束时一直存在(在这种情况下恰好是,因为它是 main
函数中声明的第一个值,但这只是巧合)。
当一个 struct
有生命周期时,这通常意味着它 借用 另一个值,并且它只能在该值存在时使用。对于 example:
struct Example<'a> {
name: &'a str,
other_name: String,
}
fn main() {
let s: String = "hello world".to_string();
let example = Example {
name: &s[..],
other_name: "".to_string(),
};
drop(s); // the lifetime of s ends here
// however, example borrows s, therefore its lifetime
// is tied to s. This means that example can't be used
// after s was dropped. Therefore, the following line
// will trigger a compiler error:
println!("{}", example.name);
}
实际错误信息中的推理略有不同,但我认为还是很容易理解的:
error[E0505]: cannot move out of `s` because it is borrowed
--> src/main.rs:12:10
|
9 | name: &s[..],
| - borrow of `s` occurs here
...
12 | drop(s); // the lifetime of s ends here
| ^ move out of `s` occurs here
...
18 | println!("{}", example.name);
| ------------ borrow later used here
错误信息指出 example
借用了 s
,在 s
被删除后使用。这是被禁止的,因为 example
的生命周期不能超过 s
.
如果您有垃圾回收语言的背景(我看您对 Python 很熟悉),整个生命周期的概念确实会让人感到非常陌生。即使是相当高级的内存管理概念,例如堆栈和堆之间的区别或何时发生分配和释放,也可能难以掌握:因为这些是垃圾收集对您隐藏的细节(有代价)。
另一方面,如果您来自必须自己管理内存的语言(例如 C++),那么这些概念您已经很熟悉了。我的理解是,Rust 的主要设计目的是在这种“系统语言”space 中竞争,同时引入策略(如借用检查器)来帮助避免大多数内存管理错误。因此,许多文档都是为这些读者编写的。
在你真正理解“生命周期”之前,你应该先了解栈和堆。生命周期问题主要出现在(或可能)堆上的东西上。 Rust 的所有权模型最终是关于将每个堆分配与特定的堆栈项相关联(可能通过其他中间堆项),这样当一个项从堆栈中弹出时,所有关联的堆分配都会被释放。
然后问问自己,每当您有对某物的引用(即内存地址)时:当使用引用时,该某物是否仍位于内存中的预期位置? 它可能不是的一个原因是因为它在堆上并且它拥有的项目已从堆栈中弹出,导致它被删除并释放其内存分配;另一个可能是因为它已重新定位到内存中的某个其他位置(例如,它是一个 Vec
超出了其先前分配中可用的 space )。即使只是数据的突变也可能违反对那里保存的内容的期望,所以它们也不允许在你下面发生。
最重要的是 Rust 的生命周期对这个问题没有任何影响:也就是说,它们从不影响如何long something remains at a memory location — 它们仅仅是我们对该问题的答案所做的断言,如果这些断言无法验证,代码将无法编译。
所以,以你的例子为例:
struct Example<'a>{
name: &'a str,
other_name: String,
}
假设我们创建了这个结构的一个实例:
let foo = Example { name: "eggyal", other_name: String::from("Eka") };
现在假设这个 foo
,一个堆栈项,位于地址 0x1000
。深入研究典型 64 位系统的实现细节,我们的内存可能看起来像这样:
...
0x1000 foo.name#ptr = 0xabcd
0x1008 foo.name#len = 6
0x1010 foo.other_name#ptr = 0x5678
0x1018 foo.other_name#cap = 3
0x1020 foo.other_name#len = 3
...
0x5678 'E'
0x5679 'k'
0x567a 'a'
...
0xabcd 'e'
0xabce 'g'
0xabcf 'g'
0xabd0 'y'
0xabd1 'a'
0xabd2 'l'
...
请注意,在 foo
中,name
仅由一个指针和一个长度组成;而 other_name
还有一个容量(在本例中,容量与其长度相同)。那么&str
和String
有什么区别呢?这完全是关于 管理相关内存分配的责任所在。
由于String
是拥有的,堆分配的字符串,foo.other_name
“拥有”(负责)其关联内存分配——因此,当 foo
被删除时(例如,因为它从堆栈中弹出),Rust 将确保地址 0x5678
处的那三个字节被释放并返回给分配器(最终发生通过实施 std::ops::Drop
)。拥有分配也意味着 String
可以安全地改变内存,将值重新定位到另一个地址等(前提是它当前没有在其他地方借出)。
相比之下,0xabcd
的内存分配不属于 foo.name
“拥有”——我们说它是“借用”分配——但是如果 foo.name
不管理分配,如何确定它包含它应该包含的内容?好吧,我们程序员向 Rust 承诺,我们 将在借用期间保持内容有效(我们给它起一个名字,在你的情况下 'a
:&'a str
意味着持有 str
的内存被借用了整个生命周期 'a
),并且借用检查器确保我们信守诺言。
但是我们承诺生命周期 'a
会有多长时间?好吧,Example
的每个实例都会有所不同:我们承诺 "eggyal"
的时间段将在 0xabcd
,而 foo
很可能完全不同于我们承诺某个其他实例的 name
值将在其地址的时间段。所以我们的生命周期 'a
是 Example
的 参数:这就是为什么它被声明为 Example<'a>
.
幸运的是,我们永远不需要明确定义我们的生命周期实际持续多长时间,因为编译器知道一切的实际生命周期,只需要检查我们的 断言 是否成立:在我们的示例中,编译器确定提供的值 "eggyal"
是字符串文字,因此类型为 &'static str
,因此在 'static
生命周期内将位于其地址 0xabcd
;因此在 foo
的情况下,'a
被允许是“任何生命周期直至并包括 'static
”;在@Aloso 的回答中,您可以看到一个生命周期不同的示例。然后,无论在哪里使用 foo
,都可以根据这个确定的界限检查和验证该使用站点的任何生命周期断言。
这需要一些时间来适应,但我发现像这样描绘内存布局并问自己“何时释放内存分配?”有助于我理解生命周期在我的代码中(有时我需要考虑什么时候值可能会被重定位或改变,但仅仅考虑释放通常就足够了——而且通常更容易理解)。
我已经从许多不同的资源中阅读了 Rust 生命周期的概念,但我仍然无法弄清楚它背后的直觉。考虑这段代码:
#[derive(Debug)]
struct Example<'a> {
name: &'a str,
other_name: String,
}
fn main() {
let a: &'static str = "hello world";
println!("{}", a);
let b: Example = Example {
name: "Hello",
other_name: "World".into(),
};
println!("{:?}", b);
}
在我看来,Rust 中的所有事物都有生命周期。在 let a: &'static str = "hello world";
行中,变量 a
一直保持活动状态直到程序结束,并且 'static
是可选的,即 let a: &str = "hello world";
也是有效的。我的困惑是当我们向其他人添加自定义生命周期时,例如 struct Example
.
struct Example<'a> {
name: &'a str,
other_name: String,
}
为什么我们需要为它附加生命周期 'a
?为什么我们在 Rust 中使用生命周期的简化和直观推理是什么?
In this line
let a:&'static str = "hello world";
the variablea
is kept alive till the end of the program
不,事实并非如此。 a
是一个 reference,即它 refer 一些字符串数据。该字符串数据是 'static
,这意味着它在程序结束时仍然有效。然而 a
不需要在程序结束时一直存在(在这种情况下恰好是,因为它是 main
函数中声明的第一个值,但这只是巧合)。
当一个 struct
有生命周期时,这通常意味着它 借用 另一个值,并且它只能在该值存在时使用。对于 example:
struct Example<'a> {
name: &'a str,
other_name: String,
}
fn main() {
let s: String = "hello world".to_string();
let example = Example {
name: &s[..],
other_name: "".to_string(),
};
drop(s); // the lifetime of s ends here
// however, example borrows s, therefore its lifetime
// is tied to s. This means that example can't be used
// after s was dropped. Therefore, the following line
// will trigger a compiler error:
println!("{}", example.name);
}
实际错误信息中的推理略有不同,但我认为还是很容易理解的:
error[E0505]: cannot move out of `s` because it is borrowed
--> src/main.rs:12:10
|
9 | name: &s[..],
| - borrow of `s` occurs here
...
12 | drop(s); // the lifetime of s ends here
| ^ move out of `s` occurs here
...
18 | println!("{}", example.name);
| ------------ borrow later used here
错误信息指出 example
借用了 s
,在 s
被删除后使用。这是被禁止的,因为 example
的生命周期不能超过 s
.
如果您有垃圾回收语言的背景(我看您对 Python 很熟悉),整个生命周期的概念确实会让人感到非常陌生。即使是相当高级的内存管理概念,例如堆栈和堆之间的区别或何时发生分配和释放,也可能难以掌握:因为这些是垃圾收集对您隐藏的细节(有代价)。
另一方面,如果您来自必须自己管理内存的语言(例如 C++),那么这些概念您已经很熟悉了。我的理解是,Rust 的主要设计目的是在这种“系统语言”space 中竞争,同时引入策略(如借用检查器)来帮助避免大多数内存管理错误。因此,许多文档都是为这些读者编写的。
在你真正理解“生命周期”之前,你应该先了解栈和堆。生命周期问题主要出现在(或可能)堆上的东西上。 Rust 的所有权模型最终是关于将每个堆分配与特定的堆栈项相关联(可能通过其他中间堆项),这样当一个项从堆栈中弹出时,所有关联的堆分配都会被释放。
然后问问自己,每当您有对某物的引用(即内存地址)时:当使用引用时,该某物是否仍位于内存中的预期位置? 它可能不是的一个原因是因为它在堆上并且它拥有的项目已从堆栈中弹出,导致它被删除并释放其内存分配;另一个可能是因为它已重新定位到内存中的某个其他位置(例如,它是一个 Vec
超出了其先前分配中可用的 space )。即使只是数据的突变也可能违反对那里保存的内容的期望,所以它们也不允许在你下面发生。
最重要的是 Rust 的生命周期对这个问题没有任何影响:也就是说,它们从不影响如何long something remains at a memory location — 它们仅仅是我们对该问题的答案所做的断言,如果这些断言无法验证,代码将无法编译。
所以,以你的例子为例:
struct Example<'a>{
name: &'a str,
other_name: String,
}
假设我们创建了这个结构的一个实例:
let foo = Example { name: "eggyal", other_name: String::from("Eka") };
现在假设这个 foo
,一个堆栈项,位于地址 0x1000
。深入研究典型 64 位系统的实现细节,我们的内存可能看起来像这样:
...
0x1000 foo.name#ptr = 0xabcd
0x1008 foo.name#len = 6
0x1010 foo.other_name#ptr = 0x5678
0x1018 foo.other_name#cap = 3
0x1020 foo.other_name#len = 3
...
0x5678 'E'
0x5679 'k'
0x567a 'a'
...
0xabcd 'e'
0xabce 'g'
0xabcf 'g'
0xabd0 'y'
0xabd1 'a'
0xabd2 'l'
...
请注意,在 foo
中,name
仅由一个指针和一个长度组成;而 other_name
还有一个容量(在本例中,容量与其长度相同)。那么&str
和String
有什么区别呢?这完全是关于 管理相关内存分配的责任所在。
由于
String
是拥有的,堆分配的字符串,foo.other_name
“拥有”(负责)其关联内存分配——因此,当foo
被删除时(例如,因为它从堆栈中弹出),Rust 将确保地址0x5678
处的那三个字节被释放并返回给分配器(最终发生通过实施std::ops::Drop
)。拥有分配也意味着String
可以安全地改变内存,将值重新定位到另一个地址等(前提是它当前没有在其他地方借出)。相比之下,
0xabcd
的内存分配不属于foo.name
“拥有”——我们说它是“借用”分配——但是如果foo.name
不管理分配,如何确定它包含它应该包含的内容?好吧,我们程序员向 Rust 承诺,我们 将在借用期间保持内容有效(我们给它起一个名字,在你的情况下'a
:&'a str
意味着持有str
的内存被借用了整个生命周期'a
),并且借用检查器确保我们信守诺言。但是我们承诺生命周期
'a
会有多长时间?好吧,Example
的每个实例都会有所不同:我们承诺"eggyal"
的时间段将在0xabcd
,而foo
很可能完全不同于我们承诺某个其他实例的name
值将在其地址的时间段。所以我们的生命周期'a
是Example
的 参数:这就是为什么它被声明为Example<'a>
.幸运的是,我们永远不需要明确定义我们的生命周期实际持续多长时间,因为编译器知道一切的实际生命周期,只需要检查我们的 断言 是否成立:在我们的示例中,编译器确定提供的值
"eggyal"
是字符串文字,因此类型为&'static str
,因此在'static
生命周期内将位于其地址0xabcd
;因此在foo
的情况下,'a
被允许是“任何生命周期直至并包括'static
”;在@Aloso 的回答中,您可以看到一个生命周期不同的示例。然后,无论在哪里使用foo
,都可以根据这个确定的界限检查和验证该使用站点的任何生命周期断言。
这需要一些时间来适应,但我发现像这样描绘内存布局并问自己“何时释放内存分配?”有助于我理解生命周期在我的代码中(有时我需要考虑什么时候值可能会被重定位或改变,但仅仅考虑释放通常就足够了——而且通常更容易理解)。