不要害怕Rust的借用检查器

我花了几乎一整天的时间,试图弄清楚所有权和借用是如何在Rust里运作的,最终还是弄清楚了。

在这篇文章中,我将通过一些破坏Rust规则的代码示例来演示这些概念是如何工作的,并解释为什么这些代码存在问题。 我假设读者对Rust编程语言知之甚少。 我还在所有代码块中添加了注释来说明代码是否是有效Rust代码。

第一个例子:添加值到字符串的Vector

本例中,有两个Vector。其中一个是 myvec ,它预先被添加了一些值,而且它被设置为不可变的。另一个被实例化为可变的,并且不包含任何值。 然后我们从 myvec 取出一个值,并且尝试用函数 Vec::push() 将这个值添加到 othervec 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
// invalid

fn main() {
let myvec: Vec<String> = vec![
String::from("hello"),
String::from("world")
];
let mut othervec: Vec<String> = Vec::new();

// `myvec.get(1)` 不是返回一个 `String`, 而是返回一个 `Option`,
// 因此需要使用 `.unwrap()` 来从它里面取出值。
othervec.push(myvec.get(1).unwrap());
}

这似乎是一件完全合理的事情——在许多其他编程语言中,你可以从数组或列表中获取一个值,并将其粘贴到另一个数据结构中。

但是在Rust里会怎么样呢?

1
2
3
4
5
$ cargo run
error[E0308]: mismatched types
..
expected struct `std::string::String`, found reference
...

可怕的类型不匹配错误!

这里发生的结果是,myvec.get(1)实际上并没有返回类型为String的值,而是返回了对字符串的引用,或叫做 &String。对字符串的引用可以在只读上下文中使用,例如,你可以写如下代码:

1
println!("{}", myvec.get(1).unwrap())

但是,由于 othervec 是一个 Vec<String> 类型的 Vector,你不可以将一个 &String 类型的值添加给它。那么,我们可以使用解引用符号 * ,对不对?

1
2
3
4
5
6
// invalid
...
let myvec: Vec<String> = vec![String::from("hello"), String::from("world")];
let mut othervec: Vec<String> = Vec::new();
othervec.push(*myvec.get(1).unwrap());
...

不对!这次的错误是 “cannot move out of borrowed content”。

编译器在此处告诉我们的是,因为 myvec.get(1) 返回的值是一个 “借用的”值,它不能被赋给别的变量。

借用

为了更详细地阐述这一点,Rust中的值一次只能分配给一个变量。 将值分配给其他变量会导致无法再使用以前的变量访问该值。 举个例子:

1
2
3
4
// invalid
let x = String::from("hello");
let y = x;
println!("{}", x);

上述代码编译的时候报错:“Use of moved value ‘x’”。这种行为在Rust中非常重要,因为在Rust中,当变量超出作用域时,它们引用的内存被释放 —— 没有垃圾收集。 为了保持这种行为,并防止无效的内存访问,数据一次只能有一个“所有者”。 在此示例中,x是原始所有者,然后y成为所有者。

也就是说,如果数据需要在其他上下文中被读取,例如,在其他函数中,可以将对数据的引用分配给其他变量。例如,以下代码编译成功并可以正常运行:

1
2
3
4
5
// valid
let x = String::from("hello");
let y = &x;
println!("{}", x);
println!("{}", y);

表达式 &x 就是“借用”的一个例子。变量y被赋予了对x值的借用引用。 默认情况下引用不可变(在Rust中也存在可变引用),并且对它们有其他约束,我将在后面进一步详细解释。

回到例子

回到我们的原来的例子,myvec.get(1).unwrap() 返回对myvec中索引1处的值的引用,并且没有发生移动。 但是,在将此值分配给变量时,将发生移动。 该值的当前所有者是myvec。 如果我们写如下代码:

1
2
3
4
5
6
7
// invalid
let myvec: Vec<String> = vec![
String::from("hello"),
String::from("world")
];
let mut othervec: Vec<String> = Vec::new();
let val = *myvec.get(1).unwrap();

这会将数据的所有者转给变量val。这是不被允许的,因为你不能移动借用的值。

思考一下能够做到这一点的含义。如果有可能将myvec.get (1)的所有权转移给val,这意味着一旦val超出作用域,myvec中索引为1的元素现在将会指向无效的内存块。

此外,我们也可以做一些可怕的事情:

1
2
3
4
5
6
7
8
9
10
// invalid
fn main() {
let myvec: Vec<String> = vec![
String::from("hello"),
String::from("world")
];
let mut othervec: Vec<String> = Vec::new();
othervec.push(myvec.get(1).unwrap());
myvec.clear();
}

因为 .clear() 释放所有内存,读取 othervec 的值将导致无效内存访问。

要解决这个问题,我们可以将引用分配给变量val,如下所示:

1
2
3
4
5
6
7
// invalid
let myvec: Vec<String> = vec![
String::from("hello"),
String::from("world")
];
let mut othervec: Vec<String> = Vec::new();
let val = myvec.get(1).unwrap();

但是我们不能将这个值添加到Vec<String>类型的Vec,因为val是&String类型的。

那么我们如何解决这个问题来实现我们想要的呢?这将取决于我们到底想要完成什么。我们不能将myvecec .get(1)的所有权转移到othervec,所以如果我们想让othervec指向Vector中实际的位置,我们必须将othervec改为&String的一个Vector:

1
2
3
4
5
6
7
8
9
// valid
fn main() {
let myvec: Vec<String> = vec![
String::from("hello"),
String::from("world")
];
let mut othervec: Vec<&String> = Vec::new();
othervec.push(myvec.get(1).unwrap());
}

