Skip to content

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 主要解决了以下问题:

  1. 变量生命周期较短的情况:在旧版本的 Rust 中,如果一个变量的生命周期非常短,那么在使用该变量之前就需要释放先前的借用,这将导致代码的复杂性增加并且很难维护。NLL 支持非词法生命周期,能够正确处理变量生命周期较短的情况并消除此类错误。

  2. 长时间借用和循环引用:传统的借用检查器难以处理循环引用或长寿命的借用。NLL 引入了一些新的算法和结构来处理这两个问题,并保证操作都是安全的。从而可以更准确的验证代码是否符合 Rust 的借用规范。

  3. 并行处理和线程安全:Rust 能够有效地处理并发和多线程应用程序,但在旧的生命周期系统中可能无法准确处理这些情况。NLL 可以准确定位所有权的转移和生命周期问题,保证并发操作的安全性,避免数据竞争和内存错误。

NLL 是 Rust 中的一种重要特性,其目的是为了改进 Rust 的类型系统,提高 Borrow Checker 的精度和安全性,并支持更灵活更复杂的编程模式。

Released under the MIT License