0%

rust临时变量生命周期和super let【翻译】

原文: https://blog.m-ou.se/super-let/

Rust中临时变量的生命周期相当复杂,但是个容易被忽略的话题.通常Rust会把临时变量维持足够长的时间,不需要我们关心;但也很多情况并不是我们期望的.

这篇文章中,我们会(重新)了解临时变量生命周期的规则,学习几种临时变量生命周期延长的情况,并探索一种新的语言想法,super let,让我们能对这些情况有更多控制.

1 临时变量

下边是个rust语句,没有上下文,只用了1个String临时变量:

1
f(&String::from('0'));

这里String临时变量会存活多久?如果我们今天是在设计rust,基本会有两个选项:

  1. f被调用前,string会立即被drop;或者
  2. f被调用后,string才会被drop.

如果用选项1,这个语句就总会产生借用检查错误,因为我们不能让f借用已经被丢掉的变量.

所以rust采用了选项2: String会先创建,然后传递给f1个引用,只有f返回,才会丢掉临时变量String.

1.1 在let语句中

现在稍微困难点:

1
2
3
let a = f(&String::from('0'));
...
g(&a);

再来一次,这里String临时变量会存活多久?

  1. let语句之后被丢掉: f调用后,g调用前.或者
  2. 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
2
3
4
let s = String::from(''0);
let a = f(&s); // s这里就被释放了
...
g(&a);

1.2 在嵌套调用中

ok,再来一个:

1
g(f(&String::from(''0)));

还是2个选项:

  1. f调用后, g调用前;
  2. 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
2
3
if f(&String::from('0')) {
...
}

同样: 什么时候String被释放?

  1. if的条件语句计算之后,body执行之前(大括号{之前);
  2. 在body之后(大括号}之后).

这个情况中,没理由在if的body执行过程中保持临时变量的生命周期,条件计算出boolean值,没有借用任何值。

因此rust选择了1.

有个很有用的例子,当用Mutex::lock的时候,会产生一个临时变量MutexGuard,在临时变量释放后会解锁Mutex:

1
2
3
4
5
fn example(m: &Mutex<String>) {
if m.lock().unwrap().is_empty() {
println!("the string is empty!");
}
}

这里,m.lock().unwrap()生成的临时变量MutexGuard.is_empty()执行之后就立即被释放。在println!语句执行期间,Mutex就没有保持锁状态.

1.4 在if let语句中

但是在if let(和match)语句中情况就不太一样,因为在这里条件语句不是必须计算出boolean值:

1
2
3
if let ... = f(&String::from('0')) {
...
}

两个选项:

  1. string在模式匹配之后,body{执行之前释放;
  2. string在body执行之后释放;

这次,有很多理由选择2。在if let语句或match分支中借用相当普遍。

因此,这种情况下rust选择2.

举个例子,假设有个Mutex<Vec<T>>的数组vec, 下面代码编译正常:

1
2
3
4
5
if let Some(x) = vec.lock().unwrap().first() {
// mutex在这里依然被锁着,
// 这是必须的,因为正在从`Vec`借用`x`(`x`是个`&T`)
println!("first item in vec: {x}");
}

m.lock().unwrap()中获取临时变量MutexGuard,然后用.first()借用第1个元素,该借用持续整个if let的body, 因为MutexGuard只会在body结束的}被释放.

然而,有些情况下这不是我们期望的,比如如果将first换成pop,直接返回值而不是索引:

1
2
3
4
5
if let Some(x) = vec.lock().unwrap().pop() {
// mutex在这里依然被锁着,
// 这不是必须的,因为没从`Vec`借用任何值
println!("first item in vec: {x}");
}

这种情况比较奇怪,会引起bug或是降低性能。

可能这是rust在这里选择错误的一个例证,在未来版本中会再讨论。可以从Niko’s blog了解更多这个话题。

现在,变通方法是用一个单独的let语句,限制临时变量的生命周期:

1
2
3
4
let x = vec.lock().unwrap().pop(); // MutexGuard 在这里被释放
if let Some(x) = x {
...
}

2 临时变量生命周期扩展

这种情况呢?

1
2
3
let a = &String::from('0');
...
f(&a);

两个选项:

  1. let语句结束后就释放;
  2. 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();

