什么是non-lexical lifetimes?认识Rust的主要改进
\n\n每个写过Rust至少几周的人,都遇到过这种情况:代码完全合乎逻辑且安全,但编译器(更准确地说,是其严格的borrow checker)拒绝编译。你看着错误提示想:“但我已经不再使用这个引用了!为什么它还在报错?”
\n\n在Rust 1.31版本(2018 edition)之前,答案很简单:“因为变量的生命周期(lifetime)是由作用域——即词法作用域——决定的。”这就是lexical lifetimes。从Rust 2018开始,编译器变得更聪明了。它引入了non-lexical lifetimes(NLL)——非词法生命周期。
\n\n在本文中,我们将探讨这是什么,为什么它的出现对Rust开发者来说是一场革命,以及它如何让代码更简洁、更直观。我们不会深入理论类型的细节,而是专注于实际示例。
\n\nLexical lifetimes的问题:为什么borrow checker过于严格
\n\n让我们回顾一下Rust中借用(borrowing)的工作原理。规则如下:
\n- \n
- 在任何时刻,要么有一个可变引用(&mut T),要么有任意数量的不可变引用(&T)。 \n
- 引用必须始终有效(不能引用已释放的数据)。 \n
在NLL之前,编译器根据引用的词法作用域来确定其生命周期。也就是说,从{到},即变量声明的地方。如果引用是在块内创建的,它会“存活”到该块的末尾,即使你不再使用它。
看看这个经典示例,它在旧版Rust中无法编译:
\n\nfn main() {\n let mut data = vec![1, 2, 3];\n\n let x = &data[0]; // 不可变引用\n\n data.push(4); // 尝试可变借用\n\n println!("{}", x); // 使用不可变引用\n}\n\n从人的角度来看:我们创建了引用x,然后修改了向量,最后使用了x。但是,当调用push时,向量data可能会重新分配内存,导致引用x失效。编译器是对的——这很危险。
但是,如果我们在修改之前使用引用呢?这才是真正的痛点:
\n\nfn main() {\n let mut name = String::from("Alice");\n\n let first_char = &name[0..1]; // 不可变引用\n println!("First char: {}", first_char); // 使用它\n\n name.push_str(" Johnson"); // 修改字符串\n println!("Full name: {}", name);\n}\n\n这段代码是安全的。我们使用了引用first_char,打印了它,然后才修改了字符串。但在Rust 2018之前,编译器会报错!为什么?因为引用first_char的词法生命周期持续到函数main的末尾,即到右花括号为止。而在这个区间内的可变借用(push_str)是被禁止的。
开发者不得不使用变通方法:引入额外的块{}来手动限制引用的生命周期:
fn main() {\n let mut name = String::from("Alice");\n\n {\n let first_char = &name[0..1];\n println!("First char: {}", first_char);\n } // 引用 first_char 在此处失效\n\n name.push_str(" Johnson");\n println!("Full name: {}", name);\n}\n\n这虽然可行,但不方便,用多余的括号污染了代码,并增加了阅读难度。尤其是在涉及多个借用的复杂函数中。
\n\nNon-lexical lifetimes(NLL):它是如何工作的
\n\nNLL的理念简单而巧妙:引用的生命周期不由作用域决定,而是由它实际被使用的位置决定。