Rust 学习笔记 3.1

2020/04/02

阅读 《 Rust 程序设计》的一些笔记。

第 4 章 - 理解所有权(ownership)

所有权是 Rust 最独特的功能,它使 Rust 不需要垃圾回收器也能保证内存安全。因此,了解所有权在 Rust 中的工作方式非常重要。在本章中,我们将讨论所有权以及几个相关功能:借用(borrowing),切片(slice)和 Rust 如何在内存中布置数据。

什么是所有权?

Rust 的主要特色是所有权(ownership),尽管这个功能易于解释,但对其他语言具有深远的影响。 所有程序必须在运行时管理内存。有些语言有垃圾回收功能,在程序运行时会不断寻找不再使用的内存;另有一些语言,程序员必须显式分配和释放内存。 Rust 使用了第三种方法:内存是通过所有权管理的,该系统具有一组规则,编译器会在编译时检查这些规则。所有所有权功能都不会拖慢程序的运行速度。

由于所有权对许多程序员来说是一个新概念,需要一些时间来适应。好消息是,你对 Rust 和所有权系统规则的了解越丰富,自然就能开发出安全高效的代码。继续吧!

了解所有权以后,你将具有坚实的基础,可以了解 Rust 独树一帜的功能。在本章,你将通过研究一种非常常见的数据结构示例来学习所有权:字符串。

栈(Stack)和堆(Heap) 在许多编程语言中,你都不需要太关注堆和栈。但是在像 Rust 这样的系统编程语言中,决定值分配在堆上还是栈上,对语言的行为方式以及决策理由的影响很大。这里是简要说明。 堆和栈都是内存的一部分,你的代码可在运行时使用,但它们的结构不同。堆栈按获取值的顺序存储值,并以相反的顺序删除值。这也被称为后进先出。设想有一堆(stack)盘子:当你添加盘子,就放在上面,当你需要盘子,从顶部取走,从中间或者底部增加或移除盘子都是不行的!添加数据就叫压入栈,删除数据就叫弹出栈。 栈中保存的所有数据必须具有已知的固定大小。编译时大小未知或者大小可能变化的数据必须存在堆中。堆的组织性较差:为了将数据放在堆上,你需要请求一定数量的空间。操作系统在堆上找到一个足够大的空间,将其标记为正在使用,然后返回一个指针,指向这个位置。这个过程称作在堆上分配,有时也简称为“分配(allocating)”。将值压入栈不被视为分配。因为指针是已知且固定大小的,你可以将指针存到栈里,但是你想拿到实际数据,就要跟随指针寻址。 考虑坐在餐馆里的场景。当您进入餐馆,说明你们一共多少人,然后工作人员会找到一个座位足够的餐桌,并带你过去。如果你们一行人中有人迟到了,他们可以询问你们的座位并找到你们。 压栈比在堆上分配要快,因为堆上你要寻址。如果现代处理器在内存中跳动次数越少,则速度越快。继续类推,考虑一家餐厅的服务器从多个桌子上接订单。最有效的做法是将所有的订单放在一张桌子上,然后转到下一张桌子。几个桌子上来来回回地收订单就很慢。同样地,如果处理器处理的数据与其他数据(如栈上数据)接近,而不是与其他数据(如堆上的数据)相距较远,则可以更好地完成工作。在堆上分配大量空间也耗时间。 当你的代码调用一个函数,传递给函数的值(可能包括指向堆中数据的指针)和函数的局部变量被压入栈。函数结束后,这些值从栈中弹出。 跟踪代码的哪些部分正在使用偶给堆中的哪些数据,从而最大程度地减少堆中的重复数据,并清理堆上未使用的数据,以确保你不会耗尽内存,这些都是所有权所解决的问题。了解所有权后,你就不需要经常考虑堆栈了,但是知道管理堆数据是所有权存在的原因,可以帮助你理解其工作原理。

所有权规则

首先,我们看一看所有权规则。在浏览示例时请记住以下规则:

变量作用域