这里Stringlet结尾就会被释放,但.len()返回的usize会存在跟a一样长的时间。

扩展并没限制只能用let _ = &...;这样的语法,这也可以:

1
2
3
4
let a = Persion {
name: &String::from('a'), // 被扩展
address: &String::from('a'), // 被扩展
};

上边代码中,临时变量string生命周期会被扩展,因为即使不知道任何Person类型的信息,我们也知道生命周期需要被扩展才能在后续使用对应的值。

let语句中临时变量生命周期扩展规则都在rust手册中, 但如果自己能快速识别出来更好:

1
2
3
4
5
6
7
8
9
let a = &temporary().field; // Extended!
let a = MyStruct { field: &temporary() }; // Extended!
let a = &MyStruct { field: &temporary() }; // Both extended!
let a = [&temporary()]; // Extended!
let a = { …; &temporary() }; // Extended!

let a = f(&temporary()); // Not extended, because it might not be necessary.
let a = temporary().f(); // Not extended, because it might not be necessary.
let a = temporary() + temporary(); // Not extended, because it might not be necessary.

即使看起来合理,但我们构建元组结构体或者元组变量的时候用的语法确实是个函数调用: 比如Some(123)从语法上来说Some是个函数. 比如

1
2
let a = Some(&temporary()); // 不会被扩展, 因为 `Some` 可以定义成任何形式
let a = Some{0: &temporary() }; // 会被扩展

让人摸不着头脑:(,是时候重温一遍原则了.

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
2
3
pub struct Writer>'a<> {
pub file: &'a File
}

代码中创建Writer来写入新创建的文件:

1
2
3
4
println!("opening file...");
let filename = "hello.txt";
let file = File::create(filename).unwrap();
let writer = Writer {file: &file};

这里包含filename,file,writer,但接下来的代码只会通过Writer来写,理想情况下,filenamefile不应该在这里显示。

因为临时变量生命周期扩展也可以在block的最后1个表达式中应用,我们可以这样写:

1
2
3
4
5
let writer = {
println!("opening file...");
let filename = "hello.txt";
Writer { file: &File::create(filename).unwrap() }
};

现在Writer的创建过程被封装在自己的区域内,只有writer变量会出现在外层作用域。感谢临时变量生命周期扩展,内层创建的临时变量File可以在writer中继续存在。

2.3 临时变量生命周期扩展的限制

如果我们把Writerfile变为private:

1
2
3
4
5
6
7
8
9
pub struct Writer<'a> {
file: &'a File
}

impl<'a> Writer<'a> {
pub fn new(file: &'a File) -> Self {
Self { file }
}
}

调用代码并不会改变太多:

1
2
3
4
println!("opening file...");
let filename = "hello.txt";
let file = File::create(filename).unwrap();
let writer = Writer::new(&file); // Only this line changed.

只需要用Writer::new()函数代替Writer{}语法就行。

但是,对于局部作用域版本就不能正常工作了:

1
2
3
4
5
6
7
let writer = {
println!("opening file...");
let filename = "hello.txt";
Writer::new(&File::create(filename).unwrap()) // Error: Does not live long enough!
};

writer.something(); // Error: File no longer alive here!

