你是否也有过这样的感觉:刚开始学习一门新语言时,本以为能像冲刺一样飞速前进,结果却在第一步就绊了个跟头?这就是我学习 Rust 时的经历。我有一个写得相当不错的 JavaScript 工具函数——功能很简单,就是解析并修改几个数组——我心想,趁着喝杯咖啡的功夫就能把它重写出来。我打开编辑器,敲出了一些感觉很自然的逻辑,然后运行编译器。结果,它“爆炸”了。不是因为语法错误,也不是因为少了分号。它告诉我,我试图使用一个已经被“移动”(moved)了的值。我盯着屏幕发呆。移动?我什么都没移动啊。我只是把它赋值给了另一个变量而已。

JavaScript 的舒适区

JavaScript 训练你像对待公共白板一样对待数据。你声明一个对象或数组,把它交给一个函数,让该函数添加一个属性,然后再把它传给别处。垃圾回收器(garbage collector)就像一个隐形的清洁工,在后台待命,随时准备清理你丢弃的一切。你从不会问:“这个字符串的所有权归谁?”你只会问:“我能读取它吗?”如果答案是肯定的,你就可以操作它。如果你需要另一个引用,只需写下 let b = a 然后继续即可。两个变量都指向同一块内存区域,如果 a 修改了它,b 会立即看到变化。这很方便,但也充满了混乱,不过这种混乱很少会让你伤筋动骨,因为运行时(runtime)会处理清理工作。

第一次编译器的冲击

Rust 不信任你。这听起来很严厉,但这是你学到的第一件事。这门语言围绕着一套拥有三条严格规则的所有权系统(ownership system)构建。第一,每个值有且只有一个所有者。第二,同一时间只能有一个所有者。第三,当该所有者超出作用域时,Rust 会自动释放(drop)该值。没有垃圾回收器,除非你显式开启,否则后台也没有引用计数。只有这三条规则,在你的代码运行之前,由编译器强制执行。

当我编写那个 JavaScript 工具函数的 Rust 版本时,我做了一些感觉很自然的操作。我创建了一个 vector(Rust 中对应数组的数据结构),并将其赋值给第二个变量。然后我尝试使用第一个变量。编译器拒绝了。在 JavaScript 中,let b = a 复制的是引用。而在 Rust 中,它移动了所有权(moves ownership)。原始变量变得无效了。编译器这样做是为了保证你永远不会有两个路径意外地踩在同一块内存上,而这正是其他系统中数据竞争(data races)和释放后使用(use-after-free)漏洞产生的原因。

为什么 Rust 要夺走你的“玩具”

看看 JavaScript 中的这种模式:

let a = [1, 2, 3];
let b = a;
a.push(4);
// Both a and b see 4.

这是你的本能反应,你甚至不会思考。但在 Rust 中,同样的直觉会在你写完之前就被编译器拒绝。一旦 b 拥有了该 vector,a 就成了一个空壳。你不能向它 push 数据,甚至连看一眼都不行。内存现在属于 b

这感觉像是一种惩罚,直到你意识到它防止了什么。如果两个变量可以在没有协调的情况下同时修改同一块堆(heap)数据,你就会面临内存损坏的风险。Rust 通过让转移过程变得显式,消除了这一整类 Bug。编译器并不是在吹毛求疵,它是在充当守门员。它强迫你在每一步都做出决定:代码的哪一部分负责哪一部分数据。

借用:一个实际上是功能的“变通方案”

当然,如果每一次赋值都会永久转移所有权,那么编写程序将会精疲力竭。你将不得不克隆(clone)一切,白白浪费内存和速度。Rust 通过“借用”(borrowing)解决了这个问题。

借用允许你在不获取所有权的情况下使用一个值。借用有两种类型,两者的区别非常重要。

  • 不可变借用,写作 &T 它们允许你读取数据。你可以根据需要拥有任意多个 at the same