原文链接:Understanding the Rust borrow checker
初尝Rust的这一天终于到来了,你满怀期待地写下几行 Rust
代码,然后在命令行输入cargo run
指令,等待着编译通过。之前就听说过,Rust
是一门只要编译能通过,就能运行地语言,你兴奋地等待着程序是否会正常运行。编译跑起来了,然后立马输出了错误:
error[E0382]: borrow of moved value
看来你是遭遇了“借用检查器”的问题。
什么是借用检查器?
借用检查器是Rust之所以为Rust的基石之一,它能够帮助(或者说是强迫)你管理“所有权”,即官方文档第四章介绍的ownership
:“Ownership 是Rust最特别的特征,它确保Rust不需要垃圾回收机制也能够保证内存安全”。
所有权,借用检查器以及垃圾回收:这些概念展开讲能讲很多,本文将介绍借用检查器能为我们做什么(能阻止我们做什么),以及它和其他内存管理机制的区别。
本文假设你对高级语言有,比如Python,JavaScript或C#之类的一定了解就行,不要求计算机内存工作原理相关的知识。
垃圾回收 vs. 手动内存分配 vs. 借用检查
对于很多常用的编程语言,你都不用考虑变量是存在哪儿的,直接声明变量,剩下的部分,语言的运行时环境会通过垃圾回收来处理。这种机制抽象了计算机内存管理,使得编程更加轻松统一。
不过这就需要我们额外深入一层才能展示它和借用检查的区别,就从栈 stack
和堆 heap
开始吧
栈与堆
我们的程序有两种内存来存值,栈 stack
和堆 heap
。他们的区别有好些,但我们只用关心其中最重要的一点:栈上存储的必须是大小固定的数据,存取都很方便,开销小;像字符串(可变长),列表和其它拥有可变大小的集合类型数据,存储在堆上。因此计算机需要给这些不确定的数据分配足够大的堆内存空间,这一过程会消耗更多的时间,并且程序通过指针访问它们,而不能像栈那样直接访问。
总结来说,栈内存存取数据快速,但要求数据大小固定;堆内存虽然存取速度慢些,但是对数据的要求宽松。
垃圾回收
在带有垃圾回收机制的语言中,栈上的数据会在超出作用域范围时被删除,堆上的数据不再使用后会由垃圾回收器处理,不需要程序员去具体关心堆栈上发生的事情。
但是对于像 C
这样的语言,要手动管理内存。那些在更高级的语言中随便就可以简单初始化的列表,在C语言中需要手动分配堆内存来初始化,而且数据不用了还需要手动释放这块儿内存,否则就会造成内存泄漏,而且内存只能被释放一次。
这种手动分配手动释放内存的过程容易出问题。微软证实他们70%的漏洞都是内存相关的问题导致的。既然手动操作内存的风险这么高,为什么还要使用呢?因为相比垃圾回收机制,它具备更高的控制力和性能,程序不用停下来花时间检查哪些内存需要被释放。
而 Rust
的所有权机制就处在二者之间。通过在程序中记录数据的使用并遵循一定的规则,借用检查器能够判断数据在什么时候能够初始化,什么时候能被释放(在Rust中释放被称作 drop
),结合了垃圾回收的便利与手动管理的性能,就像一个内嵌在语言中的内存管理器。
在实操中,在所有权机制下我们可以对数据进行三种操作方式:
- 直接将数据的所有权移交出去
- 拷贝一份数据,单独将拷贝数据的所有权移交出去
- 将数据的引用移交出去,保留数据本身的所有权,让接收方暂时“借用”(
borrow
)
使用哪种方式依据场景而定。
借用检查器的其它能力:并发
除了处理内存的分配与释放,借用检查器还能阻止数据竞争,正如Rust所谓的“无惧并发”,让你毫无顾虑地进行并发、并行编程。
缺点
美好的事物总是伴随着代价,Rust的所有权系统同样也有缺陷,事实上如果不是这些缺陷,我也不会专门写这篇文章。
比较难上手,是借用检查机制的一大缺点。Rust社区中不乏被它折磨的新人,我自己也在掌握它上面花费了很多时间。
举个例子,在借用机制下,共享数据会变得比较繁琐,尤其是共享数据的同时还要改变数据的场景。很多其它语言中非常简便就能创建的数据结构,在Rust中会比较麻烦。
但是当你理解了它,编写Rust代码会更顺手。我很喜欢社区里的一句话:
借用机制的几条规则,就像拼写检查一样,如果你一点儿都不理解他们,那你写出来的代码基本都是错的。心平气和地理解了它们,才会写出正确的代码。
几条基本规则:
- 每当向一个方法传递参数变量(非变量的引用)时,都是将该变量的所有权转移给调用的方法,此后你就不能再使用它了。
- 每当传递变量的引用(即所谓的借用),你可以传递任意多个不可变引用,或者一个可变引用。也就是说可变引用只能有一个。
实践
理解了借用检查机制后,现在实践一下。我们将使用Rust中可变长度的list: Vec
类型(类似Python中的 list
和 JavaScript中的 Array
),可变长度的特性决定了它需要使用堆内存来存储。
这个例子比较刻意,但它能很好的说明上述的规则。我们将创建一个 vector
,将它作为参数传递给一个函数进行调用,然后看看在里面会发生什么。
注意:下面这个代码实例不会通过编译
fn hold_my_vec(_: Vec) {}
fn main() {
let v = vec![2, 3, 5, 7, 11, 13, 17];
hold_my_vec(v);
let element = v.get(3);
println!("I got this element from the vector: {:?}", element);
}
运行后,会得到如下错误:
error[E0382]: borrow of moved value: `v`
--> src/main.rs:6:19
|
4 | let v = vec![2, 3, 5, 7, 11, 13, 17];
| - move occurs because `v` has type `std::vec::Vec`, which does not implement the `Copy` trait
5 | hold_my_vec(v);
| - value moved here
6 | let element = v.get(3);
| ^ value borrowed here after move
这个报错信息告诉我们 Vec
没有实现 Copy
特性(trait
),因此它的所有权是被转移(借用)了,无法在这之后再访问它的值。只有能在栈上存储的类型实现了 Copy
特性,而 Vec
类型必须分配在堆内存上,它无法实现该特性。我们需要找到另一种手段来处理类似情况。
Clone
虽然 Vec
类型变量不能实现 Copy
特性,但它实现了 Clone
特性。在Rust中,克隆是另一种复制数据的方式。与copy只能对栈上的数据进行拷贝、开销小的特点不同,克隆也可以面向堆数据,并且开销可以很大。
回到上面的例子中,传值给函数的场景,那我们给它一个向量的克隆也可以大道目的。如下代码可以正常运行:
fn hold_my_vec(_: Vec) {}
fn main() {
let v = vec![2, 3, 5, 7, 11, 13, 17];
hold_my_vec(v.clone());
let element = v.get(3);
println!("I got this element from the vector: {:?}", element);
}
但这个代码实际做了很多无用功,hold_my_vec
函数都没使用传入的向量,只是接收的它的所有权。并且例子中的向量非常小,克隆起来没什么负担,对于刚开始接触rust开发的阶段,这样可以方便地看到结果。实际上也有更好的方式,下面就来介绍。
引用
除了直接将变量的值所有权移交给函数,还可以把它“借”出去。我们需要修改下 hold_my_vec
的函数签名,让它接收的参数从 Vec
更改为 &Vec
,即引用类型。调用该函数的方式也需要修改下,让Rust编译器知道只是将向量的引用———— 一个借用值,交给函数使用。这样函数就是会短暂地借用这个值,在之后的代码中仍然可以使用它。
fn hold_my_vec(_: &Vec) {}
fn main() {
let v = vec![2, 3, 5, 7, 11, 13, 17];
hold_my_vec(&v);
let element = v.get(3);
println!("I got this element from the vector: {:?}", element);
}
总结
这篇文章只是对借用检查机制简短地概览,介绍它会做什么,以及为什么这么做。更多的细节就留给读者自己挖掘了。
实际上,随着你的程序代码量扩张,你会遭遇更多棘手的问题,需要围绕所有权和借用机制展开更深入的思考。甚至为了贴合Rust的借用机制,你得重新设计代码的组织结构。
Rust的学习曲线确实比较陡峭,但只要持续学习,你总能一路向上。