尽管临时变量生命周期扩展可以作用于Writer {}构建语法,但不能通过Writer::new()函数调用传递。(因为函数定义可以是fn new(&File) -> Self<'static>fn new(&File) -> i32,根本不需要扩展生命周期)。

现在没有办法可以控制是否应用临时变量生命周期扩展,我们必须在外层放一个let file, 使用延迟初始化:

1
2
3
4
5
6
7
let file;
let writer = {
println!("opening file...");
let filename = "hello.txt";
file = File::create(filename).unwrap();
Writer::new(&file)
};

但这又把file带到了外层,我们之前想规避的情况.:(

尽管在外层放个let file不是个大问题,但对很多rust程序员来说这不是个很明显的变通方法. 延迟初始化不是个常用的特性,当前编译器也不会建议这种替换。即使编译器可给出建议,这也不是个小的变动。

2.4 宏Macros

可能同时创建file和返回Writer的函数会更有用,比如:

1
let writer = Writer::new_file("hello.txt");

但因为Writer仅仅是借用File, 这就要求new_fileFile放到另外的地方。它可以把File泄露(leak)出去或存到static静态变量中,但当前没有一种办法能让File跟返回的Writer存活一样久。

因此,我们可以用宏来定义file和writer:

1
2
3
4
5
6
macro_rules! let_writer_to_file {
($writer:ident, $filename:expr) => {
let file = std::fs::File::create($filename).unwrap();
let $writer = Writer::new(&file);
};
}

像这样用:

1
2
let_writer_to_file!(writer, "hello.txt");
writer.something();

多亏了macro hygiene, file在该区域不可见。

尽管能用,但做成常规的函数调用方式是不是更好些?比如这样:

1
2
let writer = writer_to_file!("hello.txt");
writer.something();

如前所述,这种方法创建的临时变量File会在let writer = ...;语句中应用生命周期扩展:

1
2
3
4
5
6
macro_rules! writer_to_file {
($filename:expr) => {
Writer { file: &File::create($filename).unwrap() }
};
}
let writer = writer_to_file!("hello.txt");

会被展开成这样:

1
let writer = Writer { file: &File::create("hello.txt").unwrap() };

这里会尽量延长File的生命周期。

但这里仍然需要filepublic的,如果使用Writer::new()函数,也没办法在使用宏之前(let writer = ...;之前)插入let file;, 所以无法使用Writer::new()

format_args!()

这个问题也是现在为什么format_args!()的返回值不能保存到let语句中的原因:

1
2
let f = format_args!("{}", 1); // Error
something.write_fmt(f);

format_args!()展开后是fmt::Arguments::new(&Argument::display(&arg), …),一些参数是临时变量的引用.

临时变量生命周期扩展不会应用在函数调用的参数中,因此fmt::Arguments对象只能在同一个语句中使用。

pin!()

另一个经常通过宏构造的类型是Pin, 粗略的讲,它引用1个无法被移动的东西(细节很复杂,但跟这里不太相关)。

它是通过一个Pin::new_uncheckedunsafe函数创建的,因为你要保证它引用的值不会被移动,即使Pin本身被释放了也不能。

使用该函数最好的办法,就是利用shadowing:

1
2
let mut thing = Thing { … };
let thing = unsafe { Pin::new_unchecked(&mut thing) };

因为第2个thing覆盖了第1个,第1个thing(仍然存在)不再有名字,因为它没名字,我们可以肯定它不会被移动(即使第2个thing被释放), 我们在unsafe块中做了保证。

这是个宏中常用的模式。

1
2
3
4
5
6
macro_rules! let_pin {
($name:ident, $init:expr) => {
let mut $name = $init;
let $name = unsafe { Pin::new_unchecked(&mut $name) };
};
}

用法跟之前let_writer_to_file宏类似:

1
2
let_pin!(thing, Thing { … });
thing.something();

工作正常,完美隐藏了unsafe代码。

但是,跟Writer例子一样,如果能用函数调用就更好了:

1
let thing = pin!(Thing{...});

现在我们知道,要实现这个,我们只有利用临时变量生命周期扩展让Thing临时变量存活足够久,要应用扩展,就必须允许我们用Pin{}语法来创建Pin变量;Pin{pinned: &mut Thing{...}}会应用扩展,Pin::new_unchecked(&mut Thing {...})则不会。

这就意味着Pin的字段必须是public的,这跟Pin的目的相反。之后字段是私有的,它才能提供足够的保证。

这也就意味着你自己目前无法写出pin!()这样的宏。

标准库确实实现了,通过一种糟糕的方式: Pinprivate字段实际被定义成了pub,但标记成了unstable,如果你使用,编译器会告警。

3 super let

我们已经看到了集中限制临时变量生命周期扩展生效的情况:

  • 尝试让let writer = {...};保持整洁的作用域失败了;
  • 尝试让let writer = writer_to_file!(...);工作失败了;
  • 无法实现let f = format_args!(...);
  • 糟糕的实现pin!().

如果我们能手动选择临时变量生命周期扩展,上面这些就可以优雅的解决了。

后边不翻译了,简单来说就是期望引入super let,将局部变量的作用域提升到上一层。但感觉只是增加rust代码的复杂度,越来越麻烦了。