Rust 在内存安全方面的设计方案的核心思想是"共享不可变,可变不共享"。
在可变性控制方面,如果说, C 语言和函数式编程语言分属一个天平的两端,那么Rust 就处于这个天平的中央。C 语言的思想是:尽量不对程序员做限制,尽量接近机器底层,类 型安全、可变性、共享性都由程序员自由掌控,语言本身不提供太多的限制和规定。安全与 否,也完全取决于程序员。而函数式编程的思想是:尽量使用不可变绑定,在可变性上有严 格限制,在共享性方面没有限制。函数式编程特别强调无副作用的函数以及不可变类型,以 此来达到提高安全性的目的。
而 Rust 则是选择了折中的方案,既允许可变性,也允许共享性,只要这两者不是同时出 现即可。"共享不可变,可变不共享",是Rust 保证内存安全和线程安全的"法宝"。而我们 可以看到,Rust 的这个设计并不是首鼠两端、和稀泥式的中庸之道,而是经过了仔细的观察 总结、严谨的设计之后的产物。
-
其一,相比函数式设计方式, Rust并没有本质上牺牲安全性。函数式编程强调的"不可 变"特性,极大地提升了安全性的同时,也极大地提高了学习门槛。而Rust 在"不可变"要 求上的理性妥协,实现了在不损失安全性的同时, 一定程度上也降低了学习成本。从C/C++ 背景转为使用Rust 无需做太大的思维转变。相比函数式的设计方式, Rust 的入门门槛更低。 虽然对于习惯了无拘无束自由挥洒的C/C++ 编程语言的朋友来说,还是有诸多不习惯,但毕 竟比Haskell 要容易得多。
-
其二,Rust 针对传统C/C++ 做了大幅改进,设计了一系列静态检查规则,来防止一些潜 在的bug 。"共享不可变,可变不共享"就是其中一项重要的规则。在传统的C/C++ 中,所 有的指针都是同一个类型。从功能性来说,这样设计是非常强大的,但它缺少的恰恰是一定 程度的取舍,以提高安全性。相对来说,Rust 对程序员的限制更多,有所为、有所不为。鼓 励用户使用的功能应当越容易越优雅越好;避免用户滥用的功能应当越困难越复杂越好。二 者不可偏废。
-
其三,Rust 的这套内存安全体系,不需要依赖GC 。虽然现在GC 的性能越来越好,但 是没有GC 在某些场景下依然是很重要的。没有GC 、编译型语言的特点,是Rust 执行性能 的潜力保证。这就是为什么Rust 设计组有底气说Rust 的运行性能与C 语言处于同一个档次 的原因。当然,目前的Rust 还很年轻,许多优化还没有实现,但这不要紧,单从技术层面 上看,还有许多优化在可行性上是没问题的,唯一需要的是时间和工作量。另外,没有GC 就可以使得它只依赖一个非常轻量级的runtime。理论上来说,它可以用于许多嵌入式平台,甚至可以在无操作系统的裸机上执行,使用Rust 编写操作系统也是完全可行的。这就使得 Rust拥有与C/C++ 相似的系统级编程特性,大幅扩展了Rust的应用场景。
-
其 四 ,Rust 的核心思想"共享不可变,可变不共享",具有极好的一致性和扩展性。它 不仅可以解决内存安全的问题,还是解决线程安全的基础。在后文中我们会看到,所谓的线 程安全,实质上就是内存安全在多线程情况下的自然延伸。反过来,我们也可以把Rust 的 内 存安全解决方案视为传统的线程安全机制Read Write Locker的编译阶段执行的版本。大家应 该都能联想到,在多线程环境下,数据竞争问题是怎么出现的。如果多个线程对同一个共享 变量都是只读的,它是安全的;如果有一个线程对共享变量写操作,那它就必须是独占的, 不可有其他线程继续读写,否则就会出现数据竞争。在第四部分中我们还会发现, Rust 里面 的许多线程安全的类型,与一些非线程安全的类型,具有非常有趣的对称性。
由此我们可以看出,Rust 的这套设计方案的确是有创新性的。它走出了一条前无古人的 道路。Rust 在其他方面的功能,都不能被称作原创设计,都是从其他编程语言中学过来的。 唯独安全性方面的设计是独一无二的。只要我们保证了"共享不可变,可变不共享",我们 就可以保证内存安全。那么它这套设计方案,究竟能不能被大众所接受呢?我们拭目以待。
另外,这个规定是否是过于严苛了呢?会不会大幅削弱代码的表达能力?后面我们还需 要进一步分析。
NLL(Non-Lexical-Lifetime)
Rust 防范"内存不安全"代码的原则极其清晰明了。如果你对同一块内存存在多个引用, 就不要试图对这块内存做修改;如果你需要对一块内存做修改,就不要同时保留多个引用。 只要保证了这个原则,我们就可以保证内存安全。它在实践中发挥了强大的作用,可以帮助 我们尽早发现问题。这个原则是Rust 的立身之本、生命之基、活力之源。
这个原则是没问题的,但是,初始的实现版本有一个主要问题,那就是它让借用指针的 生命周期规则与普通对象的生命周期规则一样,是按作用域来确定的。所有的变量、借用的 生命周期就是从它的声明开始,到当前整个语句块结束。这个设计被称为Lexical Lifetime, 因为生命周期是严格和词法中的作用域范围绑定的。这个策略实现起来非常简单,但它可能 过于保守了,某些情况下借用的范围被过度拉长了,以至于某些实质上是安全的代码也被阻 止了。在某些场景下,限制了程序员的发挥。
因此,Rust 核心组又决定引入Non Lexical Lifetime, 用更精细的手段调节借用真正起作 用的范围。这就是NLL。
js
首先,我们来看几个简单的示例。
use std::ascii::AsciiExt;
fn foo()->Vec<char>{
let mut data =vec!['a','b','c'];/1--+'scope
capitalize(&mut data[.]);
data.push('d');
data.push('e');
data.push('f');
capitalize(data:&mut [char]){for c in data{
c.make_ascii_uppercase();
}
main(){
let v =foo();
println!("{:?}",V);
}
这段代码是没有问题的。我们的关注点是foo() 这个函数,它在调用capitalize 函 数的时候,创建了一个临时的&mut 型引用,在它的调用结束后,这个临时的借用就终止了, 因此,后面我们就可以再用data 去修改数据。注意,这个临时的&mut 引用存在的时间很 短,函数调用结束,它的生命周期就结束了。
但是,如果我们把这段代码稍作修改,问题就出现了:
js
foo()->Vec<char>{
let mut data
let slice =&mut capitalize(slice);
data.push('d'); //ERROR
data.push('e'); //ERROR
data.push('f'); //ERROR data
vec!['a','b','c'];//--+'scope
data[.];//<-----------+'lifetime //
在这段代码中,我们创建了一个临时变量slice, 保存了一个指向data 的 &mut 型引 用,然后再调用capitalize函数,就出问题了。编译器提示为:
js
error[E0499]:cannot borrow 'data`as mutable more than once at a time
这是因为,Rust 规定"共享不可变,可变不共享",同时出现两个&mut 型借用是违反 规则的。在编译器报错的地方,编译器认为slice 依然存在,然而又使用data 去调用fn push(&mut self,value:T)方法,必然又会产生一个&mut 型借用,这违反了Rust 的原则。在目前这个版本中,如果我们要修复这个问题,只能这样做:
js
fn foo()->Vec<char>{
let mut data =vec!['a','b','c'];//--+'scope
data.push('d');
data.push('e');
data.push('f');
data
}
我们手动创建了一个代码块,让slice 在这个子代码块中创建,后面就不会产生生命 周期冲突问题了。这是因为,在早期的编译器内部实现里面,所有的变量,包括引用,它们 的生命周期都是从声明的地方开始,到当前语句块结束(不考虑所有权转移的情况)。
这样的实现方式意味着每个引用的生命周期都是跟代码块 (scope) 相关联的,它总是从 声明的时候被创建,在退出这个代码块的时候被销毁,因此可以称为Lexical lifetime。而本 章所说的Non-Lexical lifetime,意思就是取消这个关联性,引用的生命周期,我们用另外的、 更智能的方式分析。有了这个功能,上例中手动加入的代码块就不需要了,编译器应该能自 动分析出来,slice 这个引用在capitalize 函数调用后就再没有被使用过了,它的生命 周期完全可以就此终止,不会对程序的正确性有任何影响,后面再调用push 方法修改数据, 其实跟前面的slice 并没有什么冲突关系。
看了上面这个例子,可能有人还会觉得,显式的用一个代码块来规定局部变量的生命周 期是个更好的选择,Non-Lexical-Lifetime的意义似乎并不大。那我们再继续看看更复杂的例 子。我们可以发现,Non-Lexical-Lifetime 可以打开更多的可能性,让用户有机会用更直观的 方式写代码。比如下面这样的一个分支结构的程序:
js
fn process_or_default<R,V:Default>
(map:&mut HashMap<K,V>,key:K)
{
}
这段代码从一个HashMap 中查询某个key 是否存在。如果存在,就继续处理,如果 不存在,就插入一个新的值。目前这段代码是编译不过的,因为编译器会认为在调用get_mut(&key) 的时候,产生了一个指向map 的 &mut 型引用,而且它的返回值也包含了一个 引用,返回值的生命周期是和参数的生命周期一致的。这个方法的返回值会一直存在于整个 match 语句块中,所以编译器判定,针对map 的引用也是一直存在于整个match 语句块中 的。于是后面调用insert方法会发生冲突。
当然,如果我们从逻辑上来理解这段代码,就会知道,这段代码其实是安全的。因为在 None 分支,意味着map 中没有找到这个key, 在这条路径上自然也没有指向map 的引用存 在。但是可惜,在老版本的编译器上,如果我们希望让这段代码编译通过,只能绕一下。我 们试一下做如下的修复:
js
fn get_default1<'m,K,V:Default>(
map:&'m mut HashMap<K,V>,key:K)
->&'m mut V
{
}