4.8.所有权

优质
小牛编辑
122浏览
2023-12-01

这篇教程是现行 3 个 Rust 所有权系统章节的第一部分。所有权系统是Rust最独特且最引人入胜的特性之一,也是作为 Rust 开发者应该熟悉的。Rust 所追求最大的目标 -- 内存安全,关键在于所有权。所有权系统有一些不同的概念,每个概念独自成章:

  • 所有权,你正在阅读的这个章节
  • 借用,以及它关联的特性: "引用" (references)
  • 生命周期,关于借用的高级概念

这3章依次互相关联,你需要完整地阅读全部 3 章来对Rust的所有权系统进行全面的了解。

原则(Meta)

在我们开始详细讲解之前,这有两点关于所有权系统重要的注意事项。

Rust 注重安全和速度。它通过很多零开销抽象zero-cost abstractions)来实现这些目标,也就是说在 Rust 中,实现抽象的开销尽可能的小。所有权系统是一个典型的零开销抽象的例子。本文提到所有的分析都是在编译时完成的。你不需要在运行时为这些功能付出任何开销。

然而,这个系统确实有一个开销:学习曲线。很多 Rust 初学者会经历我们所谓的“与借用检查器作斗争”的过程,也就是指 Rust 编译器拒绝编译一个作者认为合理的程序。这种“斗争”会因为程序员关于所有权系统如何工作的基本模型与 Rust 实现的实际规则不匹配而经常发生。当你刚开始尝试 Rust 的时候,你很可能会有相似的经历。然而有一个好消息:更有经验的 Rust 开发者反映,一旦他们适应所有权系统一段时间之后,与借用检查器的冲突会越来越少。

记住这些之后,让我们来学习关于所有权的内容。

所有权(Ownership)

Rust 中的变量绑定有一个属性:它们有它们所绑定的的值的所有权。这意味着当一个绑定离开作用域,它们绑定的资源就会被释放。例如:

fn foo() {
    let v = vec![1, 2, 3];
}

v进入作用域,一个新的 vector 在栈上被创建,并在上为它的3个元素分配了空间。当vfoo()的末尾离开作用域,Rust将会清理掉与向量(vector)相关的一切,甚至是堆上分配的内存。这在作用域的结尾是一定(deterministically)会发生的。

我们在之前的章节介绍了vector;这里我们只是用它来作为一个在运行时在堆上分配内存的类型的例子。他们表现起来像数组,除了通过push()更多元素他们的大小会改变。

vector 有一个泛型类型Vec<T>,所以在这个例子中vVec<i32>类型的。我们将会在本章的后面详细介绍泛型。

移动语义(Move semantics)

然而这里有更巧妙的地方:Rust 确保了对于任何给定的资源都正好(只)有一个绑定与之对应。例如,如果我们有一个 vector,我们可以把它赋予另外一个绑定:

let v = vec![1, 2, 3];

let v2 = v;

不过,如果之后我们尝试使用v,我们得到一个错误:

let v = vec![1, 2, 3];

let v2 = v;

println!("v[0] is: {}", v[0]);

它看起来像这样:

error: use of moved value: `v`
println!("v[0] is: {}", v[0]);
                        ^

当我们定义了一个取得所有权的函数,并尝试在我们把变量作为参数传递给函数之后使用这个变量时,会发生相似的事情:

fn take(v: Vec<i32>) {
    // What happens here isn’t important.
}

let v = vec![1, 2, 3];

take(v);

println!("v[0] is: {}", v[0]);

一样的错误:“use of moved value”。当我们把所有权转移给别的绑定时,我们说我们“移动”了我们引用的值。这里你并不需要什么类型的特殊注解,这是 Rust 的默认行为。

细节

在移动了绑定后我们不能使用它的原因是微妙的,也是重要的。当我们写了这样的代码:

let x = 10;

Rust 在上为一个整型(i32)分配了内存,将代表值 10 的位拷贝到分配的内存中并将这个内存区域绑定到变量 x 上以供进一步的引用。

现在考虑一下下面的代码段:

let v = vec![1, 2, 3];

