1

原文链接: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),结合了垃圾回收的便利与手动管理的性能,就像一个内嵌在语言中的内存管理器。

在实操中,在所有权机制下我们可以对数据进行三种操作方式:

  1. 直接将数据的所有权移交出去
  2. 拷贝一份数据,单独将拷贝数据的所有权移交出去
  3. 将数据的引用移交出去,保留数据本身的所有权,让接收方暂时“借用”(borrow

使用哪种方式依据场景而定。

借用检查器的其它能力:并发

除了处理内存的分配与释放,借用检查器还能阻止数据竞争,正如Rust所谓的“无惧并发”,让你毫无顾虑地进行并发、并行编程。

缺点

美好的事物总是伴随着代价,Rust的所有权系统同样也有缺陷,事实上如果不是这些缺陷,我也不会专门写这篇文章。

比较难上手,是借用检查机制的一大缺点。Rust社区中不乏被它折磨的新人,我自己也在掌握它上面花费了很多时间。

举个例子,在借用机制下,共享数据会变得比较繁琐,尤其是共享数据的同时还要改变数据的场景。很多其它语言中非常简便就能创建的数据结构,在Rust中会比较麻烦。

但是当你理解了它,编写Rust代码会更顺手。我很喜欢社区里的一句话:

借用机制的几条规则,就像拼写检查一样,如果你一点儿都不理解他们,那你写出来的代码基本都是错的。心平气和地理解了它们,才会写出正确的代码。

几条基本规则:

  1. 每当向一个方法传递参数变量(非变量的引用)时,都是将该变量的所有权转移给调用的方法,此后你就不能再使用它了。
  2. 每当传递变量的引用(即所谓的借用),你可以传递任意多个不可变引用,或者一个可变引用。也就是说可变引用只能有一个。

实践

理解了借用检查机制后,现在实践一下。我们将使用Rust中可变长度的list: Vec<T> 类型(类似Python中的 list 和 JavaScript中的 Array),可变长度的特性决定了它需要使用堆内存来存储。

这个例子比较刻意,但它能很好的说明上述的规则。我们将创建一个 vector,将它作为参数传递给一个函数进行调用,然后看看在里面会发生什么。

注意:下面这个代码实例不会通过编译

fn hold_my_vec<T>(_: Vec<T>) {}

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<i32>`, 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<i32> 没有实现 Copy 特性(trait),因此它的所有权是被转移(借用)了,无法在这之后再访问它的值。只有能在栈上存储的类型实现了 Copy 特性,而 Vec 类型必须分配在堆内存上,它无法实现该特性。我们需要找到另一种手段来处理类似情况。

Clone

虽然 Vec 类型变量不能实现 Copy 特性,但它实现了 Clone 特性。在Rust中,克隆是另一种复制数据的方式。与copy只能对栈上的数据进行拷贝、开销小的特点不同,克隆也可以面向堆数据,并且开销可以很大。

回到上面的例子中,传值给函数的场景,那我们给它一个向量的克隆也可以大道目的。如下代码可以正常运行:

fn hold_my_vec<T>(_: Vec<T>) {}

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<T> 更改为 &Vec<T>,即引用类型。调用该函数的方式也需要修改下,让Rust编译器知道只是将向量的引用———— 一个借用值,交给函数使用。这样函数就是会短暂地借用这个值,在之后的代码中仍然可以使用它。

fn hold_my_vec<T>(_: &Vec<T>) {}

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的学习曲线确实比较陡峭,但只要持续学习,你总能一路向上。


明酱
62 声望6 粉丝