RUST学习日记之所有权与引用

注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。

生命周期 作用域

核心区别总结

特性 作用域 (Scope) 生命周期 (Lifetime)
定义对象 变量的可见性区域及其存活期 引用的有效性时间段,即引用指向的数据存活的时间
控制对象 变量何时创建销毁 引用何时有效(不指向无效内存)
关注点 内存的分配与释放 内存安全,尤其是防止悬垂引用
目的 组织代码和管理资源 确保引用的有效性,由借用检查器(Borrow Checker)强制执行
形式 通常由 {} 代码块隐式定义 通常通过 'a, 'b 等显式注解(当编译器无法推断时)

剩下的,官方的文档写的非常好,我也是直接复制过来用了(Bushi)

什么是所有权?

所有权ownership)是 Rust 用于如何管理内存的一组规则。所有程序都必须管理其运行时使用计算机内存的方式。一些语言中具有垃圾回收机制,在程序运行时有规律地寻找不再使用的内存;在另一些语言中,程序员必须亲自分配和释放内存。Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序的运行。

因为所有权对很多程序员来说都是一个新概念,需要一些时间来适应。好消息是随着你对 Rust 和所有权系统的规则越来越有经验,你就越能自然地编写出安全和高效的代码。持之以恒!

当你理解了所有权,你将有一个坚实的基础来理解那些使 Rust 独特的功能。在本章中,你将通过完成一些示例来学习所有权,这些示例基于一个常用的数据结构:字符串。

栈(Stack)与堆(Heap)

在很多语言中,你并不需要经常考虑到栈与堆。不过在像 Rust 这样的系统编程语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出这样的抉择。我们会在本章的稍后部分描述所有权与栈和堆相关的内容,所以这里只是一个用来预热的简要解释。

栈和堆都是代码在运行时可供使用的内存,但是它们的结构不同。栈以放入值的顺序存储值并以相反顺序取出值。这也被称作 后进先出last in, first out)。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做 入栈pushing onto the stack),而移出数据叫做 出栈popping off the stack)。栈中的所有数据都必须占用已知且固定的大小。在编译时大小未知或大小可能变化的数据,要改为存储在堆上。

堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。内存分配器(memory allocator)在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针pointer)。这个过程称作 在堆上分配内存allocating on the heap),有时简称为 “分配”(allocating)。(将数据推入栈中并不被认为是分配)。因为指向放入堆中数据的指针是已知的并且大小是固定的,你可以将该指针存储在栈上,不过当需要实际数据时,必须访问指针。想象一下去餐馆就座吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。如果有人来迟了,他们也可以通过询问来找到你们坐在哪。

入栈比在堆上分配内存要快,因为(入栈时)分配器无需为存储新数据去搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为分配器必须首先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。

访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问。现代处理器在内存中跳转越少就越快。继续类比,假设有一个服务员在餐厅里处理多个桌子的点菜。在一个桌子报完所有菜后再移动到下一个桌子是最有效率的。从桌子 A 听一个菜,接着桌子 B 听一个菜,然后再桌子 A,然后再桌子 B 这样的流程会更加缓慢。出于同样原因,处理器在处理的数据彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)更高效。

当你的代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。

跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过明白了所有权的主要目的就是管理堆数据,能够帮助解释为什么所有权要以这种方式工作。

所有权规则

首先,让我们看一下所有权的规则。当我们通过举例说明时,请谨记这些规则:

  1. Rust 中的每一个值都有一个 所有者owner)。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者离开作用域,这个值将被丢弃。

变量作用域

既然我们已经掌握了基本语法,将不会在之后的例子中包含 fn main() { 代码,所以如果你是一路跟过来的,必须手动将之后例子的代码放入一个 main 函数中。这样,例子将显得更加简明,使我们可以关注实际细节而不是样板代码。

在所有权的第一个例子中,我们看看一些变量的 作用域scope)。作用域是一个项(item)在程序中有效的范围。假设有这样一个变量:

1
let s = "hello";

变量 s 绑定到了一个字符串字面值,这个字符串值是硬编码进程序代码中的。这个变量从声明的点开始直到当前作用域结束时都是有效的。示例 4-1 中的注释标明了变量 s 在何处是有效的。

1
2
3
4
5
{                      // s 在这里无效,它尚未声明
let s = "hello"; // 从此处起,s 是有效的

// 使用 s
} // 此作用域已结束,s 不再有效