在第 2 章中我们已经见过 Rust 程序的例子了。既然我们已经了解了基本语法,之后的例子就不包含 fn main() { 代码了,继续学习,你需要自己把代码加到 main 函数中去运行。这样,我们的例子就可以更简洁,专注于实际内容而不是样板代码。

所有权的第一个例子,我们将研究一些变量的作用域。作用域就是一个项目在程序中的有效范围。假设我们又一个这样的变量:

let s = "hello";

变量 s 表示字符串文字,字符串的值被硬编码到我们程序源码中。变量从声明的那一刻起就一直有效直到作用域的结尾。清单 4-1 注释说明了变量 s 的作用域。

{   // s 在这不可用,它还未被定义
    let s = "hello"; // 从这一点开始 s 是有效的
    // 用 s 做一些事情
}   // 作用域结束了,s 不再有效

清单 4-1: 变量及其作用域

换句话说,这里有两个重要的时间点:

此时,作用域和变量有效的关系与其他编程语言类似。现在,以此为基础,我们将介绍 String 类型。

String 类型

为了说明所有权规则,我们需要的数据类型比第 3 章“数据类型”一节中介绍的数据类型更复杂。先前涵盖的类型都存储在栈中,当它们的作用域结束时会从栈中弹出,但是我们想查看存储在堆中的数据,并探索 Rust 如何知道何时该清理这些数据。

这里我们用 String 举例并专注于 String 与所有权有关的部分。这些方面也适用于其他复杂数据类型,无论是标准库提供的还是你自造的。第 8 章中我们会更深入的讨论 String 。

我们已经看过字符串文字,其中字符硬编码在我们的程序中。字符串文字很方便,但是并不是在所有使用文字的场景都合适。其中一个原因是,它们是不可变的。另一个是在编写代码时,并非每个字符值都可以知道:例如,我们想获得用户输入并保存该输入该怎么办?对于这种情况, Rust 具有第二种字符串类型 String 。这种类型分配在堆上,因此可以存储在编译时未知的大量文本。你可以使用 from 函数从字符串文字创建字符串,如下所示:

let s = String::from("hello");

双冒号(::)是一种运算符,这种运算符使我们可以调用特定命名空间 String 类型下的 from 函数。而不是用类似 string_from 这样的名字。我们将在第 5 章的“方法语法”部分以及第 7 章的“模块树中引用项目的路径” 中更多讨论这种语法。

这种字符串可以被改变:

let mut s = String::from("hello");
s.push_str(", world!"); // push_str() 添加文字到字符串
println!("{}", s); // 这里将打印 `hello, world!`

所以,这儿有什么区别呢?为什么 String 可以被改变而文字就不行?区别在这两种类型如何处理内存。

内存和分配

对于字符串文字,我们在编译时就知道内容,因此文本直接背影编码到最终的可执行文件中。这就是字符文字高效快速的原因。但是这些属性仅来自字符串文字的不可变性。不幸的是,对于在编译时大小未知且运行程序时大小可能改变的文本,我们无法在二进制文件中添加大量的内存。

对于 String 类型,为了支持可变的,可增长的文本,我们需要在堆上分配一定数量的内存,在编译时为止,用以保存内容,这意味着:

第一部分有我们完成:当我们调用 String::from 时,其实现会请求所需要的内存。这在编程语言中几乎是通用的。

然而,第二部分就不同了,带有垃圾回收器(GC)的语言,GC 会跟踪并清理不再使用的内存,我们不需要考虑。没有 GC ,我们就要负责确定何时不再使用内存,并调用代码明确的释放内存,就像我们要求的那样。从历史上看,要正确执行此操作一直是编程难题。如果我们忘记了,就会浪费内存。如果过早的释放,我们就得到了不可用的变量。如果我们重复释放,也是有问题的。我们需要将一个 allocate(分配) 恰好与一个 free(释放) 对应。

Rust 采取了另一种路径:拥有它的变量超出范围后,内存自动回收。这是清单 4-1 中作用域实例的一个版本,其中 String 替代了字符串常量:

{
    let s = String::from("hello"); // 从这一点开始 s 是有效的
    // 用 s 做一些事情
}   // 作用域结束了,s 不再有效

我们可以很自然地将 String 需要的内存返回给操作系统:当超出 s 的作用域。当变量超出作用域, Rust 调用一个特殊的函数,这个函数叫 drop ,这是 String 的作者可以在其中防止代码以返回内存的地方。 Rust 在右大括号出自动调用 drop 函数。

注意:在 C++ 中,这种在项目生命周期结束时分配资源的模式有时称为“资源获取即初始化”( Resource Acquisition Is Initialization, RAII),如果你使用过 RAII 模式,Rust 中的 drop 也就不会陌生。

这种模式对 Rust 代码的编写方式有深远影响。现在看起来似乎很简单,但是在更复杂的情况下,当我们想让多个变量使用我们在堆上分配的数据时,代码的行为可能出乎意料。现在我们探讨其中的一些情况。

变量与数据交互的方式:移动(Move)

多个变量可以在 Rust 中以不同方式与同一个数据交互。看清单 4-2 中的例子:

let x = 5;
let y = x;

清单 4-2 :将变量 x 赋值给 y

我们可能会猜它做了什么:“将 5 值绑定到 x ;然后在 x 中复制值并将其绑定到 y 。” 我们现在有两个变量 x 和 y , 它们都等于 5 。这确实是正在发生的事情,因为整数是已知且固定大小的简单值,然后将这两个 5 值压入栈。

现在看一看 String 版本:

let s1 = String::from("hello");
let s2 = s1;

这和前一段代码很相似,因此我们可以假设它的工作方式是相同的:那就是,第二行会得到 s1 的拷贝并绑定到 s2 。但是实际不是这样的。

看图 4-1 ,看看 String 幕后发生了什么。一个 String 分成三个部分,画在了左边:一个指向保存字符串内容的内存指针,一个长度,还有一个容量。这组数据保存在栈里。右边是保存在堆内存上的内容。

4-1

图 4-1:内存中绑定到 s1 的值 “hello” 的字符串表示形式。

该长度(len)是 String 当前正在使用的内存量(以字节为单位)。此处容量(capacity)是 String 从操作系统接受的内存总量(以字节为单位)。长度和容量之间的差别很重要,但这种情况下并不重要,暂时可以忽略容量。

当我们把 s1 赋值给 s2 , String 数据被复制,这意味着我们复制了栈上的指针,长度和容量。我们不会复制指针指向的堆上的数据,内存中的数据如图 4-2 所示。

4-2

图 4-2:变量 s2 在内存中表示形式,该变量具有 s1 的指针,长度和容量的副本。

如果 Rust 复制了堆数据的话,就会像图 4-3 ,但不是这样的。如果堆上的数据很大, Rust 复制堆上的数据,就会使 s2 = s1 运行代价很高。

4-3

图 4-3:如果 Rust 也复制堆数据,则 s2 = s1 可能这么做

先前我们说过,当变量超出作用域, Rust 自动调用 drop 函数并清理该变量的堆内存。但是图 4-2 显示了两个指向相同位置的数据指针。这是一个问题:当 s2 和 s1 都超出范围时,它们都会尝试释放相同一块的内存。这被称为 双重释放错误 ,是我们前面提到的内存安全错误之一。释放内存两次可能导致内存损坏,从而可能导致安全漏洞。

为了确保内存安全,在 Rust 中,在这种情况下会发生的事情有很多细节。Rust 不会尝试复制分配的内存,而是认为 s1 不再有效,因此,当 s1 超出范围时, Rust 不需要释放任何内容。检查下创建 s2 之后尝试使用 s1 会发生什么;它不起作用:

let s1 = String::from("hello");
let s2 = s1;

println!("{}, world", s1);

你会得到下面的错误,因为 Rust 阻止你使用无效的引用:

error[E0382]: use of moved value: `s1`
 --> src/main.rs:5:28
  |
3 |     let s2 = s1;
  |         -- value moved here
4 |
5 |     println!("{}, world!", s1);
  |                            ^^ value used here after move
  |
  = note: move occurs because `s1` has type `std::string::String`, which does
  not implement the `Copy` trait

如果你在使用其他语言时听说过浅拷贝(shallow copy)和深拷贝(deep copy)这两个术语,那么复制指针、长度和容量而不复制数据的概念听起来就像是浅拷贝。但是由于 Rust 还使第一个变量无效,这不是浅拷贝,因此称为移动(move)。在此示例中,我们说 s1 已移入 s2。因此实际发生的情况如图 4-4 所示。

4-4

图 4-4:s1 无效后再内存中的表示形式

那解决了我们的问题!只有 s2 有效,当它超出范围时,仅凭它就可以释放内存,完成了。

此外,这暗示了一种设计选择: Rust 永远不会自动创建数据的“深层”副本。因此,就运行效率而言,任何自动复制都被认为是廉价的。

变量与数据交互的方式:克隆(Clone)

如果我们确实要深拷贝 String 的堆数据,而不仅仅是栈数据,则可以使用一种称为 clone 的通用方法。我们将在第 5 章讨论方法语法,但是由于方法是许多编程语言中的常用功能,因此你以前可能见过它们。

这是 clone 方法的实际例子:

let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);

这代码可以运行,并且显示完成了图 4-3 的行为,它确实复制了堆数据。

当你看到克隆调用时,你因该知道正在执行的代码可能运行开销很大。这种视觉指示器表明发生了一些不同的情况。

仅栈数据:拷贝(Copy)

还有个问题没有讨论。这段使用了整数的代码是有效的,部分代码如清单 4-2 所示:

let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);

这段代码似乎与我们刚学到的矛盾:我们没有调用 clone 方法,但是 x 仍然可用且不是移入 y 。

原因是诸如在编译时已知大小的整数之类的类型,完全存储在栈中,因此可以快速产生实际值的副本。这意味着在创建变量 y 之后,我们不需要阻止 x 生效。换句话说,这里的深拷贝和浅拷贝没有区别,因此调用克隆与通常的浅拷贝没有不同,我们可以将其省略。

Rust 具有一个特殊的注释,称为 Copy 特征,我们可以将其放在存储在栈的类型上(第 10 章进一步讨论)。如果类型具有 Copy 特征,则分配后仍然可以使用旧的变量值。但如果该类型或其实现部分实现了 Drop 特性, Rust 将不允许我们使用 Copy 特征对该类型进行注释。如果在值超出范围时对该类型需要特殊处理,向该类型添加 Copy 注释,则会出现编译错误。要了解如何将 Copy 特征添加到你的类型中,参考附录 C 的“可导出特征”。

那么什么类型被拷贝?你可以查看给定的文档,一般规则是,任何一组简单的标量值都可以拷贝,而不需要分配或是某种形式的资源都不是拷贝。一下是一些 Copy 类型:

所有权和职能

用于将值传递给函数的语义类似于用于将值分配给变量的语义。就像赋值一样,将变量传递给函数将移动或拷贝。清单 4-3 给出了一个示例,启动带有注释,显示变量进入和退出范围的位置。

fn main() {
    let s = String::from("hello");  // s 进入作用域

    takes_ownership(s);             // s 的值移入函数
                                    // ... s 从这开始无效了

    let x = 5;                      // x 进入作用域

    makes_copy(x);                  // x 移入函数中
                                    // 但是 i32 是拷贝类型,所以 x 仍然有效
                                    // 继续使用 x 变量。

} // 这里x 超出作用域,接着是 s 。但是因为 s 的值被移动了,没什么特别情况。

fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!("{}", some_string);
} // 这里, some_string 超出作用域,调用 drop ,释放内存。

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
    println!("{}", some_integer);
} // 这里,some_integer 超出作用域,没什么特别的。

清单 4-3:函数的所有权和作用域注释

如果我们在调用 takes_ownership 之后使用 s , Rust 将抛出编译一场,这些静态检查防止我们犯错误。尝试将代码添加到使用 s 和 x 的 main 函数中,以查看可以在哪里使用它们以及所有权规则在哪里阻止你这么做。

返回值和作用域

返回值也可以转移所有权。清单 4-4 带有与清单 4-3 类似的注释。

fn main() {
    let s1 = gives_ownership();         // gives_ownership 将其返回值移入 s

    let s2 = String::from("hello");     // s2 进入作用域

    let s3 = takes_and_gives_back(s2);  // s2 被移入 takes_and_gives_back, 该
                                        //  函数也将返回值移入 s3
} // 这里, s3 超出作用域而被 drop. s2 超出作用域但是由于被移动,所以无事发生. 
  //  s1 超出作用域被 drop 。

fn gives_ownership() -> String {             // gives_ownership 将返回值移动给
                                             // 调用方。

    let some_string = String::from("hello"); // some_string 进入作用域

    some_string                              // some_string 被返回并移动到函数
                                             // 调用方。
}

// takes_and_gives_back will take a String and return one
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域

    a_string  // a_string 别返回且移动到函数调用方
}

清单 4-4:返回值转移所有权

变量的所有权每次都遵循相同的模式:将值分配给它移动的另一个变量。当包含堆上数据的变量超出范围,只有该数据移至另一个变量,才 drop 这个值。

拥有所有权,然后返回每个函数的所有权,有点乏味。如果我们要让函数使用值但不取得所有权怎么办?令人烦恼的是,除了我们可能想返回的函数主体的任何数据之外,如果我们想再次使用它们,还需要将其传递回去。

可以使用元组返回多个值,如清单 4-5 。

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

清单 4-5:返回参数所有权

但对于一个应该是通用的概念来说,这有太多的仪式和大量的工作。幸运的是, Rust 有此概念的功能,称为参考(references) 。