听说Rust?那你可能听说过“lifetimes”。它们是标志性特征之一,但有时也是最受诅咒的一种。这不是必需的。
lifetimes都是一个有趣的项目:很多人似乎对它们的日常熟悉,而没有完全理解它们是什么。也许,他们真的是Rust的Monads。让我们谈谈他们是什么,在哪里遇到他们,然后如何掌握他们。
这篇博文仅需要很少的Rust知识。目标受众是一般的好奇程序员或新的Rustaceans。
如果您想了解更多关于Rust或您的公司想要采用它,请记住我们提供量身定制的课程,开发和咨询。
提示
出于本篇博文的目的,我将明确地使用drop来删除所有值。如果不使用它,Rust将在范围的末尾为所有变量插入一个drop语句。由于我们正在谈论掉线和很多想要控制它的例子,我发现在任何地方都明确它是有用的。
生命周期
数据的生命周期唤起了一些直观的理解。它们通常与Rust中的堆栈和堆相关,并且整齐地映射到那些,但确实来自Rust的更为核心的概念:所有权。我们来看一些普通的旧数据结构。
struct Point {
x: i32,
y: i32,
}
fn main() {
let point = Point { x: 0, y: 0 };
drop(point);
}
所有权要求每个数据都由该程序的另一部分独占。在这种情况下,它是let-binding点。所有权结束后,该值将被删除,这意味着它将从内存中删除。这会立即发生。在let-bindings的情况下,这是在范围的末尾。如果删除掉话,代码将是相同的。这给了我们两点:将数据引入程序(初始化)和删除(丢弃)。 Rust中有趣的是这两点总是清晰存在。这些点之间的范围是绑定区域,它的关联值是活动的,它的生命周期。
将此与垃圾收集语言(如Java)进行比较:此处,对任何数据片段的保持引用数决定何时从内存中删除值。这意味着在初始化之后,数据的生命周期取决于它的参考频率。垃圾收集器会定期检查某个值是否仍然有引用它的引用,并且一旦不是这种情况就会将其删除。
引用
在Rust,&采取所谓的借用。这也唤起了直观的理解:拥有的东西,可以借用。
比如,我们想要打印任何一对x和y坐标。我们可以通过编写这样的函数来做到这一点:
fn print_coordinates(x: &i32, y: &i32) {
println!("x: {}, y: {}", x, y);
}
print_coordinates从调用它的范围借用x和y。我们称之为:
fn main() {
let point = Point { x: 0, y: 0 };
print_coordinates(&point.x, &point.y);
drop(point);
}
很简单吧?
让我们重新排序一下:
fn main() {
let point = Point { x: 0, y: 0 };
drop(point);
print_coordinates(&point.x, &point.y);
}
如果Rust允许这样做,我们就会遇到问题。但它没有,编译器会向我们输出:
Compiling playground v0.0.1 (file:///playground)
error[E0382]: use of moved value: `point.x`
--> src/main.rs:15:24
|
13 | drop(point);
| ----- value moved here
drop取得值的所有权(将其从内存中删除)。它将它移出主要来做那件事。在谈到有生命期时,问题也可以这样表达:我们不能打印部分要点,因为它不再存在。
让我们尝试在编译器上作弊:
fn main() {
let point = Point { x: 0, y: 0 };
let x = &point.x;
let y = &point.y;
drop(point);
print_coordinates(x, y);
}
运行
Compiling playground v0.0.1 (file:///playground)
error[E0505]: cannot move out of `point` because it is borrowed
--> src/main.rs:16:10
|
13 | let x = &point.x;
| ------- borrow of `point.x` occurs here
Darn,编译好!但错误消息稍有改变。为什么会这样?编译器知道当你借用东西时,原件必须存活。因此,借款永远不会比原本更长寿。所以它只是连接点:x和y是从点开始的,所以瞬间点不再有效,x和y的使用不再有效。或者,换句话说:如果您以后打算使用借用,则无法移动该点 - 移动可能会使借用无效。他们终身受限。
让我们尝试进一步隐藏编译器的情况:
struct Point {
x: i32,
y: i32,
}
impl Point {
fn x(&self) -> &i32 {
&self.x
}
fn y(&self) -> &i32 {
&self.y
}
}
fn main() {
let point = Point { x: 0, y: 0 };
let x = point.x();
let y = point.y();
drop(point);
print_coordinates(x, y);
}
在主要方面没有借用,至少x和y只返回普通借用。
Compiling playground v0.0.1 (file:///playground)
error[E0505]: cannot move out of `point` because it is borrowed
--> src/main.rs:26:10
|
23 | let x = point.x();
| ----- borrow of `point` occurs here
GRRRRR!同样的问题!聪明的野兽!编译器跟踪我们通过对访问器x()的调用借用了这一点。
那么,让我们说明编译器跟着我们做的事情:
我们构建了point
我们使用point(&self)的引用调用x(),
在x()内部,我们借用了point的子域。
我们通过let x将借用绑定到绑定
我们对y做同样的事情
drop 掉point
编译器捕获我们尝试使用x和y。
它通过一个函数调用汇集了这些动作的链,并且让我们用其中一个值调皮。
在这一点上,我们可能会认为Rust只是分析整个程序,并在组装程序时检查所有借用的有效性。但是整个程序分析成本高昂且难以沟通(“嘿,这种借用不起作用,因为某些地方无处可去”)。
事情并非如此。
标识
我们可以用不同的方式编写实现:
impl Point {
fn x<'point>(&'point self) -> &'point i32 {
&self.x
}
fn y<'point>(&'point self) -> &'point i32 {
&self.y
}
}
您不需要这样做的原因是终身省略。这种情况非常普遍,如果你根本不写任何东西,那就假设了。
那么,这说什么呢?首先,它引入了一个生命周期参数 'point 。因为我们写 &'point self ,我们将输入引用绑定到生命周期。&'point i32 返回类型中的i32点也会将返回引用绑定到该生命周期。
这允许编译器推理 &self.x 。它检查的内容是:“我可以分发一个不长于**'point** 指向 &self,它本身具有生命周期 'point?”答案是肯定的。你被允许通过。
但是生命周期不止于此:它是功能签名的一部分!这意味着,它与调用者进行通信:“如果你调用它,你需要确保我给你的借用不会比我调用x()的东西长。”
第一个结论
这使我们可以在编译代码时最终弄清楚编译器检查的内容。首先,它检查fn x(&self) - >&i32是否合理。我刚刚描述了原因。
现在,在编译main时,它会检查我们是否持有保证。通过调用x(),我们同意在点不再存在之后不使用返回的借位。我们没有。我们试图在使用借用之前放弃这一点。编译器抓住了我们。
理解了x()的接口后,您还将了解大多数集合API的接口。
让我们回到我们写的内容。这是我们的程序:
这使我们可以在编译代码时最终弄清楚编译器检查的内容。首先,它检查fn x(&self) - >&i32是否合理。因为我刚刚描述的原因。
现在,在编译main时,它会检查我们是否持有保证。通过调用x(),我们同意在点不再存在之后不使用返回的借位。我们没有。我们试图在使用借用之前放弃这一点。编译器抓住了我们。
理解了x()的接口后,您还将了解大多数集合API的接口。
让我们回到我们写的内容。这是我们的程序:
- 介绍一个价值
- 获取指向值的字段的指针
- 删除该值
- 取消引用该值
我们要求这个订单。它不起作用,编译器不会尝试使其工作。生命中有很大的收获。
你无法使用lifetimes 编程
我再说一遍:你无法使用lifetimes编程。它们是语言中的一种微小的逻辑语言。他们所做的是证明所有参考文献的有效性,一次一个函数。
lifetimes的聪明之处在于它们的构成和信号。具有生命周期的函数签名不仅仅是“指针输入,指针输出”,它们通信它们彼此之间的关系。
你仍然 - 这是大多数人头撞墙的地方 - 必须遵守这些规则。 Rust是一种命令式语言:你所写的是它的执行方式。终身注释的改变不会使非法情况突然发生。这也是他们的美丽:如果你宣称他们错了,你就不能破坏事物。
仍然:lifetimes是可定义的。实际上,如果您的代码使用借用检查器进行验证,那么它将被编译为好像所有借用都是简单的指针。
问题与解决方案
诸如“如何延长我们的生命周期?”之类的问题。如果你接近它们,很容易回答。做适当的事情以使值更长寿(例如,不要提前放弃)。
我在培训或Hack&Learn中看到的一个常见问题是出现了终身问题,人们开始乱用生命周期语法。这通常是错误的做法。如果您不完全理解编译器调用的内容,那么它总是错误的方法。它可能发现了一个你没有想过的问题,使借用无效。
解决方案通常是回到绘图板并考虑您的逻辑流程。我推荐一张纸或白板。
最后,通常可以选择仅在内存中复制或克隆数据以再次获得所有权。知道何时尝试取回所有权是有道理的,这是一项重要的技能。
活得更久,更繁荣
这已经很长了,我只介绍了一种生命周期语法。这是一个占主导地位的人。还有一个你应该知道的初学者:
struct Wrapper<'wrapped, T: 'wrapped> {
wrapped: &'wrapped T
}
这包括借用任何类型。但是当我们借用时,包装器不能比包装点更长。在这里,'wrapped 完全相同:它以相同的名称绑定在一起。结构体具有多个生命周期绑定非常罕见。的确,我自己从未使用过那种。
注意T:'wrapped 。生命周期就像特征一样是类型界限。这将T限制为任何类型,只要它的寿命长于 'wrapped 。这是必要的,因为指向借用 'wrapped 在比起 'wrapped 的东西更短的东西“显然是不安全的。
当你有一些东西在没有破坏它的情况下操作时,你会经常看到这种符号:经典案例是迭代器。它们终生受限于它们迭代的东西。
使用生命周期来澄清情境
那么我们需要在哪里使用lifetimes?在任何情况都不清楚的地方。
让我们看一下std :: str.split的函数签名,它会拆分一个字符串切片并分出拆分的部分:
fn split<'a, P>(&'a self, pat: P) -> Split<'a, P> where
P: Pattern<'a>,
标准库习惯使用非描述性名称,例如 使用 ‘a 做为生命周期标识 花一点时间来运用你的知识。
让我们按顺序排列:先了解问题,然后再解释生命周期注释。 split中包含2个参数:self,要迭代的切片,以及pat,用于拆分的模式。 Split是一个懒惰的迭代器,当下一个被调用时,会分发指向原始的子句。这一点至关重要:Split不会复制字符串,只需指向原始字符串的相应子字符串即可。为了工作,原件必须活着。但模式拍拍也是如此!每次调用next()时我们都需要它,所以它需要活着!所以情况是:我们有几个相互指向的东西,它们必须在作为一个群体工作时保持不变。
在这种情况下,没有任何删除,但我们的描述很容易:我们只引入一个生命周期’a,它将所有这些值绑定在一起。为了实现这一点,我们只需在所有适当的插槽中使用它。现在,我们将Split绑定到输入和模式。这样可以确保在迭代器(或随后发出的任何值!)仍然存在时不能删除它们。
这种检查完全没有运行时成本,但在编译时完全有效!
结论
我希望我为那些与他们斗争的人们的生活更加清晰。我没有介绍所有生命周期符号和情况,但它们基本上只是同一问题的形式。这可能发生在将来的帖子中。
记住两条黄金法则:
- 在理解编译器调用的内容之前,不要弄乱生命周期语法
- 取得所有权(例如通过克隆或使用Box)不是作弊