示例 4-1:一个变量和其有效的作用域

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

  • s 进入作用域时,它就是有效的。
  • 这一直持续到它离开作用域为止。

目前为止,变量是否有效与作用域的关系跟其他编程语言是类似的。现在我们在此基础上介绍 String 类型。

String 类型

为了演示所有权的规则,我们需要一个比第三章 “数据类型” 中讲到的都要复杂的数据类型。前面介绍的类型都是已知大小的,可以存储在栈中,并且当离开作用域时被移出栈,如果代码的另一部分需要在不同的作用域中使用相同的值,可以快速简单地复制它们来创建一个新的独立实例。不过我们需要寻找一个存储在堆上的数据来探索 Rust 是如何知道该在何时清理数据的,而 String 类型就是一个很好的例子。

我们会专注于 String 与所有权相关的部分。这些方面也同样适用于标准库提供的或你自己创建的其他复杂数据类型。在第八章会更深入地讲解 String

我们已经见过字符串字面值,即被硬编码进程序里的字符串值。字符串字面值是很方便的,不过它们并不适合使用文本的每一种场景。原因之一就是它们是不可变的。另一个原因是并非所有字符串的值都能在编写代码时就知道:例如,要是想获取用户输入并存储该怎么办呢?为此,Rust 有另一种字符串类型,String。这个类型管理被分配到堆上的数据,所以能够存储在编译时未知大小的文本。可以使用 from 函数基于字符串字面值来创建 String,如下:

这两个冒号 :: 是运算符,允许将特定的 from 函数置于 String 类型的命名空间(namespace)下,而不需要使用类似 string_from 这样的名字。在第五章的 “方法语法”(“Method Syntax”) 部分会着重讲解这个语法,而且在第七章的 “路径用于引用模块树中的项” 中会讲到模块的命名空间。

1
2
3
4
5
let mut s = String::from("hello");

s.push_str(", world!"); // push_str() 在字符串后追加字面值

println!("{s}"); // 将打印 `hello, world!`

那么这里有什么区别呢?为什么 String 可变而字面值却不行呢?区别在于两个类型对内存的处理上。

内存与分配

就字符串字面值来说,我们在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串字面值快速且高效。不过这些特性都只得益于字符串字面值的不可变性。不幸的是,我们不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,并且它的大小还可能随着程序运行而改变。

对于 String 类型,为了支持一个可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:

  • 必须在运行时向内存分配器(memory allocator)请求内存。
  • 需要一个当我们处理完 String 时将内存返回给分配器的方法。

第一部分由我们完成:当调用 String::from 时,它的实现 (implementation) 请求其所需的内存。这在编程语言中是非常通用的。

然而,第二部分实现起来就各有区别了。在有 垃圾回收garbage collectorGC)的语言中,GC 记录并清除不再使用的内存,而我们并不需要关心它。在大部分没有 GC 的语言中,识别出不再使用的内存并调用代码显式释放就是我们的责任了,跟请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,这也是个 bug。我们需要精确的为一个 allocate 配对一个 free

Rust 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。下面是示例 4-1 中作用域例子的一个使用 String 而不是字符串字面值的版本:

1
2
3
4
5
6
{
let s = String::from("hello"); // 从此处起,s 是有效的

// 使用 s
} // 此作用域已结束,
// s 不再有效

这是一个将 String 需要的内存返回给分配器的很自然的位置:当 s 离开作用域的时候。当变量离开作用域,Rust 为我们调用一个特殊的函数。这个函数叫做 drop,在这里 String 的作者可以放置释放内存的代码。Rust 在结尾的 } 处自动调用 drop

注意:在 C++ 中,这种 item 在生命周期结束时释放资源的模式有时被称作 资源获取即初始化Resource Acquisition Is Initialization (RAII))。如果你使用过 RAII 模式的话应该对 Rust 的 drop 函数并不陌生。

这个模式对编写 Rust 代码的方式有着深远的影响。现在它看起来很简单,不过在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。现在让我们探索一些这样的场景。

使用移动的变量与数据交互

在 Rust 中,多个变量可以采取不同的方式与同一数据进行交互。让我们看看示例 4-2 中一个使用整型的例子。

1
2
let x = 5;
let y = x;

示例 4-2:将变量 x 的整数值赋给 y

我们大致可以猜到这在干什么:“将 5 绑定到 x;接着生成一个值 x 的拷贝并绑定到 y”。现在有了两个变量,xy,都等于 5。这也正是事实上发生了的,因为整数是有已知固定大小的简单值,所以这两个 5 被压入了栈中。

