Rust
设计的这个原则,究竟有没有必要呢?它又是如何在实际代码中起到“内存
安全”检查作用的呢?
第一个示例,我们用enum来说明。假如我们有一个枚举类型:
enum StringOrInt {
Str(String),
Int(i64),
}
它有两个元素,分别可以携带String类型的信息以及i64类型的信息。假如我们
有一个引用指向了它的内部数据,同时再修改这个变量,大家猜想会发生什么情
况?这样做可能会出现内存安全问题,因为我们有机会用一个String类型的指针指
向i64类型的数据,或者用一个i64类型的指针指向String类型的数据。完整示例如
下:
use std::fmt::Debug;
#[derive(Debug)]
enum StringOrInt {
Str(String),
Int(i64),
}
fn main() {
use StringOrInt::{Str, Int};
let mut x = Str("Hello world".to_string());
if let Str(ref insides) = x {
x = Int(1);
println!("inside is {}, x says: {:?}", insides, x);
}
}
在这段代码中,我们用if let语法创建了一个指向内部String的指针,然后在此指
针的生命周期内,再把x内部数据变成i64类型。这是典型的内存不安全的场景。
幸运的是,这段代码编译不通过,错误信息为:
error: cannot assign to x
because it is borrowed [E0506]
这个例子给了我们一个直观的感受,为什么Rust需要“可变性和共享性不能同时
存在”的规则?保证当前只有一个访问入口,这是保证安全的可靠做法。
内存不安全示例:迭代器失效
如果在遍历一个数据结构的过程中修改这个数据结构,会导致迭代器失效。比
如在C++里面,我们可能写出这样的代码:
#include <vector>
using namespace std;
int main() {
vector<int> v(10,10);
for (vector<int>::iterator i = v.begin(); i != v.end(); i++) {
if (*i % 2 == 0) { // when arbitrary condition satisfied
v.clear();
}
}
return 0;
}
编译,执行,发现程序崩溃了。原因就在于我们在迭代的过程中,数组v直接
被清空了,而迭代器并不知道这个信息,它还在继续进行迭代,于是出现了“野指
针”现象,此时迭代器实际上指向了已经被释放的内存。迭代器失效这样的问题在
C++中是“未定义行为”,也就是说可能发生什么后果都是未知的。这是一种典型的
内存不安全行为。
然而,在Rust里面,下面这样的代码是不允许编译通过的:
fn main() {
let mut arr = vec!["ABC", "DEF", "GHI"];
for item in &arr {
arr.clear();
}
}
为什么Rust可以避免这个问题呢?因为Rust里面的for循环实质上是生成了一个
迭代器,它一直持有一个指向容器的引用,在迭代器的生命周期内,任何对容器的
修改都是无法编译通过的。类似这样:
{ //以下是伪代码
// 在iter变量的生命周期内,它都持有一个指向arr的引用
let iter<'a> = into_iter(&'a arr);
loop {
match iter.next() {
// 如果需要使用 arr 的 &mut 指针,则会发生冲突
// &mut arr 和 &arr 不能同时存在,它违反了Rust内存安全的原则
Some(i) => { arr.clear(); }
None => break ,
}
}
}
在整个for循环的范围内,这个迭代器的生命周期都一直存在。而它持有一个指
向容器的引用,&型或者&mut型,根据情况而定。迭代器的API设计是可以修改当
前指向的元素,没办法修改容器本身的。当我们想在这里对容器进行修改的时候,
必然需要产生一个新的针对容器的&mut型引用,(clear方法的签名是Vec::
clear(&mut self),调用clear必然产生对原Vec的&mut型引用)。
这是与Rust的“alias+mutation”规则相冲突的,所以编译不通过。
为什么在Rust中永远不会出现迭代器失效这样的错误?
因为通过“mutation+alias”规则,就可以完全杜绝这样的现象,这个规则是Rust内存安全的
根,是解决内存安全问题的灵魂。Rust不是针对各式各样的场景,用case by case的
方式来解决内存安全问题。而是通过一种统一的机制,高屋建瓴地解决这一类问
题,快刀斩乱麻,直击要害。
面对类似迭代器失效这一类的、指针指向非法地址的内存安全问题,许多语言
都无法做到静态检查出来。比如在Java中出现这样的问题的时候,编译器是没法检
查出来的,在执行阶段,程序会抛出一个异常“Exception in
thread“main”java.util.ConcurrentModificationException”。
因为我们在for循环内部对容器本身做了修改,Java容器探测到了这种修改,然后就阻止了逻辑的继续执行,抛
出了异常。
Java的这个设计相比C++要好很多,因为即便出现了迭代器失效,最多引发异常,而并不会有“野指针”这样的内存安全问题,因为迭代器没有机会访问已经被释放的非法内存。然而“抛出异常”并不是一个完美的设计,只是不得已而为之
罢了。因为异常本来的设计目的是为了处理外部环境难以预计的错误的,而现在的这个错误实际上是程序的逻辑错误,即便抛出了异常,外部逻辑捕获了这个异常,也没什么好办法来处理。唯一合理的修复方案是,发现这样的异常之后,回过头来修复代码错误。这样的问题如果在编译阶段就能得到发现和解决,才是最合适的解决方案。在遍历容器的时候同时对容器做修改,可能出现在多线程场景,也可能出
现在单线程场景。
类似这样的问题依靠GC也没办法处理。GC只关心内存的分配和释放,对于变
量的读写权限是不关心的。GC在此处发挥不了什么作用。
而Rust依靠我们前面强调的“alias+mutation”规则就可以很好地解决该问题。这
个思路的核心就是:如果存在多个只读的引用,是允许的;如果存在可写的引用,
那么就一定不能同时存在其他的只读或者可写的引用。大家看到这个逻辑,是不是
马上联想到多线程环境下的“ReadWriteLocker”?事实也确实如此。
Rust检查内存安
全的核心逻辑可以理解为一个在编译阶段执行的读写锁。多个读同时存在是可以
的,存在一个写的时候,其他的读写都不能同时存在。
大家还记不记得,Rust设计者总结的Rust的三大特点:
一是快,
二是内存安全,
三是免除数据竞争。
由上面的分析可见,Rust所说的“免除数据竞争”,实际上和“内存安全”是一回事。“免除数据竞争”可以看作多线程环境下的“内存安全”。单线程环境下的“内存安全”靠的是编译阶段的类似读写锁的机制,与多线程环境下其他语言常用的读写锁机制并无太大区别。也正因为Rust编译器在设计上打下的良好基础,“内存安全”才能轻松地扩展到多线程环境下的“免除数据竞争”。
这两个概念其实只有一箭之隔。所以我们可以理解Java将此异常命名为“Concurrent”的真实含义
——这里的“Concurrent”并不是单指多线程并发。