Appearance
14.1 NLL 希望解决的问题
首先,看个简单的示例:
rust
fn foo() -> Vec<char> {
let mut data = vec!['a', 'b', 'c']; // --+ 'scope
capitalize(&mut data[..]); // |
// ^~~~~~~~~~~~~~~~~~~~~~~~~ 'lifetime // |
data.push('d'); // |
data.push('e'); // |
data.push('f'); // |
data // |
// <-----------------------------------------+
}
fn capitalize(data: &mut [char]) {
for c in data {
c.make_ascii_uppercase();
}
}
fn main() {
let v = foo();
println!("{:?}", v);
}
这段代码是没有问题的。我们的关注点是foo()
这个函数,它在调用capitalize
函数的时候,创建了一个临时的&mut
型引用,在它的调用结束后,这个临时的借用就终止了,因此,后面我们就可以再用data
去修改数据。注意,这个临时的&mut
引用存在的时间很短,函数调用结束,它的生命周期就结束了。
但是,如果我们把这段代码稍作修改,问题就出现了:
rust
fn foo() -> Vec<char> {
let mut data = vec!['a', 'b', 'c']; // --+ 'scope
let slice = &mut data[..];// <-----------+ 'lifetime
capitalize(slice); // |
data.push('d'); //ERROR // |
data.push('e'); //ERROR // |
data.push('f'); //ERROR // |
data //ERROR // |
// <-----------------------------------------+
}
在早期的编译器内部实现里面,所有的变量,包括引用,它们的生命周期都是从声明的地方开始,到当前语句块结束(不考虑所有权转移的情况)。 因为 Rust 规定了“共享不可变,可变不共享”,同时出现两个&mut
型借用是违反规则的。早期的编译器认为slice
依然存在,然而又使用data
去调用fn push(&mut self, value: T)
方法,必然又会产生一个&mut
型借用,这违反了 Rust 的原则。在目前这个版本中,如果我们要修复这个问题,只能这样做:
rust
fn foo() -> Vec<char> {
let mut data = vec!['a', 'b', 'c']; // --+ 'scope
{
let slice = &mut data[..]; // <---- 创建一个代码块,让`slice`在这个子代码块中创建,就不会产生生命周期冲突问题了。
capitalize(slice); // |
}// <------------------------------------+
data.push('d');
data.push('e');
data.push('f');
data
}
早期 rust 编译器的实现方式是每个引用的生命周期都是跟代码块(scope)相关联的,总是从声明的时候被创建,在退出这个代码块的时候被销毁,因此可以称为 Lexical lifetime。
而本章所说的 Non-Lexical lifetime,意思就是取消这个关联性,引用的生命周期,我们用另外的、更智能的方式分析。有了这个功能,上例中手动加入的代码块就不需要了,编译器应该能自动分析出来,slice
这个引用在capitalize
函数调用后就再没有被使用过了,它的生命周期完全可以就此终止,不会对程序的正确性有任何影响,后面再调用 push 方法修改数据,其实跟前面的slice
并没有什么冲突关系。
看了上面这个例子,可能有人还会觉得,显式的用一个代码块来规定局部变量的生命周期是个更好的选择,Non-Lexical-Lifetime 的意义似乎并不大。那我们再继续看看更复杂的例子。我们可以发现,Non-Lexical-Lifetime 可以打开更多的可能性,让用户有机会用更直观的方式写代码。比如下面这样的一个分支结构的程序:
rust
fn process_or_default<K,V:Default>
(map: &mut HashMap<K,V>, key: K)
{
match map.get_mut(&key) { // -------------+ 'lifetime
Some(value) => process(value), // |
None => { // |
map.insert(key, V::default()); // |
// ^~~~~~ ERROR. // |
} // |
} // <------------------------------------+
}
这个示例,编译器会判定,针对map
的引用也是一直存在于整个 match 语句块中的。于是后面调用insert
方法会发生冲突。
当然,如果我们从逻辑上来理解这段代码,就会知道,这段代码其实是安全的。因为在None
分支,意味着map
中没有找到这个key
,在这条路径上自然也没有指向map
的引用存在。但是可惜,在老版本的编译器上,这段代码编译不通过。 针对早期编译器的解决办法可以查看 RFC 上关于NLL 的设计讲解。
因为要解决这个问题,会让牺牲宝贵的时间不说,解决代码还会有额外的性能开销。这也是为什么标准库中的 HashMap 设计了一个叫作entry
的 api,如果用 entry 来写这段逻辑,可以这么做:
rust
fn get_default3<'m,K,V:Default>(
map: &'m mut HashMap<K,V>,
key: K)
-> &'m mut V
{
map.entry(key)
.or_insert_with(|| V::default())
}
这个设计既清晰简洁,也没有额外的性能开销,而且不需要 Non-Lexical-Lifetime 的支持。 这说明,虽然老版本的生命周期检查确实有点过于严格,但至少在某些场景下,我们其实还是有办法绕过去的,不一定要在“良好的抽象”和“安全性”之间做选择。但是它付出了其他的代价,那就是设计难度更高,更不容易被掌握。
标准库中的 entry API 也是很多高手经过很长时间才最终设计出来的产物。对于普通用户而言,如果在其他场景下出现了类似的冲突,恐怕大部分人都没有能力想到一个最佳方案,可以既避过编译器限制,又不损失性能。所以在实践中的很多场景下,普通用户做不到“零开销抽象”。
让编译器能更准确地分析借用指针的生命周期,不要简单地与 scope 相绑定,不论对普通用户还是高阶用户都是一个更合理、更有用的功能。 如果编译器能有这么聪明,那么它应该能理解下面这段代码其实是安全的:
rust
match map.get_mut(&key) {
Some(value) => process(value), // 找到了就继续处理这个值
None => {
map.insert(key, V::default()); // 没找到 key 就插入一个新的值
}
}
这段代码既符合用户直观思维模式,又没有破坏 Rust 的安全原则。以前的编译器无法编译通过,实际上是对正确程序的误伤,是一种应该修复的缺陷。NLL 的设计目的就是让 Rust 的安全检查更加准确,减少误报,使得编译器对程序员的掣肘更少。
目前 NLL 特性已经稳定发布了,以下代码可以正常编译通过:
rust
use std::collections::HashMap;
fn process_or_default4(map: &mut HashMap<String, String>, key: String)
{
match map.get_mut(&key) {
Some(value) => println!("{}", value),
None => {
map.insert(key, String::new());
}
}
}
fn main() {
let mut map = HashMap::<String, String>::new();
let key = String::from("abc");
process_or_default4(&mut map, key);
}
小结
NLL(Non-Lexical Lifetimes)是 Rust 中一个重要的类型系统特性,用于改进 Rust 的借用检查器(Borrow Checker)。在之前的 Rust 版本中,Rust 的借用规则是基于可见性和词法层级的。这种规则虽然已经很强大,但在某些情况下会出现一些限制。
为了解决这些问题,Rust 引入了 NLL 特性。NLL 通过对变量的生命周期进行精确计算,允许更多的安全的代码被编写,在早期版本的 Rust 中可能会被拒绝。
NLL 主要解决了以下问题:
变量生命周期较短的情况:在旧版本的 Rust 中,如果一个变量的生命周期非常短,那么在使用该变量之前就需要释放先前的借用,这将导致代码的复杂性增加并且很难维护。NLL 支持非词法生命周期,能够正确处理变量生命周期较短的情况并消除此类错误。
长时间借用和循环引用:传统的借用检查器难以处理循环引用或长寿命的借用。NLL 引入了一些新的算法和结构来处理这两个问题,并保证操作都是安全的。从而可以更准确的验证代码是否符合 Rust 的借用规范。
并行处理和线程安全:Rust 能够有效地处理并发和多线程应用程序,但在旧的生命周期系统中可能无法准确处理这些情况。NLL 可以准确定位所有权的转移和生命周期问题,保证并发操作的安全性,避免数据竞争和内存错误。
NLL 是 Rust 中的一种重要特性,其目的是为了改进 Rust 的类型系统,提高 Borrow Checker 的精度和安全性,并支持更灵活更复杂的编程模式。