然后这样写的结果是,我们无法修改 othervec 里的值,我将在下一节中更详细地讨论这个问题。在othervec超出作用域之前,myvec都不能超出作用域。

另一方面,如果我们只关心位置1的值,那么我们可以复制这个字符串:

1
2
3
4
5
6
7
8
9
// valid
fn main() {
let myvec: Vec<String> = vec![
String::from("hello"),
String::from("world")
];
let mut othervec: Vec<String> = Vec::new();
othervec.push(myvec.get(1).unwrap().to_string());
}

&String上的to_string()方法创建一个新的字符串并将所有权给了othervec。

借用的总结

Rust具有的这种约束,要求不能将值分配给多个所有者,这解决了其他编程语言(如C)中可能继续读取无效内存的大问题。 解决这个问题的意义也非常疯狂:另一种看待这个问题的方式是,当我们用myvec.get(1).unwrap()从myvec中取出一个值时,得到的值知道它来自哪里, 并且知道因为它有一个所有者,所以不能将它分配给另一个变量。 在其他编程语言中,如果从类似列表的对象中提取变量,就像在下面的Python中一样:

1
2
x = ["a", "b", "c"]
y = x[1]

现在存储在y中的值来自哪里是无关紧要的,它可以像任何其他字符串一样被对待。 在Rust中,情况并非如此!

更多关于借用和所有权的内容,请阅读《Rust程序设计语言》那本书的相关章节:

第二个例子:单独的函数

那么如前所述的一个代码示例,我提到我们可以把othervec构建为一个Vec<&String>,也就是一个字符串引用的Vector。在本节中,我将更详细地介绍处理引用。

为了说明这一点,如下是无效代码的另一部分:

1
2
3
4
5
6
7
8
9
10
11
// invalid
fn copy_to_new_vec(myvec: &Vec<String>, othervec: &mut Vec<String> ) -> &mut Vec<String> {
othervec.push(myvec.get(1).unwrap().to_string());
return othervec;
}

fn main() {
let myvec = vec![String::from("hello"), String::from("world")];
let mut othervec: Vec<String> = Vec::new();
let newvec: &Vec<String> = copy_to_new_vec(&myvec, &mut othervec);
}

编译器会报告如下错误:

1
2
3
4
$ cargo run
...
expected lifetime parameter
...

这个例子和第一个例子很像。这个例子的主要不同是,我们将从myvec第一个位置拷贝值的逻辑抽取到一个叫做 copy_to_new_vec 函数里。

我们不是将myvec和othervec的所有权传递给copy_to_new_vec,而是传递对myvec的引用,以及对othervec的可变引用。 othervec需要作为可变引用传递,因为我们在copy_to_new_vec中为其写入值。 copy_to_new_vec返回对othervec的引用。 而newvec是对othervec的引用。

这看起来很合理,为什么编译器会报错呢?

返回对其他对象的引用的函数的缺点是,如果被引用的对象超出作用域或被释放,那么从这个函数返回的引用中读取数据将导致无效的内存读取。

引入“生命周期参数”

为了解决这个问题,Rust有一个叫做“生命周期参数”的概念。你可以给Rust编译器一些信息,告诉它,你的引用正在引用的确切对象。 有了这些信息,如果引用的对象超出作用域而引用仍在作用域内,则Rust编译器会报错。

生命周期参数通常由编译器推断出来,但在此示例中,因为返回值有两个参数可引用,所以必须显式提供生命周期参数。 它们看起来类似于Rust中的泛型:

1
2
3
4
5
6
7
8
9
10
11
// valid
fn copy_to_new_vec<'a>(vec: &Vec<String>, othervec: &'a mut Vec<String> ) -> &'a mut Vec<String> {
othervec.push(vec.get(1).unwrap().to_string());
return othervec;
}

fn main() {
let vec = vec![String::from("hello"), String::from("world")];
let mut othervec: Vec<String> = Vec::new();
copy_to_new_vec(&vec, &mut othervec);
}

在尖角括号之间添加带单引号的字符串可以使用生命周期参数对函数进行参数化。 然后可以将它们附加到参数和返回值(如上所示)以指示关系。 在上述代码里添加了生命周期参数,我们向Rust编译器指出copy_to__new_vec的返回值依赖于第二个参数。 但是,如果我们尝试写如下类似的代码:

1
2
3
4
5
6
7
8
9
10
11
// invalid
fn copy_to_new_vec<'a>(vec: &Vec<String>, othervec: &'a mut Vec<String> ) -> &'a mut Vec<String> {
othervec.push(vec.get(1).unwrap().to_string());
return othervec;
}

fn main() {
let vec = vec![String::from("hello"), String::from("world")];
let newvec = copy_to_new_vec(&vec, &mut Vec::new());
newvec.get(1);
}

编译器将会报如下错误:

1
2
3
4
$ cargo run
...
Borrowed value does not live long enough
...

这个错误的含义是,由于copy_to_new_vec的返回值在作用域范围上比copy_to_new_vec中的othervec更长,这是有问题的。

如果允许这样做,则newvec.get(1)将失败,因为newvec引用的值已经被释放。 这是因为在 let newvec = copy_to_new_vec(&vec, &mut Vec::new()); 这行代码后,copy_to_new_vec的第二个参数就已经超出作用域了。

这里查看有关Rust生命周期的更多信息。

总结

希望这些例子能帮助你理解Rust中的借用系统。这肯定要花很多时间去适应,一开始可能会很困难。但是一旦你了解了它,这个借用系统就变成了一个难以置信的安全网,它可以防止在其他系统编程语言中常见的许多问题。

祝玩得开心!

原文链接: http://www.squidarth.com/rc/rust/2018/05/31/rust-borrowing-and-ownership.html