Rust 的所有权
从浅拷贝说起
现在我要写一段代码完成以下工作:
- 创建一个字符串变量
S1
值为"Hello"
- 将
S1
浅拷贝(shallow copy)给S2
- 在
S2
的后面加上" World"
- 输出
S1
的值
浅拷贝不会将整个对象复制一份,而是将 S1
的「引用」赋值给 S2
,这样 S2
和 S1
指代的实际上时相同的一块字符串。因此当 S2
改变时,S1
也会随之改变;在这个例子中如果一切正常,程序应该会输出 "Hello World"
。
显然,各种语言都能实现上述功能。C / C++ 可以直接用指针实现浅拷贝:
char s1[16] = "Hello";
char* s2 = s1;
strcat(s2, " World");
printf("%s", s1);
Java 因为比起上面说的这俩稍微高层一些,它将「连接字符串」这种函数封装进了类,代码读起来也比较赏心悦目:
StringBuffer s1 = new StringBuffer("Hello");
StringBuffer s2 = s1;
s2.append(" World");
System.out.println(s1);
需要注意的是,Java 中的 String 类型是不可变的(Immutable),因此想要支持上述操作需要使用 StringBuffer。如果这里使用的是 String 类型,结果会输出
Hello
:Java 的 String 类型在执行s2 += s1
时并非把s1
的值直接添加到s2
后面,而是创建了一个临时的s3 = s1 + s2
,然后舍弃原来s2
的值并把s3
的值传给s2
。
其它语言就不一一列举了。这个功能实现起来很简单,各种语言实现起来基本都只是语法有少许差别。
然而,如果你按照这种思路把上述代码「翻译」成 Rust 的代码,你会发现这是行不通的:
let mut s1 = String::from("Hello");
let mut s2 = s1;
s2.push_str(" World");
println!("{}", s1);
上述代码会在编译时报错:
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:20
|
2 | let mut s1 = String::from("Hello");
| ------ move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let mut s2 = s1;
| -- value moved here
4 | s2.push_str(" World");
5 | println!("{}", s1);
| ^^ value borrowed here after move
|
这是为什么?什么叫 borrow of moved value?借用了一个已经搬走的值?
优雅离场
先过会儿解释 Rust 编译器给出的报错,我们从另一条路出发。
上面这些代码在运行结束之后,声明的 s1
和 s2
这两个变量不再使用了。假如这是庞大系统之中的一小部分代码,我们希望这部分用来储存字符串的空间可以被回收重复利用,以后也许能用来存储其它变量。如果只分配不回收,可用的空间就会越来越少,少到以后分配变量都以及找不到足够的空间,程序就会因为空间太过狭小而「被憋死了」。这就是经典的**内存泄漏(memory leak)**问题。
那应该如何回收空间呢?注意,s2
是 s1
的浅拷贝,也就是说 s2
和 s1
指向的是同一块内存,引用的同一个字符串对象。如果让程序自动完成这个回收过程,s1
和 s2
会先后分别执行一次释放操作,这叫二次释放(double free),而这是有着潜在隐患的。设想一下,假如 s1
占用的空间在回收之后接着被分配给了另一个变量,这时再执行 s2
的释放操作,因为它们指向的是同一块空间,系统就会把刚刚分配出去的空间再次回收。人家另一个变量明明正常运作着却被操作系统清理掉了,将来就有可能会分配给别的对象,访问这个变量就会访问到不属于它的内容,从而引起一连串的连锁反应。而就算是 s1
被回收之后没有立即再被分配,根据操作系统管理内存的方式不同,「释放一块已经被释放的空间」也会导致各种不确定、难以预测的行为发生,例如之后这块内存可能被分配出去两次。总而言之,二次释放属于代码中的严重 bug,是我们都不愿意看到的。
正确的清理方式应该是释放 s1
和 s2
其中之一,另一个便可自然失效。问题是,编译器并不知道回收哪一个,也不知道这块空间都被谁持有着。假如之后我让 s3 = s2
,又让 s4 = s3
,一块空间被引用出去很多次,编译器是找不回这所有的引用者的,也就没法确定一个变量离开作用域后应不应该释放它的内存,这个过程只能由作为程序员的你来完成。而屏幕前的你是人类(不出意外的话应该是),你需要在大脑中完成这复杂的编译过程找到所有的引用对象,然后在正确的时间正确的位置精准将他们释放掉。少释放一个就会 memory leak,多释放一个就会 double free,你承担着至关重要、但也不能出错的工作。
部分比较原始的语言(例如 C / C++)都采用了这种方式,把变量的生杀大权交给你自己。理论上只要你不出错,程序可以以非常高效的方式健康运行。然而这极其考验程序员的内力,因为一旦出错并不会立即出现故障,往往因为各种并发症导致各种看不懂的出错信息,常常会让程序员陷入无休止的 debug 中。
垃圾回收车
在受够了与内存管理斗智斗勇之后,以 Java 为代表的语言提出了另一种策略。Java 开来了一辆「垃圾回收车」,让你只需要忙于处理逻辑,垃圾回收车来负责定期回收空间,它会主动拜访你的堆内存空间,如果发现它不再被使用就把它装车拉走。这在 Java 中叫 Garbage Collector(GC),它会伴随你的程序一起运行在 JVM 中(有关 GC 的机制可以参考 Java 的相关文档,这里就不多说了)。其他一些语言例如 javascript 、python 也有类似的垃圾回收机制。
这套机制确实是可行的,关于在代码运行时如何扫描内存并检测那些无法访问到的内存已经有了很成熟的方案。有了它,你就可以不再操心释放内存的问题了。不过,也正因为垃圾回收这一伴生程序与真正处理逻辑的代码一起运行,它会分走一些 CPU 性能,导致这些语言的代码效率受损,运行起来没有底层语言那么快。在一些逻辑相对简单的地方(比如上面拼接 "Hello World"
的程序)程序员是可以看出需要回收哪些空间从而手动执行释放的,但在有 Garbage Collection 机制的语言中通常都不允许你手动释放内存,而需要等待垃圾车统一管理,这就又减少了性能上的优化空间。
除此之外,还有一些比较小众的解决方式。C++ 11 中新提出了一种「智能指针」的概念,可以使用 std::shared_ptr
来管理引用对象。它会在对象内部记录「有多少引用正在指向这个对象」这个数值,将其赋值可以增加 1,引用失效会减少 1,当它检测到这个值为 0 时便可自动删除。这种策略并没有把问题彻底解决,代码中仍然有可能存在「互相引用」的对象释放不掉,同时这种策略毕竟多分配了储存空间也多做了检查运算,多多少少会对性能产生影响。
多少年来程序员们在开发难度和性能之间反复取舍,想出了各种方案,然而自动管理就牺牲运行时性能,手动管理就增加代码开发难度,一部分程序员自认为有足够的功力就去啃 C 这块硬骨头,另一部分则想办法优化 GC 的性能,让它尽可能少占一些性能。然而无论哪种方式都并不能做到尽善尽美,这似乎成为了一对不可调节的矛盾。
直到 Rust 出场。
所有权
Rust 采用了一种与之前所有语言都不同的处理方式。还是之前的任务:
let mut s1 = String::from("Hello");
let mut s2 = s1;
s2.push_str(" World");
println!("{}", s1);
因为在 Rust 中 String 不是固定长度的基本类型,因此第 2 行的赋值默认只会进行浅拷贝,这样第 1~2 行创建了 s1
和 s2
两个指向同一个对象的 String,如果不做处理就即将面对前面的 double free 问题。Rust 为了避免这个问题,一旦将 s1
赋值给别的对象 s2
,它会立即让 s1
失效;此时再想要输出 s1
的值,就会编译失败: s1
已经被「移交(move)」给了其它变量。
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:20
|
2 | let mut s1 = String::from("Hello");
| ------ move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let mut s2 = s1;
| -- value moved here
4 | s2.push_str(" World");
5 | println!("{}", s1);
| ^^ value borrowed here after move
|
既然 s1
已经失效,当 s1
离开作用域时自然不需要释放内存,只需要等待有效的 s2
释放内存即可。
这就是 Rust 著名的「所有权」机制。每一个时刻,只能有唯一的变量持有一个对象的所有权,同时也只有持有所有权的变量离开作用域时才会释放对象占用的空间,于是同一个对象的空间就只会被释放一次。失去对象所有权的变量离开作用域时不会对对象本身产生任何影响:
let s1;
{
let s2 = String::from("Hello");
s1 = s2;
}
println!("{}", s1);
第 5 行当 s2
离开作用域时,因为 "Hello"
这个字符串的所有权已经被移交给了 s1
,所以 s2
不拥有任何变量的所有权,因此 "Hello"
这个对象并不会被释放,也能正常被输出。
如果你是第一次接触所有权模型,你也许会跟我一样被这种全新的变量空间管理方式震惊到,觉得它实在超出了一般的编程思维逻辑,然后仔细一想又好像有点道理。停下来仔细想一想,然后再继续阅读下面的内容。
借用
最初一想这想法确实很新颖,也确实足够安全。但再仔细一想,这会给代码编写增添很多麻烦。设想我们需要一个函数来获取字符串长度,接受一个字符串作为参数,并以它的长度为返回值:
fn main() {
let s1 = String::from("Hello");
let length = get_length(s1);
println!("{}", length);
}
fn get_length(s: String) -> usize {
return s.len();
}
这段代码确实能正常工作,运行结果会输出 5 也符合预期。然而,在调用完 get_length
函数后,你会发现 s1
变量此时已经失效了,再访问或输出它会报错。为什么呢?
上面说过,赋值操作会导致变量的所有权发生移交。实际上,在调用函数的时候也会发生变量向形参的隐式赋值过程。上述代码中第 3 行调用了 get_length
这个函数,其中会发生变量 s1
向函数形参 s
的赋值,而这会将 s1
的所有权移交给 s
;随着第 9 行子函数结束,s
离开作用域,因为它持有字符串对象的所有权,因此将字符串空间释放,回到主函数中 s1
就已经失去了所有权。
想要解决这个问题也不难:函数不仅在调用时会进行隐式赋值,在返回时也会将返回值隐式赋值给接受函数返回值的变量。利用这一点,我们可以将函数参数的所有权移交回去:
fn main() {
let s1 = String::from("Hello");
let (len_s1, s1) = get_length(s1);
println!("{} {}", s1, len_s1);
}
fn get_length(s: String) -> (usize, String) {
return (s.len(), s);
}
上述这段代码虽然可以解决问题,但这个过程是很违背直觉的。就像是假期女朋友跟兄弟几个一起出去玩了一趟,回来发现原本我的女朋友变成了兄弟的女朋友。用 Rust 的话讲,「女朋友」的所有权被「移交」给了兄弟;到最后我还得主动要求兄弟把女朋友还回来(根据真实故事改编,故事主人公不是我);这个过程显然不符合常理(在这个比喻中也不道德,我们予以谴责)。
而在编程中,调用函数又是一种非常频繁的操作,总不能为了归还所有权,我还要在每个函数最后都把所有的参数都返回一遍吧?针对这种情况,Rust 提供了另一种操作:借用。
fn main() {
let s1 = String::from("Hello");
let length = get_length(&s1);
println!("{} {}", length, s1);
}
fn get_length(s: &String) -> usize {
return s.len();
}
借用可以获取对象的值,但不会给予其所有权,对象所有权仍在原来的变量手中。代码中借用用 AND 符号 &
来标记,有点类似于 C 中的引用。当这个借用走出作用域时,因为借用不持有对象的所有权,因此不会释放对象这块空间,原来的变量仍然可以正常操作这块对象。这依然符合「每个对象只会被释放一次」这个原则。实际上,Rust 保证的是每个可以访问对象的变量是有差异的,其中有且只有一个是主变量,其余的要么是借用要么失效,只有主变量拥有对象的释放权。
需要注意的是,借用需要依附于变量存在。如果在某个借用正常存在的过程中,拥有所有权的变量本身先走出了作用域,那借用指向的对象就会被释放,导致借用访问到非法内存。为了避免这种情况,Rust 规定,变量自身必须要比借用活得长。这个「活得长」并不是我的通俗解释,而是如果你出现了这种错误,Rust 编译器真的会报「它活得不够长」这个错:
let &s1;
{
let s = String::from("Hello");
s1 = &s;
}
println!("{}", s1);
error[E0597]: `s` does not live long enough
--> src/main.rs:5:14
|
5 | s1 = &s;
| ^^ borrowed value does not live long enough
6 | }
| - `s` dropped here while still borrowed
7 | println!("{}", s1);
| -- borrow later used here
将错误提前
如果你看的足够仔细,你会发现上述所有没有遵循所有权的操作都会在编译时报错。Rust 对于所有权的检查是在编译时进行的,它不会让你把错误留到程序运行时,而强制要求你在编写代码的时候就想好应该如何应对可能发生的种种情况。
其实随着编程语言的发展,各种新语言的发展趋势基本都顺应了这条原则:将运行时错误提前到编译时发现。虽然 Alan Turing 已经证明了不可能避免所有的运行时错误发生,但我们平时碰到的大多数运行时出现的错误其实都相当低级,真正涉及到数学逻辑的 bug 少之又少。那种找 bug 找一天改 bug 十秒钟的故事相信你一定碰到过,这种 bug 才是我们平时想要尽可能减少和避免的。
近些年来逐渐发展起来的各种语言已经证明了这一点,它们从设计之初就以优化开发体验、提升开发效率为目标,尽可能地让编译器更聪明,能提前发现更多的运行时异常。Typescript 其实说到底主要就做了「将错误提前」这一件事,就单单它能大幅减少类型错误这一点,哪怕多写一些类型定义代码都已经让无数程序员趋之若鹜。
也正因为所有权的检查是在编译时完成的,整套机制不会影响运行时的性能。它真正做到了二者兼得:既得到了 C++ 那般的运行效率,又得到了 Java 那般的内存安全易于管理。