现在看看这个 String 版本:

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

这看起来与上面的代码非常类似,所以我们可能会假设它们的运行方式也是类似的:也就是说,第二行可能会生成一个 s1 的拷贝并绑定到 s2 上。但事实并非如此。

看看图 4-1 以了解 String 的底层会发生什么。String 由三部分组成,如图左侧所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。

Two tables: the first table contains the representation of s1 on the stack, consisting of its length (5), capacity (5), and a pointer to the first value in the second table. The second table contains the representation of the string data on the heap, byte by byte.

图 4-1:将值 "hello" 绑定给 s1String 在内存中的表现形式

长度表示 String 的内容当前使用了多少字节的内存。容量是 String 从分配器总共获取了多少字节的内存。长度与容量的区别是很重要的,不过在当前上下文中并不重要,所以现在可以忽略容量。

当我们将 s1 赋值给 s2String 的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。换句话说,内存中数据的表现如图 4-2 所示。

Three tables: tables s1 and s2 representing those strings on the stack, respectively, and both pointing to the same string data on the heap.

图 4-2:变量 s2 的内存表现,它有一份 s1 指针、长度和容量的拷贝

这个表现形式看起来并不像图 4-3 中的那样,如果 Rust 也拷贝了堆上的数据,那么内存看起来就是这样的。如果 Rust 这么做了,那么操作 s2 = s1 在堆上数据比较大的时候会对运行时性能造成非常大的影响。

Four tables: two tables representing the stack data for s1 and s2, and each points to its own copy of string data on the heap.

图 4-3:另一个 s2 = s1 时可能的内存表现,如果 Rust 同时也拷贝了堆上的数据的话

之前我们提到过当变量离开作用域后,Rust 自动调用 drop 函数并清理变量的堆内存。不过图 4-2 展示了两个数据指针指向了同一位置。这就有了一个问题:当 s2s1 离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放double free)的错误,也是之前提到过的内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。

为了确保内存安全,在 let s2 = s1; 之后,Rust 认为 s1 不再有效,因此 Rust 不需要在 s1 离开作用域后清理任何东西。看看在 s2 被创建之后尝试使用 s1 会发生什么;这段代码不能运行:

1
2
3
4
let s1 = String::from("hello");
let s2 = s1;

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

你会得到一个类似如下的错误,因为 Rust 禁止你使用无效的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:15
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{s1}, world!");
| ^^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

如果你在其他语言中听说过术语 浅拷贝shallow copy)和 深拷贝deep copy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 同时使第一个变量无效了,这个操作被称为 移动move),而不是叫做浅拷贝。上面的例子可以解读为 s1移动 到了 s2 中。那么具体发生了什么,如图 4-4 所示。

Three tables: tables s1 and s2 representing those strings on the stack, respectively, and both pointing to the same string data on the heap. Table s1 is grayed out be-cause s1 is no longer valid; only s2 can be used to access the heap data.

图 4-4:s1 无效之后的内存表现

这样就解决了我们的问题!因为只有 s2 是有效的,当其离开作用域,它就释放自己的内存,完毕。

另外,这里还隐含了一个设计选择:Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何自动的复制都可以被认为是对运行时性能影响较小的。

作用域与赋值

作用域、所有权和通过 drop 函数释放内存之间的关系反过来也同样成立。当你给一个已有的变量赋一个全新的值时,Rust 将会立即调用 drop 并释放原始值的内存。例如,考虑如下代码:

1
2
3
4
let mut s = String::from("hello");
s = String::from("ahoy");

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

起初我们声明了变量 s 并绑定为一个 "hello" 值的 String。接着立即创建了一个值为 "ahoy"String 并赋值给 s。在这里,完全没有任何内容指向了原始堆上的值。

One table s representing the string value on the stack, pointing to the second piece of string data (ahoy) on the heap, with the original string data (hello) grayed out because it cannot be accessed anymore.

图 4-5: 当初始值被整体替换后的内存表现

因此原始的字符串立刻就离开了作用域。Rust 会在其上运行 drop 函数同时内存会马上释放。当结尾打印其值时,将会是 "ahoy, world!"

使用克隆的变量与数据交互

如果我们 确实 需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的常用方法。第五章会讨论方法语法,不过因为方法在很多语言中是一个常见功能,所以之前你可能已经见过了。

这是一个实际使用 clone 方法的例子:

1
2
3
4
let s1 = String::from("hello");
let s2 = s1.clone();

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

这段代码能正常运行,并且明确产生图 4-3 中行为,这里堆上的数据确实被复制了。

当出现 clone 调用时,你知道一些特定的代码被执行而且这些代码可能相当消耗资源。你很容易察觉到一些不寻常的事情正在发生。

只在栈上的数据:拷贝

这里还有一个没有提到的细节。这些代码使用了整型并且是有效的,它们是示例 4-2 中的一部分:

1
2
3
4
let x = 5;
let y = x;

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

但这段代码似乎与我们刚刚学到的内容相矛盾:没有调用 clone,不过 x 依然有效且没有被移动到 y 中。

原因是像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y 后使 x 无效。换句话说,这里没有深浅拷贝的区别,所以这里调用 clone 并不会与通常的浅拷贝有什么不同,我们可以不用管它。

Rust 有一个叫做 Copy trait 的特殊注解,可以用在类似整型这样的存储在栈上的类型上(第十章将会详细讲解 trait)。如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其他变量后仍然有效。

Rust 不允许自身或其任何部分实现了 Drop trait 的类型使用 Copy trait。如果我们对其值离开作用域时需要特殊处理的类型使用 Copy 注解,将会出现一个编译时错误。要学习如何为你的类型添加 Copy 注解以实现该 trait,请阅读附录 C 中的 “可派生的 trait”

那么哪些类型实现了 Copy trait 呢?你可以查看给定类型的文档来确认,不过作为一个通用的规则,任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:

  • 所有整数类型,比如 u32
  • 布尔类型,bool,它的值是 truefalse
  • 所有浮点数类型,比如 f64
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都实现 Copy 的时候。比如,(i32, i32) 实现了 Copy,但 (i32, String) 就没有。

所有权与函数

将值传递给函数与给变量赋值的原理相似。向函数传递值可能会移动或者复制,就像赋值语句一样。示例 4-3 使用注释展示变量何时进入和离开作用域:

文件名:src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn main() {
let s = String::from("hello"); // s 进入作用域

takes_ownership(s); // s 的值移动到函数里 ...
// ... 所以到这里不再有效

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

makes_copy(x); // x 应该移动函数里,
// 但 i32 是 Copy 的,
println!("{}", 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 会抛出一个编译时错误。这些静态检查使我们免于犯错。试试在 main 函数中添加使用 sx 的代码来看看哪里能使用它们,以及所有权规则会在哪里阻止我们这么做。

返回值与作用域

返回值也可以转移所有权。示例 4-4 展示了一个返回了某些值的示例,与示例 4-3 一样带有类似的注释。

文件名:src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fn main() {
let s1 = gives_ownership(); // gives_ownership 将它的返回值传递给 s1

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

let s3 = takes_and_gives_back(s2); // s2 被传入 takes_and_gives_back,
// 它的返回值又传递给 s3
} // 此处,s3 移出作用域并被丢弃。s2 被 move,所以无事发生
// s1 移出作用域并被丢弃

fn gives_ownership() -> String { // gives_ownership 将会把返回值传入
// 调用它的函数

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

some_string // 返回 some_string 并将其移至调用函数
}

// 该函数将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String {
// a_string 进入作用域

a_string // 返回 a_string 并移出给调用的函数
}

示例 4-4: 转移返回值的所有权

变量的所有权总是遵循相同的模式:将值赋给另一个变量时它会移动。当持有堆中数据值的变量离开作用域时,其值将通过 drop 被清理掉,除非数据被移动为另一个变量所有。

虽然这样是可以的,但是在每一个函数中都获取所有权并接着返回所有权有些啰嗦。如果我们想要函数使用一个值但不获取所有权该怎么办呢?如果我们还要接着使用它的话,每次都传进去再返回来就有点烦人了,除此之外,我们也可能想返回函数体中产生的一些数据。

我们可以使用元组来返回多个值,如示例 4-5 所示。

文件名:src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let s1 = String::from("hello");

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

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

fn calculate_length(s: String) -> (String, usize) {
let length = s.len(); // len() 返回字符串的长度

(s, length)
}

示例 4-5: 返回参数的所有权

引用与借用

示例 4-5 中的元组代码有这样一个问题:我们必须将 String 返回给调用函数,以便在调用 calculate_length 后仍能使用 String,因为 String 被移动到了 calculate_length 内。相反我们可以提供一个 String 值的引用(reference)。引用reference)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。与指针不同,引用在其生命周期内保证指向某个特定类型的有效值。

下面是如何定义并使用一个(新的)calculate_length 函数,它以一个对象的引用作为参数而不是获取值的所有权:

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let s1 = String::from("hello");

let len = calculate_length(&s1);

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

fn calculate_length(s: &String) -> usize {
s.len()
}

首先,注意变量声明和函数返回值中的所有元组代码都消失了。其次,注意我们传递 &s1calculate_length,同时在函数定义中,我们获取 &String 而不是 String。这些 & 符号就是 引用,它们允许你使用值但不获取其所有权。图 4-6 展示了一张示意图。

Three tables: the table for s contains only a pointer to the table for s1. The table for s1 contains the stack data for s1 and points to the string data on the heap.

图 4-6:&String s 指向 String s1 示意图

注意:与使用 & 引用相反的操作是 解引用dereferencing),它使用解引用运算符 * 实现。我们将会在第八章遇到一些解引用运算符,并在第十五章详细讨论解引用。

仔细看看这个函数调用:

1
2
let s1 = String::from("hello");
let len = calculate_length(&s1);

&s1 语法让我们创建一个指向s1 的引用,但是并不拥有它。因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。

同理,函数签名使用 & 来表明参数 s 的类型是一个引用。让我们增加一些解释性的注释:

1
2
3
4
fn calculate_length(s: &String) -> usize { // s 是 String 的引用
s.len()
} // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权,
// 所以什么也不会发生

变量 s 有效的作用域与函数参数的作用域一样,不过当 s 停止使用时并不丢弃引用指向的数据,因为 s 并没有所有权。当函数使用引用而不是实际值作为参数,无需返回值来交还所有权,因为就不曾拥有所有权。

我们将创建一个引用的行为称为 借用borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完后,必须还回去。因为我们并不拥有它的所有权。

那如果我们尝试修改借用的变量呢?尝试示例 4-6 中的代码。剧透:这行不通!

1
2
3
4
5
6
7
8
9
fn main() {
let s = String::from("hello");

change(&s);
}

fn change(some_string: &String) {
some_string.push_str(", world");
}

示例 4-6:尝试修改借用的值

这里是错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
--> src/main.rs:8:5
|
8 | some_string.push_str(", world");
| ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference
|
7 | fn change(some_string: &mut String) {
| +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

正如变量默认是不可变的,引用也一样。(默认)不允许修改引用的值。

可变引用

我们通过一个小调整就能修复示例 4-6 代码中的错误,允许我们修改一个借用的值,这就是 可变引用mutable reference):

1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello");

change(&mut s);
}

fn change(some_string: &mut String) {
some_string.push_str(", world");
}

首先,我们必须将 s 改为 mut。然后在调用 change 函数的地方创建一个可变引用 &mut s,并更新函数签名以接受一个可变引用 some_string: &mut String。这就非常清楚地表明,change 函数将改变它所借用的值。

可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。这些尝试创建两个 s 的可变引用的代码会失败:

1
2
3
4
5
6
let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);

错误如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
--> src/main.rs:5:14
|
4 | let r1 = &mut s;
| ------ first mutable borrow occurs here
5 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
6 |
7 | println!("{}, {}", r1, r2);
| -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

这个报错说这段代码是无效的,因为我们不能在同一时间多次将 s 作为可变变量借用。第一个可变的借入在 r1 中,并且必须持续到在 println! 中使用它,但是在那个可变引用的创建和它的使用之间,我们又尝试在 r2 中创建另一个可变引用,该引用借用与 r1 相同的数据。

这一限制以一种非常小心谨慎的方式允许可变性,防止同一时间对同一数据存在多个可变引用。新 Rustacean 们经常难以适应这一点,因为大部分语言中变量任何时候都是可变的。这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争data race)类似于竞态条件,它可由这三个行为造成:

  • 两个或更多指针同时访问同一数据。
  • 至少有一个指针被用来写入数据。
  • 没有同步数据访问的机制。

数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 通过拒绝编译存在数据竞争的代码来避免此问题!

一如既往,可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能同时拥有:

1
2
3
4
5
6
7
let mut s = String::from("hello");

{
let r1 = &mut s;
} // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用

let r2 = &mut s;

Rust 在同时使用可变与不可变引用时也强制采用类似的规则。这些代码会导致一个错误:

1
2
3
4
5
6
7
let mut s = String::from("hello");

let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 大问题

println!("{}, {}, and {}", r1, r2, r3);

错误如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:6:14
|
4 | let r1 = &s; // no problem
| -- immutable borrow occurs here
5 | let r2 = &s; // no problem
6 | let r3 = &mut s; // BIG PROBLEM
| ^^^^^^ mutable borrow occurs here
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

呼!我们不能在拥有不可变引用的同时拥有可变引用。

不可变引用的借用者可不希望在借用时值会突然发生改变!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的引用者能够影响其他引用者读取到的数据。

注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。例如,因为最后一次使用不可变引用的位置在 println!,它发生在声明可变引用之前,所以如下代码是可以编译的:

1
2
3
4
5
6
7
8
9
let mut s = String::from("hello");

let r1 = &s; // 没问题
let r2 = &s; // 没问题
println!("{r1} and {r2}");
// 此位置之后 r1 和 r2 不再使用

let r3 = &mut s; // 没问题
println!("{r3}");

不可变引用 r1r2 的作用域在 println! 最后一次使用之后结束,这发生在可变引用 r3 被创建之前。因为它们的作用域没有重叠,所以代码是可以编译的。编译器可以在作用域结束之前判断不再使用的引用。

尽管借用错误有时令人沮丧,但请牢记这是 Rust 编译器在提前指出一个潜在的 bug(在编译时而不是在运行时)并精准显示问题所在。这样你就不必去跟踪为何数据并不是你想象中的那样。

悬垂引用(Dangling References)

在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个悬垂指针dangling pointer)—— 指向可能已被分配给其他用途的内存位置的指针。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂引用:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

让我们尝试创建一个悬垂引用,看看 Rust 如何通过通过一个编译时错误来防止它:

1
2
3
4
5
6
7
8
9
fn main() {
let reference_to_nothing = dangle();
}

fn dangle() -> &String {
let s = String::from("hello");

&s
}

这里是错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
--> src/main.rs:5:16
|
5 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
|
5 | fn dangle() -> &'static String {
| +++++++
help: instead, you are more likely to want to return an owned value
|
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
|

error[E0515]: cannot return reference to local variable `s`
--> src/main.rs:8:5
|
8 | &s
| ^^ returns a reference to data owned by the current function

Some errors have detailed explanations: E0106, E0515.
For more information about an error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 2 previous errors

错误信息引用了一个我们还未介绍的功能:生命周期(lifetimes)。第十章会详细介绍生命周期。不过,如果你不理会生命周期部分,错误信息中确实包含了为什么这段代码有问题的关键信息:

1
2
this function's return type contains a borrowed value, but there is no value
for it to be borrowed from

让我们仔细看看我们的 dangle 代码的每个阶段到底发生了什么:

1
2
3
4
5
6
7
fn dangle() -> &String { // dangle 返回一个字符串的引用

let s = String::from("hello"); // s 是一个新字符串

&s // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃。其内存被释放。
// 危险!

因为 s 是在 dangle 函数内创建的,当 dangle 的代码执行完毕后,s 将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个无效的 String,这可不对!Rust 不会允许我们这么做。

这里的解决方法是直接返回 String

1
2
3
4
5
fn no_dangle() -> String {
let s = String::from("hello");

s
}

这样就没有任何错误了。所有权被移动出去,所以没有值被释放。

引用的规则

让我们概括一下之前对引用的讨论:

  • 在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用。
  • 引用必须总是有效的。

接下来,我们来看看另一种不同类型的引用:slice。

Slice 类型

切片slice)允许你引用集合中一段连续的元素序列,而不用引用整个集合。slice 是一种引用,所以它不拥有所有权。

这里有一个编程小习题:编写一个函数,该函数接收一个用空格分隔单词的字符串,并返回在该字符串中找到的第一个单词。如果函数在该字符串中并未找到空格,则整个字符串就是一个单词,所以应该返回整个字符串。

注意:出于介绍字符串 slice 的目的,本小节假设只使用 ASCII 字符集;一个关于 UTF-8 处理的更全面的讨论位于第八章“使用字符串储存 UTF-8 编码的文本”小节。

让我们推敲下如何不用 slice 编写这个函数的签名,来理解 slice 能解决的问题:

1
fn first_word(s: &String) -> ?

first_word 函数有一个参数 &String。因为我们不需要所有权,所以这没有问题。不过应该返回什么呢?我们并没有一个真正获取部分字符串的办法。不过,我们可以返回单词结尾的索引,结尾由一个空格表示。试试如示例 4-7 中的代码。

文件名:src/main.rs

1
2
3
4
5
6
7
8
9
10
11
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();

for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}

s.len()
}

示例 4-7:first_word 函数返回 String 参数的一个字节索引值

因为需要逐个元素的检查 String 中的值是否为空格,需要用 as_bytes 方法将 String 转化为字节数组。

1
let bytes = s.as_bytes();

接下来,使用 iter 方法在字节数组上创建一个迭代器:

1
for (i, &item) in bytes.iter().enumerate() {

我们将在第十三章详细讨论迭代器。现在,只需知道 iter 方法返回集合中的每一个元素,而 enumerate 包装了 iter 的结果,将这些元素作为元组的一部分来返回。enumerate 返回的元组中,第一个元素是索引,第二个元素是集合中元素的引用。这比我们自己计算索引要方便一些。

因为 enumerate 方法返回一个元组,我们可以使用模式来解构,我们将在第六章中进一步讨论有关模式的问题。所以在 for 循环中,我们指定了一个模式,其中元组中的 i 是索引而元组中的 &item 是单个字节。因为我们从 .iter().enumerate() 中获取了集合元素的引用,所以模式中使用了 &

for 循环中,我们通过字节的字面值语法来寻找代表空格的字节。如果找到了一个空格,返回它的位置。否则,使用 s.len() 返回字符串的长度。

1
2
3
4
5
6
    if item == b' ' {
return i;
}
}

s.len()

现在有了一个找到字符串中第一个单词结尾索引的方法,不过这有一个问题。我们返回了一个独立的 usize,不过它只在 &String 的上下文中才是一个有意义的数字。换句话说,因为它是一个与 String 相分离的值,无法保证将来它仍然有效。考虑一下示例 4-8 中使用了示例 4-7 中 first_word 函数的程序。

文件名:src/main.rs

1
2
3
4
5
6
7
8
9
10
fn main() {
let mut s = String::from("hello world");

let word = first_word(&s); // word 的值为 5

s.clear(); // 这清空了字符串,使其等于 ""

// word 在此处的值仍然是 5,
// 但是没有更多的字符串让我们可以有效地应用数值 5。word 的值现在完全无效!
}

示例 4-8:存储 first_word 函数调用的返回值并接着改变 String 的内容

这个程序编译时没有任何错误,而且在调用 s.clear() 之后使用 word 也不会出错。因为 words 状态完全没有联系,所以 word 仍然包含值 5。可以尝试用值 5 来提取变量 s 的第一个单词,不过这是有 bug 的,因为在我们将 5 保存到 word 之后 s 的内容已经改变。

我们不得不时刻担心 word 的索引与 s 中的数据不再同步,这既繁琐又易出错!如果编写这么一个 second_word 函数的话,管理索引这件事将更加容易出问题。它的签名看起来像这样:

1
fn second_word(s: &String) -> (usize, usize) {

现在我们要跟踪一个开始索引一个结束索引,同时有了更多从数据的某个特定状态计算而来的值,但都完全没有与这个状态相关联。现在有三个飘忽不定的不相关变量需要保持同步。

幸运的是,Rust 为这个问题提供了一个解决方法:字符串 slice。

字符串 slice

字符串 slicestring slice)是 String 中一部分值的引用,它看起来像这样:

1
2
3
4
let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];

不同于整个 String 的引用,hello 是一个部分 String 的引用,由一个额外的 [0..5] 部分指定。可以使用一个由中括号中的 [starting_index..ending_index] 指定的 range 创建一个 slice,其中 starting_index 是 slice 的第一个位置,ending_index 则是 slice 最后一个位置的后一个值。在其内部,slice 的数据结构存储了 slice 的开始位置和长度,长度对应于 ending_index 减去 starting_index 的值。所以对于 let world = &s[6..11]; 的情况,world 将是一个包含指向 s 索引 6 的指针和长度值 5 的 slice。

图 4-7 展示了一个图例。

Three tables: a table representing the stack data of s, which points to the byte at index 0 in a table of the string data "hello world" on the heap. The third table rep-resents the stack data of the slice world, which has a length value of 5 and points to byte 6 of the heap data table.

图 4-7:引用了部分 String 的字符串 slice

对于 Rust 的 .. range 语法,如果想要从索引 0 开始,可以不写两个点号之前的值。换句话说,如下两个语句是相同的:

1
2
3
4
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];

依此类推,如果 slice 包含 String 的最后一个字节,也可以舍弃尾部的数字。这意味着如下也是相同的:

1
2
3
4
5
6
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];

也可以同时舍弃这两个值来获取整个字符串的 slice。所以如下亦是相同的:

1
2
3
4
5
6
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];

注意:字符串 slice range 的索引必须位于有效的 UTF-8 字符边界内,如果尝试从一个多字节字符的中间位置创建字符串 slice,则程序将会因错误而退出。

在记住所有这些知识后,让我们重写 first_word 来返回一个 slice。“字符串 slice” 的类型声明写作 &str

1
2
3
4
5
6
7
8
9
10
11
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();

for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}

&s[..]
}

我们使用跟示例 4-7 相同的方式获取单词结尾的索引,通过寻找第一个出现的空格。当找到一个空格,我们返回一个字符串 slice,它使用字符串的开始和空格的索引作为开始和结束的索引。

现在当调用 first_word 时,会返回与底层数据关联的单个值。这个值由一个 slice 开始位置的引用和 slice 中元素的数量组成。

second_word 函数也可以改为返回一个 slice:

1
fn second_word(s: &String) -> &str {

现在我们有了一个不易混淆且直观的 API 了,因为编译器会确保指向 String 的引用持续有效。还记得示例 4-8 程序中,那个当我们获取第一个单词结尾的索引后,接着就清除了字符串导致索引就无效的 bug 吗?那些代码在逻辑上是不正确的,但却没有显示任何直接的错误。问题会在之后尝试对空字符串使用第一个单词的索引时出现。slice 就不可能出现这种 bug 并让我们更早的知道出问题了。使用 slice 版本的 first_word 会抛出一个编译时错误:

1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello world");

let word = first_word(&s);

s.clear(); // 错误!

println!("the first word is: {word}");
}

这里是编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // error!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("the first word is: {word}");
| ------ immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

回忆一下借用规则,当拥有某值的不可变引用时,就不能再获取一个可变引用。因为 clear 需要清空 String,它尝试获取一个可变引用。在调用 clear 之后的 println! 使用了 word 中的引用,所以这个不可变的引用在此时必须仍然有效。Rust 不允许 clear 中的可变引用和 word 中的不可变引用同时存在,因此编译失败。Rust 不仅使得我们的 API 简单易用,也在编译时就消除了一整类的错误!

字符串字面值就是 slice

还记得我们讲到过字符串字面值被储存在二进制文件中吗?现在知道 slice 了,我们就可以正确地理解字符串字面值了:

1
let s = "Hello, world!";

这里 s 的类型是 &str:它是一个指向二进制程序特定位置的 slice。这也就是为什么字符串字面值是不可变的;&str 是一个不可变引用。

字符串 slice 作为参数

在知道了能够获取字面值和 String 的 slice 后,我们对 first_word 做了改进,这是它的签名:

1
fn first_word(s: &String) -> &str {

而更有经验的 Rustacean 会编写出示例 4-9 中的签名,因为它使得可以对 &String 值和 &str 值使用相同的函数:

1
fn first_word(s: &str) -> &str {

示例 4-9: 通过将 s 参数的类型改为字符串 slice 来改进 first_word 函数

如果有一个字符串 slice,可以直接传递它。如果有一个 String,则可以传递整个 String 的 slice 或对 String 的引用。这种灵活性利用了 deref coercions 的优势,这个特性我们将在“函数和方法的隐式 Deref 强制转换”章节中介绍。定义一个获取字符串 slice 而不是 String 引用的函数使得我们的 API 更加通用并且不会丢失任何功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn main() {
let my_string = String::from("hello world");

// `first_word` 适用于 `String`(的 slice),部分或全部
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` 也适用于 `String` 的引用,
// 这等价于整个 `String` 的 slice
let word = first_word(&my_string);

let my_string_literal = "hello world";

// `first_word` 适用于字符串字面值,部分或全部
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);

// 因为字符串字面值已经 **是** 字符串 slice 了,
// 这也是适用的,无需 slice 语法!
let word = first_word(my_string_literal);
}

其他类型的 slice

字符串 slice,正如你想象的那样,是针对字符串的。不过也有更通用的 slice 类型。考虑一下这个数组:

1
let a = [1, 2, 3, 4, 5];

就跟我们想要获取字符串的一部分那样,我们也会想要引用数组的一部分。我们可以这样做:

1
2
3
4
5
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);

这个 slice 的类型是 &[i32]。它跟字符串 slice 的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度。你可以对其他所有集合使用这类 slice。第八章讲到 vector 时会详细讨论这些集合。