8

(译)理解Rust的 borrow checker

 2 years ago
source link: https://segmentfault.com/a/1190000041064419
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK