4.1 生命周期
深入生命周期
1. 不太聪明的生命周期检查
在 Rust 语言学习中,一个很重要的部分就是阅读一些你可能不经常遇到,但是一旦遇到就难以理解的代码,这些代码往往最令人头疼的就是生命周期,这里我们就来看看一些本以为可以编译,但是却因为生命周期系统不够聪明导致编译失败的代码。
1.1 例子 1
1 |
|
上面的代码中,foo.mutate_and_share()
虽然借用了
&mut self
,但是它最终返回的是一个
&self
,然后赋值给
loan
,因此理论上来说它最终是进行了不可变借用,同时
foo.share
也进行了不可变借用,那么根据 Rust
的借用规则:多个不可变借用可以同时存在,因此该代码应该编译通过。
事实上,运行代码后,你将看到一个错误:
1 |
|
编译器的提示在这里其实有些难以理解,因为可变借用仅在
mutate_and_share
方法内部有效,出了该方法后,就只有返回的不可变借用,因此,按理来说可变借用不应该在
main
的作用范围内存在。
对于这个反直觉的事情,让我们用生命周期来解释下,可能你就很好理解了:
1 |
|
以上是模拟了编译器的生命周期标注后的代码,可以注意到
&mut foo
和 loan
的生命周期都是
'c
。
还记得生命周期消除规则中的第三条吗?因为该规则,导致了
mutate_and_share
方法中,参数 &mut self
和返回值 &self
的生命周期是相同的,因此,若返回值的生命周期在 main
函数有效,那 &mut self
的借用也是在 main
函数有效。
这就解释了可变借用为啥会在 main
函数作用域内有效,最终导致 foo.share()
无法再进行不可变借用。
总结下:&mut self
借用的生命周期和 loan
的生命周期相同,将持续到 println
结束。而在此期间
foo.share()
又进行了一次不可变 &foo
借用,违背了可变借用与不可变借用不能同时存在的规则,最终导致了编译错误。
上述代码实际上完全是正确的,但是因为生命周期系统的“粗糙实现”,导致了编译错误,目前来说,遇到这种生命周期系统不够聪明导致的编译错误,我们也没有太好的办法,只能修改代码去满足它的需求,并期待以后它会更聪明。
1.2 例子 2
再来看一个例子:
1 |
|
这段代码不能通过编译的原因是编译器未能精确地判断出某个可变借用不再需要,反而谨慎的给该借用安排了一个很大的作用域,结果导致后续的借用失败:
1 |
|
分析代码可知在 match map.get_mut(&key)
方法调用完成后,对 map
的可变借用就可以结束了。但从报错看来,编译器不太聪明,它认为该借用会持续到整个
match
语句块的结束(第 16
行处),这便造成了后续借用的失败。
类似的例子还有很多,由于篇幅有限,就不在这里一一列举,如果大家想要阅读更多的类似代码,可以看看《Rust 代码鉴赏》一书。
2. 无界生命周期
不安全代码(unsafe
)经常会凭空产生引用或生命周期,这些生命周期被称为是
无界(unbound) 的。
无界生命周期往往是在解引用一个裸指针(裸指针 raw pointer)时产生的,换句话说,它是凭空产生的,因为输入参数根本就没有这个生命周期:
1 |
|
上述代码中,参数 x
是一个裸指针,它并没有任何生命周期,然后通过 unsafe
操作后,它被进行了解引用,变成了一个 Rust
的标准引用类型,该类型必须要有生命周期,也就是 'a
。
可以看出 'a
是凭空产生的,因此它是无界生命周期。这种生命周期由于没有受到任何约束,因此它想要多大就多大,这实际上比
'static
要强大。例如 &'static &'a T
是无效类型,但是无界生命周期 &'unbounded &'a T
会被视为 &'a &'a T
从而通过编译检查,因为它可大可小,就像孙猴子的金箍棒一般。
我们在实际应用中,要尽量避免这种无界生命周期。最简单的避免无界生命周期的方式就是在函数声明中运用生命周期消除规则。若一个输出生命周期被消除了,那么必定因为有一个输入生命周期与之对应。
3. 生命周期约束 HRTB
生命周期约束跟特征约束类似,都是通过形如 'a: 'b
的语法,来说明两个生命周期的长短关系。
3.1 'a: 'b
假设有两个引用 &'a i32
和
&'b i32
,它们的生命周期分别是 'a
和
'b
,若 'a
>= 'b
,则可以定义
'a:'b
,表示 'a
至少要活得跟 'b
一样久。
1 |
|
例如上述代码定义一个结构体,它拥有两个引用字段,类型都是泛型
T
,每个引用都拥有自己的生命周期,由于我们使用了生命周期约束
'b: 'a
,因此 'b
必须活得比 'a
久,也就是结构体中的 s
字段引用的值必须要比 r
字段引用的值活得要久。
3.2 T: 'a
表示类型 T
必须比 'a
活得要久:
1 |
|
因为结构体字段 r
引用了 T
,因此
r
的生命周期 'a
必须要比 T
的生命周期更短(被引用者的生命周期必须要比引用长)。
在 Rust 1.30 版本之前,该写法是必须的,但是从 1.31
版本开始,编译器可以自动推导 T: 'a
类型的约束,因此我们只需这样写即可:
1 |
|
来看一个使用了生命周期约束的综合例子:
1 |
|
上面的例子中必须添加约束 'a: 'b
后,才能成功编译,因为
self.part
的生命周期与 self
的生命周期一致,将
&'a
类型的生命周期强行转换为 &'b
类型,会报错,只有在 'a
>= 'b
的情况下,'a
才能转换成 'b
。
4. 闭包函数的消除规则
先来看一段简单的代码:
1 |
|
乍一看,这段代码比古天乐还平平无奇,能有什么问题呢?来,拄拐走两圈试试:
1 |
|
咦?竟然报错了,明明两个一模一样功能的函数,一个正常编译,一个却报错,错误原因是编译器无法推测返回的引用和传入的引用谁活得更久!
真的是非常奇怪的错误,学过上一节的读者应该都记得这样一条生命周期消除规则:如果函数参数中只有一个引用类型,那该引用的生命周期会被自动分配给所有的返回引用。我们当前的情况完美符合,
function
函数的顺利编译通过,就充分说明了问题。
先给出一个结论:这个问题,可能很难被解决,建议大家遇到后,还是老老实实用正常的函数,不要秀闭包了。
对于函数的生命周期而言,它的消除规则之所以能生效是因为它的生命周期完全体现在签名的引用类型上,在函数体中无需任何体现:
1 |
|
因此编译器可以做各种编译优化,也很容易根据参数和返回值进行生命周期的分析,最终得出消除规则。
可是闭包,并没有函数那么简单,它的生命周期分散在参数和闭包函数体中(主要是它没有确切的返回值签名):
1 |
|
编译器就必须深入到闭包函数体中,去分析和推测生命周期,复杂度因此急剧提升:试想一下,编译器该如何从复杂的上下文中分析出参数引用的生命周期和闭包体中生命周期的关系?
由于上述原因(当然,实际情况复杂的多),Rust 语言开发者目前其实是有意针对函数和闭包实现了两种不同的生命周期消除规则。
用
Fn
特征解决闭包生命周期之前我们提到了很难解决,但是并没有完全堵死(论文字的艺术- , -) 这不 @Ykong1337 同学就带了一个解决方法,为他点赞!
1
2
3
4
5
6
7
8
9
fn main() {
let closure_slision = fun(|x: &i32| -> &i32 { x });
assert_eq!(*closure_slision(&45), 45);
// Passed !
}
fn fun<T, F: Fn(&T) -> &T>(f: F) -> F {
f
}
5. NLL (Non-Lexical Lifetime)
之前我们在引用与借用那一章其实有讲到过这个概念,简单来说就是:引用的生命周期正常来说应该从借用开始一直持续到作用域结束,但是这种规则会让多引用共存的情况变得更复杂:
1 |
|
按照上述规则,这段代码将会报错,因为 r1
和
r2
的不可变引用将持续到 main
函数结束,而在此范围内,我们又借用了 r3
的可变引用,这违反了借用的规则:要么多个不可变借用,要么一个可变借用。
好在,该规则从 1.31 版本引入 NLL
后,就变成了:引用的生命周期从借用处开始,一直持续到最后一次使用的地方。
按照最新的规则,我们再来分析一下上面的代码。r1
和
r2
不可变借用在 println!
后就不再使用,因此生命周期也随之结束,那么 r3
的借用就不再违反借用的规则,皆大欢喜。
再来看一段关于 NLL
的代码解释:
1 |
|
这段代码一目了然,a
有三段生命周期:α
,β
,γ
,每一段生命周期都随着当前值的最后一次使用而结束。
在实际项目中,NLL
规则可以大幅减少引用冲突的情况,极大的便利了用户,因此广受欢迎,最终该规则甚至演化成一个独立的项目,未来可能会进一步简化我们的使用,Polonius
:
6. Reborrow 再借用
学完 NLL
后,我们就有了一定的基础,可以继续学习关于借用和生命周期的一个高级内容:再借用。
先来看一段代码:
1 |
|
以上代码,大家可能会觉得可变引用 r
和不可变引用
rr
同时存在会报错吧?但是事实上并不会,原因在于
rr
是对 r
的再借用。
对于再借用而言,rr
再借用时不会破坏借用规则,但是你不能在它的生命周期内再使用原来的借用
r
,来看看对上段代码的分析:
1 |
|
再来看一个例子:
1 |
|
如上所示,函数体内对参数的二次借用也是典型的 Reborrow
场景。
那么下面让我们来做件坏事,破坏这条规则,使其报错:
1 |
|
果然,破坏永远比重建简单 :) 只需要在 rr
再借用的生命周期内使用一次原来的借用 r
即可!
7. 生命周期消除规则补充
在上一节中,我们介绍了三大基础生命周期消除规则,实际上,随着 Rust 的版本进化,该规则也在不断演进,这里再介绍几个常见的消除规则:
7.1 impl 块消除
1 |
|
如果你以前写的impl
块长上面这样,同时在 impl
内部的方法中,根本就没有用到
'a
,那就可以写成下面的代码形式。
1 |
|
'_
称为匿名生命周期(anonymous lifetime),在这里表示
BufReader
有一个不使用的生命周期,我们可以忽略它,无需为它创建一个名称。
歪个楼,有读者估计会发问:既然用不到
'a
,为何还要写出来?如果你仔细回忆下上一节的内容,里面有一句专门用粗体标注的文字:生命周期参数也是类型的一部分,因此
BufReader<'a>
是一个完整的类型,在实现它的时候,你不能把 'a
给丢了!
7.2 生命周期约束消除
1 |
|
在本节的生命周期约束中,也提到过,新版本 Rust 中,上面情况中的
T: 'a
可以被消除掉,当然,你也可以显式的声明,但是会影响代码可读性。关于类似的场景,Rust
团队计划在未来提供更多的消除规则,但是,你懂的,计划未来就等于未知。
8. 一个复杂的例子
下面是一个关于生命周期声明过大的例子,会较为复杂,希望大家能细细阅读,它能帮你对生命周期的理解更加深入。
1 |
|
运行后报错:
1 |
|
这段代码看上去并不复杂,实际上难度挺高的,首先在直觉上,list.get_interface()
借用的可变引用,按理来说应该在这行代码结束后,就归还了,但是为什么还能持续到
use_list(&list)
后面呢?
这是因为我们在 get_interface
方法中声明的
lifetime
有问题,该方法的参数的生命周期是
'a
,而 List
的生命周期也是
'a
,说明该方法至少活得跟 List
一样久,再回到
main
函数中,list
可以活到 main
函数的结束,因此 list.get_interface()
借用的可变引用也会活到 main
函数的结束,在此期间,自然无法再进行借用了。
要解决这个问题,我们需要为 get_interface
方法的参数给予一个不同于 List<'a>
的生命周期
'b
,最终代码如下:
1 |
|