原文: https://blog.m-ou.se/super-let/
Rust中临时变量的生命周期相当复杂,但是个容易被忽略的话题.通常Rust会把临时变量维持足够长的时间,不需要我们关心;但也很多情况并不是我们期望的.
这篇文章中,我们会(重新)了解临时变量生命周期的规则,学习几种临时变量生命周期延长的情况,并探索一种新的语言想法,super let
,让我们能对这些情况有更多控制.
1 临时变量
下边是个rust语句,没有上下文,只用了1个String
临时变量:
1 | f(&String::from('0')); |
这里String
临时变量会存活多久?如果我们今天是在设计rust,基本会有两个选项:
- 在
f
被调用前,string会立即被drop;或者 - 在
f
被调用后,string才会被drop.
如果用选项1,这个语句就总会产生借用检查错误,因为我们不能让f
借用已经被丢掉的变量.
所以rust采用了选项2: String
会先创建,然后传递给f
1个引用,只有f
返回,才会丢掉临时变量String
.
1.1 在let
语句中
现在稍微困难点:
1 | let a = f(&String::from('0')); |
再来一次,这里String
临时变量会存活多久?
- 在
let
语句之后被丢掉:f
调用后,g
调用前.或者 - 跟
a
的一样长,在g
调用后.
这次,根据f
的签名, 选项1可能正常工作. 如果f
定义成fn f(s: &str) -> usize
(比如str::len
), 在let
语句调用后就立刻丢掉String
会相当完美。
但如果f
定义成fn f(s: &str) -> &[u8]
(比如str::as_bytes
),那a
就会借用临时变量String
,这时如果我们长时间持有a
就会造成借用检查错误。
用选项2的话,两种情况下都能正常编译,但会让临时变量存在时间过长,会造成资源浪费或引起bug(比如MutexGuard
比预期存在时间长,从而引起死锁)。
听起来我们可能需要第3种选择: 让临时变量生命周期依赖f
的定义.
但是,rust的借用检查器只是进行检查: 它不会影响代码的行为,这是个很重要而且很有用的特性。举个例子, 将fn f(s: &str) -> &[u8]
(返回值借用了参数)变为fn f(s: &str) -> &'static [u8]
(返回值没有借用参数)不会对调用端造成任何影响,也不能影响临时变量被drop的时间点。
因此只能选择选项1或2,rust选了1: 在let
之后就释放临时变量。可以很容易将临时变量String
移到1个单独的let
语句,来延长生命周期,让rust不会报错:
1 | let s = String::from(''0); |
1.2 在嵌套调用中
ok,再来一个:
1 | g(f(&String::from(''0))); |
还是2个选项:
- 在
f
调用后,g
调用前; - 在
g
调用后;
这段代码跟前一个几乎一样: 临时变量String
的借用传给f
,f
的返回值传给g
。但这次都嵌套在1个单独的语句中了。
还是那样,选项1根据f
的定义可能正常或不正常工作; 选项2会比期望的保持更长时间。
但是这次,选项1会让程序员感觉奇怪,比如String::from('0').as_bytes().contains(&0x80)
会无法编译,因为as_bytes(f)
之后临时变量就被释放,contains(g)
就失败了。
这里把临时变量生命周期维持时间稍微长点儿会更好些,因为语句结束后仍会被释放。
因此rust选择了2: 不管f
的定义如何, 临时变量String
会在g
调用后被释放。
1.3 在if
语句中
现在看看单独的if
语句:
1 | if f(&String::from('0')) { |
同样: 什么时候String
被释放?
- 在
if
的条件语句计算之后,body执行之前(大括号{
之前); - 在body之后(大括号
}
之后).
这个情况中,没理由在if
的body执行过程中保持临时变量的生命周期,条件计算出boolean值,没有借用任何值。
因此rust选择了1.
有个很有用的例子,当用Mutex::lock
的时候,会产生一个临时变量MutexGuard
,在临时变量释放后会解锁Mutex
:
1 | fn example(m: &Mutex<String>) { |
这里,m.lock().unwrap()
生成的临时变量MutexGuard
在.is_empty()
执行之后就立即被释放。在println!
语句执行期间,Mutex
就没有保持锁状态.
1.4 在if let
语句中
但是在if let
(和match
)语句中情况就不太一样,因为在这里条件语句不是必须计算出boolean值:
1 | if let ... = f(&String::from('0')) { |
两个选项:
- string在模式匹配之后,body
{
执行之前释放; - string在body执行之后释放;
这次,有很多理由选择2。在if let
语句或match
分支中借用相当普遍。
因此,这种情况下rust选择2.
举个例子,假设有个Mutex<Vec<T>>
的数组vec
, 下面代码编译正常:
1 | if let Some(x) = vec.lock().unwrap().first() { |
从m.lock().unwrap()
中获取临时变量MutexGuard
,然后用.first()
借用第1个元素,该借用持续整个if let
的body, 因为MutexGuard
只会在body结束的}
被释放.
然而,有些情况下这不是我们期望的,比如如果将first
换成pop
,直接返回值而不是索引:
1 | if let Some(x) = vec.lock().unwrap().pop() { |
这种情况比较奇怪,会引起bug或是降低性能。
可能这是rust在这里选择错误的一个例证,在未来版本中会再讨论。可以从Niko’s blog了解更多这个话题。
现在,变通方法是用一个单独的let
语句,限制临时变量的生命周期:
1 | let x = vec.lock().unwrap().pop(); // MutexGuard 在这里被释放 |
2 临时变量生命周期扩展
这种情况呢?
1 | let a = &String::from('0'); |
两个选项:
- 在
let
语句结束后就释放; - 在
f
调用后释放;
选项1会引起借用检查错误,因此2可能更合理些。这也是rust现在如何做的: 临时生命周期被扩展了,因此上边代码可以正常编译。
这种临时变量生命周期比其出现的语句存在更长时间的现象叫做临时变量生命周期扩展。
临时变量生命周期扩展不会应用在所有let
语句的临时变量中: let a = f(&String::from('0'));
中就不会延长.
在let a = &f(&String::from('0'));
(注意有个额外的&
), 临时变量生命周期扩展会应用在外层的&
(借用f
返回的临时变量),而不是内层的&
(借用String
临时变量).
举个例子, 将f
替换成str::len
:
1 | let a: &usize = &String::from('a').len(); |
这里String
在let
结尾就会被释放,但.len()
返回的usize
会存在跟a
一样长的时间。
扩展并没限制只能用let _ = &...;
这样的语法,这也可以:
1 | let a = Persion { |
上边代码中,临时变量string生命周期会被扩展,因为即使不知道任何Person
类型的信息,我们也知道生命周期需要被扩展才能在后续使用对应的值。
let
语句中临时变量生命周期扩展规则都在rust手册中, 但如果自己能快速识别出来更好:
1 | let a = &temporary().field; // Extended! |
即使看起来合理,但我们构建元组结构体或者元组变量的时候用的语法确实是个函数调用: 比如Some(123)
从语法上来说Some
是个函数. 比如
1 | let a = Some(&temporary()); // 不会被扩展, 因为 `Some` 可以定义成任何形式 |
让人摸不着头脑:(
,是时候重温一遍原则了.
2.1 常量提升(Constant Promotion)
临时变量生命周期扩展常跟另一个*常量提升(Constant Promotion)*概念弄混,是另一种临时变量生命周期意外延长的情况。
对于像&123
或&None
这种常量(没有内部可变性和析构函数),会被自动提升到永久存在,也就是这些引用的生命周期是'static
。比如:
1 | let x = f(&3); // &3是'static,不管`f`定义成什么. |
甚至出现在简单的表达式中
1 | let x = f(&(1+2)); |
在临时变量生命周期扩展和常量提升都适用的地方,会用常量提升,因为会把生命周期提升到最大:
1 | let x = &1; // 'static |
上边x
是个'static
引用, 1
生命周期要大于x
本身.
2.2 Block中的临时变量生命周期扩展
设想我们有个Writer
类型,持有一个写入File
的引用:
1 | pub struct Writer>'a<> { |
代码中创建Writer
来写入新创建的文件:
1 | println!("opening file..."); |
这里包含filename,file,writer
,但接下来的代码只会通过Writer
来写,理想情况下,filename
和file
不应该在这里显示。
因为临时变量生命周期扩展也可以在block的最后1个表达式中应用,我们可以这样写:
1 | let writer = { |
现在Writer
的创建过程被封装在自己的区域内,只有writer
变量会出现在外层作用域。感谢临时变量生命周期扩展,内层创建的临时变量File
可以在writer
中继续存在。
2.3 临时变量生命周期扩展的限制
如果我们把Writer
的file
变为private:
1 | pub struct Writer<'a> { |
调用代码并不会改变太多:
1 | println!("opening file..."); |
只需要用Writer::new()
函数代替Writer{}
语法就行。
但是,对于局部作用域版本就不能正常工作了:
1 | let writer = { |
尽管临时变量生命周期扩展可以作用于Writer {}
构建语法,但不能通过Writer::new()
函数调用传递。(因为函数定义可以是fn new(&File) -> Self<'static>
或fn new(&File) -> i32
,根本不需要扩展生命周期)。
现在没有办法可以控制是否应用临时变量生命周期扩展,我们必须在外层放一个let file
, 使用延迟初始化:
1 | let file; |
但这又把file
带到了外层,我们之前想规避的情况.:(
尽管在外层放个let file
不是个大问题,但对很多rust程序员来说这不是个很明显的变通方法. 延迟初始化不是个常用的特性,当前编译器也不会建议这种替换。即使编译器可给出建议,这也不是个小的变动。
2.4 宏Macros
可能同时创建file和返回Writer
的函数会更有用,比如:
1 | let writer = Writer::new_file("hello.txt"); |
但因为Writer
仅仅是借用File
, 这就要求new_file
把File
放到另外的地方。它可以把File
泄露(leak
)出去或存到static
静态变量中,但当前没有一种办法能让File
跟返回的Writer
存活一样久。
因此,我们可以用宏来定义file和writer:
1 | macro_rules! let_writer_to_file { |
像这样用:
1 | let_writer_to_file!(writer, "hello.txt"); |
多亏了macro hygiene
, file
在该区域不可见。
尽管能用,但做成常规的函数调用方式是不是更好些?比如这样:
1 | let writer = writer_to_file!("hello.txt"); |
如前所述,这种方法创建的临时变量File
会在let writer = ...;
语句中应用生命周期扩展:
1 | macro_rules! writer_to_file { |
会被展开成这样:
1 | let writer = Writer { file: &File::create("hello.txt").unwrap() }; |
这里会尽量延长File
的生命周期。
但这里仍然需要file
是public
的,如果使用Writer::new()
函数,也没办法在使用宏之前(let writer = ...;
之前)插入let file;
, 所以无法使用Writer::new()
。
format_args!()
这个问题也是现在为什么format_args!()
的返回值不能保存到let
语句中的原因:
1 | let f = format_args!("{}", 1); // Error |
format_args!()
展开后是fmt::Arguments::new(&Argument::display(&arg), …)
,一些参数是临时变量的引用.
临时变量生命周期扩展不会应用在函数调用的参数中,因此fmt::Arguments
对象只能在同一个语句中使用。
pin!()
另一个经常通过宏构造的类型是Pin
, 粗略的讲,它引用1个无法被移动的东西(细节很复杂,但跟这里不太相关)。
它是通过一个Pin::new_unchecked
的unsafe
函数创建的,因为你要保证它引用的值不会被移动,即使Pin
本身被释放了也不能。
使用该函数最好的办法,就是利用shadowing:
1 | let mut thing = Thing { … }; |
因为第2个thing
覆盖了第1个,第1个thing
(仍然存在)不再有名字,因为它没名字,我们可以肯定它不会被移动(即使第2个thing
被释放), 我们在unsafe
块中做了保证。
这是个宏中常用的模式。
1 | macro_rules! let_pin { |
用法跟之前let_writer_to_file
宏类似:
1 | let_pin!(thing, Thing { … }); |
工作正常,完美隐藏了unsafe代码。
但是,跟Writer
例子一样,如果能用函数调用就更好了:
1 | let thing = pin!(Thing{...}); |
现在我们知道,要实现这个,我们只有利用临时变量生命周期扩展让Thing
临时变量存活足够久,要应用扩展,就必须允许我们用Pin{}
语法来创建Pin
变量;Pin{pinned: &mut Thing{...}}
会应用扩展,Pin::new_unchecked(&mut Thing {...})
则不会。
这就意味着Pin
的字段必须是public
的,这跟Pin
的目的相反。之后字段是私有的,它才能提供足够的保证。
这也就意味着你自己目前无法写出pin!()
这样的宏。
标准库确实实现了,通过一种糟糕的方式: Pin
的private
字段实际被定义成了pub
,但标记成了unstable
,如果你使用,编译器会告警。
3 super let
我们已经看到了集中限制临时变量生命周期扩展生效的情况:
- 尝试让
let writer = {...};
保持整洁的作用域失败了; - 尝试让
let writer = writer_to_file!(...);
工作失败了; - 无法实现
let f = format_args!(...);
- 糟糕的实现
pin!()
.
如果我们能手动选择临时变量生命周期扩展,上面这些就可以优雅的解决了。
后边不翻译了,简单来说就是期望引入super let
,将局部变量的作用域提升到上一层。但感觉只是增加rust代码的复杂度,越来越麻烦了。