理解Rust的 borrow checker

原文链接:Understanding the Rust borrow checker
理解Rust的 borrow checker
文章图片

初尝Rust的这一天终于到来了,你满怀期待地写下几行 Rust 代码,然后在命令行输入cargo run指令,等待着编译通过。之前就听说过,Rust 是一门只要编译能通过,就能运行地语言,你兴奋地等待着程序是否会正常运行。编译跑起来了,然后立马输出了错误:

error[E0382]: borrow of moved value

看来你是遭遇了“借用检查器”的问题。
什么是借用检查器? 借用检查器是Rust之所以为Rust的基石之一,它能够帮助(或者说是强迫)你管理“所有权”,即官方文档第四章介绍的ownership:“Ownership 是Rust最特别的特征,它确保Rust不需要垃圾回收机制也能够保证内存安全”。
【理解Rust的 borrow checker】所有权,借用检查器以及垃圾回收:这些概念展开讲能讲很多,本文将介绍借用检查器能为我们做什么(能阻止我们做什么),以及它和其他内存管理机制的区别。
本文假设你对高级语言有,比如Python,JavaScript或C#之类的一定了解就行,不要求计算机内存工作原理相关的知识。
垃圾回收 vs. 手动内存分配 vs. 借用检查 对于很多常用的编程语言,你都不用考虑变量是存在哪儿的,直接声明变量,剩下的部分,语言的运行时环境会通过垃圾回收来处理。这种机制抽象了计算机内存管理,使得编程更加轻松统一。
不过这就需要我们额外深入一层才能展示它和借用检查的区别,就从栈 stack 和堆 heap 开始吧
栈与堆
我们的程序有两种内存来存值,栈 stack 和堆 heap 。他们的区别有好些,但我们只用关心其中最重要的一点:栈上存储的必须是大小固定的数据,存取都很方便,开销小;像字符串(可变长),列表和其它拥有可变大小的集合类型数据,存储在堆上。因此计算机需要给这些不确定的数据分配足够大的堆内存空间,这一过程会消耗更多的时间,并且程序通过指针访问它们,而不能像栈那样直接访问。
总结来说,栈内存存取数据快速,但要求数据大小固定;堆内存虽然存取速度慢些,但是对数据的要求宽松。
垃圾回收
在带有垃圾回收机制的语言中,栈上的数据会在超出作用域范围时被删除,堆上的数据不再使用后会由垃圾回收器处理,不需要程序员去具体关心堆栈上发生的事情。
但是对于像 C 这样的语言,要手动管理内存。那些在更高级的语言中随便就可以简单初始化的列表,在C语言中需要手动分配堆内存来初始化,而且数据不用了还需要手动释放这块儿内存,否则就会造成内存泄漏,而且内存只能被释放一次。
这种手动分配手动释放内存的过程容易出问题。微软证实他们70%的漏洞都是内存相关的问题导致的。既然手动操作内存的风险这么高,为什么还要使用呢?因为相比垃圾回收机制,它具备更高的控制力和性能,程序不用停下来花时间检查哪些内存需要被释放。
Rust 的所有权机制就处在二者之间。通过在程序中记录数据的使用并遵循一定的规则,借用检查器能够判断数据在什么时候能够初始化,什么时候能被释放(在Rust中释放被称作 drop),结合了垃圾回收的便利与手动管理的性能,就像一个内嵌在语言中的内存管理器。
在实操中,在所有权机制下我们可以对数据进行三种操作方式:
  1. 直接将数据的所有权移交出去
  2. 拷贝一份数据,单独将拷贝数据的所有权移交出去
  3. 将数据的引用移交出去,保留数据本身的所有权,让接收方暂时“借用”(borrow
使用哪种方式依据场景而定。
借用检查器的其它能力:并发 除了处理内存的分配与释放,借用检查器还能阻止数据竞争,正如Rust所谓的“无惧并发”,让你毫无顾虑地进行并发、并行编程。
缺点 美好的事物总是伴随着代价,Rust的所有权系统同样也有缺陷,事实上如果不是这些缺陷,我也不会专门写这篇文章。
比较难上手,是借用检查机制的一大缺点。Rust社区中不乏被它折磨的新人,我自己也在掌握它上面花费了很多时间。
举个例子,在借用机制下,共享数据会变得比较繁琐,尤其是共享数据的同时还要改变数据的场景。很多其它语言中非常简便就能创建的数据结构,在Rust中会比较麻烦。
但是当你理解了它,编写Rust代码会更顺手。我很喜欢社区里的一句话:
借用机制的几条规则,就像拼写检查一样,如果你一点儿都不理解他们,那你写出来的代码基本都是错的。心平气和地理解了它们,才会写出正确的代码。
几条基本规则:
  1. 每当向一个方法传递参数变量(非变量的引用)时,都是将该变量的所有权转移给调用的方法,此后你就不能再使用它了。
  2. 每当传递变量的引用(即所谓的借用),你可以传递任意多个不可变引用,或者一个可变引用。也就是说可变引用只能有一个。
实践 理解了借用检查机制后,现在实践一下。我们将使用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的学习曲线确实比较陡峭,但只要持续学习,你总能一路向上。

    推荐阅读