let mut v2 = v;

第一行在栈上为 vector 对象v分配了内存,就像上面的 x 一样。不过同时它也在上为实际的数据 [1, 2, 3] 分配了一些内存。Rust 拷贝堆上分配的内存的地址到一个内部指针,作为位于栈上的 vector 对象的一部分(让我们叫它数据指针)。值得指出的是(即便冒着冗余的风险),我们也将 vector 对象和它的数据存储在不同的内存区域,而不是分配到一块连续的内存中(出于一些我们现在不会讨论的理由)。vector 的这两部分(在堆上的和在栈上的)在任何时候都必须同步像大小,容量这样的信息。

当我们从v移动到v2,Rust 实际上按位拷贝了v在栈上分配的内容到v2。这个浅拷贝(shallow copy)并没有复制一份堆上分配的实际数据。这就是说将会有两个 vector 内容的指针都指向通用的堆上分配的空间。这样会违反 Rust 的安全保证,通过引入一个数据竞争,当你可以同时访问vv2的时候。

例如如果我们通过v2缩短 vector 到只有两个元素:

# let v = vec![1, 2, 3];
# let mut v2 = v;
v2.truncate(2);

而同时v仍是可以访问的话,这会产生一个无效的 vector,因为它并不知道堆上的数据已经被缩短了。现在v在栈上的部分与堆上的相应部分的信息并不一致。v仍然认为有 3 个元素并乐意我们访问那个并不存在的元素v[2],不过你可能已经知道这是一个导致灾难的剧本。因为这可能会导致一个段错误,更糟的是会允许未经授权的用户读取他没有访问权限的数据。

这就是为何 Rust 在我们移动后禁止使用v的原因。

注意到优化可能会根据情况移除栈上字节(例如上面的向量)的实际拷贝也是很重要的。所以它也许并不像它开始看起来那样没有效率。

Copy类型

我们已经知道了当所有权被转移给另一个绑定以后,你不能再使用原始绑定。然而,有一个trait会改变这个行为,它叫做Copy。我们还没有讨论到 trait,不过目前,你可以理解为一个为特定类型增加额外行为的标记。例如:

let v = 1;

let v2 = v;

println!("v is: {}", v);

在这个情况,v是一个i32,它实现了Copy。这意味着,就像一个移动,当我们把v赋值给v2,产生了一个数据的拷贝。不过,不像一个移动,我们仍可以在之后使用v。这是因为i32并没有指向其它数据的指针,对它的拷贝是一个完整的拷贝。

所有基本类型都实现了Copy trait,因此他们的所有权并不像你想象的那样遵循“所有权规则”被移动。作为一个例子,如下两段代码能够编译是因为i32bool类型实现了Copy trait。

fn main() {
    let a = 5;

    let _y = double(a);
    println!("{}", a);
}

fn double(x: i32) -> i32 {
    x * 2
}
fn main() {
    let a = true;

    let _y = change_truth(a);
    println!("{}", a);
}

fn change_truth(x: bool) -> bool {
    !x
}

如果我们使用了没有实现Copytrait的类型,我们会得到一个编译错误,因为我们尝试使用一个移动了的值。

error: use of moved value: `a`
println!("{}", a);
               ^

我们会在trait部分讨论如何编写你自己类型的Copy

所有权之外(More than ownership)

当然,如果我们不得不在每个我们写的函数中交还所有权:

fn foo(v: Vec<i32>) -> Vec<i32> {
    // Do stuff with `v`.

    // // Hand back ownership.
    v
}

这将会变得烦人。它在我们获取更多变量的所有权时变得更糟:

fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) {
    // Do stuff with `v1` and `v2`.

    // Hand back ownership, and the result of our function.
    (v1, v2, 42)
}

let v1 = vec![1, 2, 3];
let v2 = vec![1, 2, 3];

let (v1, v2, answer) = foo(v1, v2);

额!返回值,返回的代码行(上面的最后一行),和函数调用都变得更复杂了。

幸运的是,Rust 提供了一个 trait,借用,它帮助我们解决这个问题。这个主题将在下一个部分讨论!