Rust语言的核心特点是:在没有放弃对内存的直接控制力的情况下,实现了内存安全。
所谓对内存的直接控制能力,前文已经有所展示:可以自行决定内存布局,包括在栈上分配内存,还是在堆上分配内存;支持指针类型;可以对一个变量实施取地址操作;有确定性的内存释放;等等。
另一方面,从安全性的角度来说,我们可以看到,Rust有所有权概念、借用指针、生命周期分析等这些内容。
随便写个小程序都编译不通过,学习曲线非常陡峭。那么,Rust设计者究竟是如何考虑的这个问题,为什么要设计这样复杂的规则?Rust语言的这一系列安全规则,背后的指导思想究竟是什么呢?
总的来说,Rust的设计者们在一系列的“内存不安全”的问题中观察到了这样的一个结论:
首先我们介绍一下这两个概念Alias和Mutation。
- Alias的意思是“别名”。如果一个变量可以通过多种Path来访问,那它们就可以互相看作alias。Alias意味着“共享”,我们可以通过多个入口访问同一块内存。
- Mutation的意思是“改变”。如果我们通过某个变量修改了一块内存,就是发生了mutation。Mutation意味着拥有“修改”权限,我们可以写入数据。
Rust保证内存安全的一个重要原则就是,如果能保证alias和mutation不同时出现,那么代码就一定是安全的。
编译错误示例
它们主要关心的是共享和可变之间的关系。“共享不可变,可变不共享”是所有这些编译错误遵循的同样的法则。
下面我们通过几个简单的示例来直观地感受一下这个规则究竟是什么意思。
示例一
以上这段代码是可以编译通过的。其中变量绑定i、p1、p2指向的是同一个变量,我们可以通过不同的Path访问同一块内存p,*p1,*p2,所以它们存在“共享”。而且它们都只有只读的权限,所以它们存在“共享”,不存在“可变”。因此它一定是安全的。
示例二
我们让变量绑定i是可变的,然后在存在p1的情况下,通过i修改变量的值:
编译,出现了错误,错误信息为:
error:cannot assign to 'i’because it is borrowed [E0506]
这个错误可以这样理解:在存在只读借用的情况下,变量绑定i和p1已经互为alias,它们之间存在“共享”,因此必须避免“可变”。这段代码违反了“共享不可变”的原则。
示例三
如果我们把上例中的借用改为可变借用的话,其实是可以通过它修改原来变量的值的。以下代码可以编译通过:
那我们是不是说,它违反了“共享不可变”的原则呢?其实不是。因为这段代码中不存在“共享”。在可变借用存在的时候,编译器认为原来的变量绑定i已经被冻结(frozen),不可通过i读写变量。此时有且仅有p1这一个入口可以读写变量。证明如下:
在pl存在的情况下,不可通过i写变量。如果这种情况可以被允许,那就会出现多个入口可以同时访问同一块内存,且都具有写权限,这就违反了Rust的“共享不可变,可变不共享”的原则。错误信息为:
error:cannot assign to `i’because it is borrowed [E0506]
示例四
同时创建两个可变借用的情况:
编译错误信息为:
error:cannot borrow 'i’as mutable more than once at a time [E0499]
因为p1、p2都是可变借用,它们都指向了同一个变量,而且都有修改权限,这是Rust不允许的情况,因此这段代码无法编译通过。
正因如此,&mut型借用也经常被称为“独占指针”&型借用也经常被称为“共享指针”。
内存不安全示例:修改枚举
Rust设计的这个原则,究竟有没有必要呢?它又是如何在实际代码中起到“内存安全”检查作用的呢?
第一个示例,我们用enum来说明。假如我们有一个枚举类型:
它有两个元素,分别可以携带String类型的信息以及i64类型的信息。
假如我们有一个引用指向了它的内部数据,同时再修改这个变量,大家猜想会发生什么情况?这样做可能会出现内存安全问题,因为我们有机会用一个String类型的指针指向i64类型的数据,或者用一个i64类型的指针指向String类型的数据。完整示例如下:
在这段代码中,我们用if let语法创建了一个指向内部string的指针,然后在此指针的生命周期内,再把x内部数据变成i64类型。这是典型的内存不安全的场景。
幸运的是,这段代码编译不通过,错误信息为:
error:cannot assign to `x’because it is borrowed [E0506]
这个例子给了我们一个直观的感受,为什么Rust需要“可变性和共享性不能同时存在”的规则?保证当前只有一个访问入口,这是保证安全的可靠做法。
内存不安全示例:迭代器失效
如果在遍历一个数据结构的过程中修改这个数据结构,会导致迭代器失效。
在Rust里面,下面这样的代码是不允许编译通过的:
为什么 Rust可以避免这个问题呢?因为Rust里面的for循环实质上是生成了一个迭代器,它一直持有一个指向容器的引用,在迭代器的生命周期内,任何对容器的修改都是无法编译通过的。类似这样:
在整个for循环的范围内,这个迭代器的生命周期都一直存在。
而它持有一个指向容器的引用,&型或者&mut型,根据情况而定。
迭代器的API设计是可以修改当前指向的元素,没办法修改容器本身的。
当我们想在这里对容器进行修改的时候,必然需要产生一个新的针对容器的&mut型引用,(clear方法的签名是Vec::clear(&mut self),调用clear必然产生对原Vec的amut型引用)。这是与Rust的“alias+mutation”规则相冲突的,所以编译不通过。
为什么在Rust中永远不会出现迭代器失效这样的错误?因为通过“mutation+alias”规则,就可以完全杜绝这样的现象,这个规则是Rust内存安全的根,是解决内存安全问题的灵魂。
Rust不是针对各式各样的场景,用case by case的方式来解决内存安全问题。而是通过一种统一的机制,高屋建瓴地解决这一类问题,快刀斩乱麻,直击要害。
这样的问题如果在编译阶段就能得到发现和解决,才是最合适的解决方案。在遍历容器的时候同时对容器做修改,可能出现在多线程场景,也可能出现在单线程场景。
类似这样的问题依靠GC也没办法处理。GC只关心内存的分配和释放,对于变量的读写权限是不关心的。GC在此处发挥不了什么作用。
而Rust依靠我们前面强调的“alias+mutation”规则就可以很好地解决该问题。这个思路的核心就是:如果存在多个只读的引用,是允许的;如果存在可写的引用,那么就一定不能同时存在其他的只读或者可写的引用。
大家看到这个逻辑,是不是马上联想到多线程环境下的“ReadWriteLocker”?事实也确实如此。Rust检查内存安全的核心逻辑可以理解为一个在编译阶段执行的读写锁。多个读同时存在是可以的,存在一个写的时候,其他的读写都不能同时存在。
大家还记不记得,Rust设计者总结的Rust的三大特点:一是快,二是内存安全,三是免除数据竞争。
由上面的分析可见,Rust所说的“免除数据竞争”,实际上和“内存安全”是一回事。
“免除数据竞争”可以看作多线程环境下的“内存安全”。单线程环境下的“内存安全”靠的是编译阶段的类似读写锁的机制,与多线程环境下其他语言常用的读写锁机制并无太大区别。
也正因为Rust编译器在设计上打下的良好基础,“内存安全”才能轻松地扩展到多线程环境下的“免除数据竞争”。
这两个概念其实只有一箭之隔。所以我们可以理解Java将此异常命名为“Concurrent”的真实含义——这里的“Concurrent”并不是单指多线程并发。
内存不安全示例:悬空指针
我们再使用一个例子,来继续说明为什么Rust的“mutation+alias”规则是有必要的。我们这次通过制造一个悬空指针来解释。
同样,使用动态数组类型,使用一个指针指向它的第一个元素,然后在原来的动态数组中插入数据:
编译不通过,错误信息为:
error:cannot borrow `arr’as mutable because it is also borrowed as immutable
我们可以看到,“mutation+alias”规则再次起了作用。在存在一个不可变引用的情况下,我们不能修改原来变量的值。
写Rust代码的时候,经常会有这样的感觉:Rust编译器极其严格,甚至到了“不近人情”的地步。
但是大部分时候却又发现,它指出来的问题的确是对我们编程有益的。对它使用越熟练,越觉得它是一个好帮手。