引用与借用

所有权、box 和移动为安全地使用堆进行编程提供了基础。然而,仅移动的 API 使用起来可能不太方便。例如,假设你想读取一些字符串两次

在这个例子中,调用 greet 将数据从 m1m2 移动到 greet 的参数中。这两个字符串在 greet 结束时都被 drop,因此不能在 main 中使用。如果我们尝试像在 format!(..) 操作中那样读取它们,那将是未定义的行为。因此,Rust 编译器拒绝了这个程序,并报出了我们在上一节看到的相同错误

error[E0382]: borrow of moved value: `m1`
 --> test.rs:5:30
 (...rest of the error...)

这种移动行为非常不方便。程序经常需要多次使用一个字符串。另一种 greet 可以返回字符串的所有权,像这样

然而,这种编程风格相当冗长。Rust 提供了一种简洁的读取和写入风格,无需通过引用进行移动。

引用是非所有权的指针

引用是一种指针。这是一个引用的例子,它以更方便的方式重写了我们的 greet 程序

表达式 &m1 使用与号运算符创建一个对 m1 的引用(或“借用”)。greet 参数 g1 的类型更改为 &String,意思是“对 String 的引用”。

在 L2 行观察到从 g1 到字符串 “Hello” 有两个步骤。g1 是一个引用,它指向栈上的 m1,而 m1 是一个 String,包含一个 box,它指向堆上的 “Hello”。

虽然 m1 拥有堆数据 “Hello”,但 g1拥有 m1 或 “Hello”。因此,在 greet 结束且程序到达 L3 行后,没有堆数据被释放。只有 greet 的栈帧消失了。这个事实与我们的Box 释放原则一致。因为 g1 不拥有 “Hello”,所以 Rust 没有代表 g1 释放 “Hello”。

引用是非所有权的指针,因为它们不拥有它们指向的数据。

解引用指针访问其数据

之前使用 box 和 string 的例子没有展示 Rust 如何“追踪”指针到其数据。例如,println! 宏神秘地适用于 String 类型的拥有字符串和 &String 类型的字符串引用。底层机制是解引用运算符,用星号 (*) 表示。例如,这是一个程序,它以几种不同的方式使用解引用

观察 r1 指向栈上的 xr2 指向堆值 2 之间的区别。

当你阅读 Rust 代码时,你可能不会经常看到解引用运算符。在某些情况下,例如使用点运算符调用方法时,Rust 会隐式地插入解引用和引用。例如,这个程序展示了两种等效的方式来调用 i32::abs(绝对值)和 str::len(字符串长度)函数

fn main()  {
let x: Box<i32> = Box::new(-1);
let x_abs1 = i32::abs(*x); // explicit dereference
let x_abs2 = x.abs();      // implicit dereference
assert_eq!(x_abs1, x_abs2);

let r: &Box<i32> = &x;
let r_abs1 = i32::abs(**r); // explicit dereference (twice)
let r_abs2 = r.abs();       // implicit dereference (twice)
assert_eq!(r_abs1, r_abs2);

let s = String::from("Hello");
let s_len1 = str::len(&s); // explicit reference
let s_len2 = s.len();      // implicit reference
assert_eq!(s_len1, s_len2);
}

这个例子展示了三种方式的隐式转换

  1. i32::abs 函数期望输入类型为 i32。要使用 Box<i32> 调用 abs,你可以显式地解引用 box,例如 i32::abs(*x)。你也可以使用方法调用语法(例如 x.abs())隐式地解引用 box。点语法是函数调用语法的语法糖。

  2. 这种隐式转换适用于多层指针。例如,对 box 的引用 r: &Box<i32> 调用 abs 将插入两个解引用。

  3. 这种转换也适用于相反的方向。函数 str::len 期望一个引用 &str。如果你对一个拥有的 String 调用 len,那么 Rust 将插入一个借用运算符。(事实上,还有从 Stringstr 的进一步转换!)

我们将在后面的章节中更多地讨论方法调用和隐式转换。现在,重要的结论是,这些转换发生在方法调用和一些宏(如 println)中。我们想揭开 Rust 所有“魔法”的面纱,以便你可以对 Rust 的工作方式有一个清晰的心智模型。

Rust 避免同时别名和修改

指针是一个强大而危险的特性,因为它们启用了别名。别名是通过不同的变量访问相同的数据。别名本身是无害的。但是与修改结合起来,我们就有了灾难的配方。一个变量可以以多种方式“抽走”另一个变量的“地毯”,例如

  • 通过释放别名数据,使另一个变量指向已释放的内存。
  • 通过修改别名数据,使另一个变量期望的运行时属性无效。
  • 通过并发地修改别名数据,导致数据竞争,为另一个变量带来不确定的行为。

作为一个运行示例,我们将查看使用 vector 数据结构 Vec 的程序。与长度固定的数组不同,vector 通过将其元素存储在堆中而具有可变长度。例如,Vec::push 将一个元素添加到 vector 的末尾,像这样

vec! 创建一个 vector,元素在方括号之间。vector v 的类型为 Vec<i32>。语法 <i32> 表示 vector 的元素类型为 i32

一个重要的实现细节是 v 分配了一个具有特定容量的堆数组。我们可以窥探 Vec 的内部结构,亲自查看这个细节

注意:单击图表右上角的双筒望远镜图标,可以在任何运行时图表中切换此详细视图。

请注意,vector 的长度 (len) 为 3,容量 (cap) 为 3。vector 已满。因此,当我们执行 push 操作时,vector 必须创建一个具有更大容量的新分配,复制所有元素,并释放原始堆数组。在上面的图中,数组 1 2 3 4 位于与原始数组 1 2 3(可能)不同的内存位置。

为了将这一点与内存安全联系起来,让我们将引用引入进来。假设我们创建了一个对 vector 堆数据的引用。然后,该引用可能会因 push 操作而失效,如下模拟所示

最初,v 指向堆上具有 3 个元素的数组。然后在 L1 行创建 num 作为对第三个元素的引用。但是,v.push(4) 操作调整了 v 的大小。调整大小将释放先前的数组并分配一个新的、更大的数组。在此过程中,num 仍然指向无效内存。因此,在 L3 行,解引用 *num 会读取无效内存,导致未定义的行为。

更抽象地说,问题在于 vector v 既被别名化(通过引用 num)又被修改(通过操作 v.push(4))。因此,为了避免这些类型的问题,Rust 遵循一个基本原则

指针安全原则:数据永远不应同时被别名化和修改。

数据可以被别名化。数据可以被修改。但是数据不能被别名化被修改。例如,Rust 通过禁止别名化来强制执行 box(拥有的指针)的此原则。将一个 box 从一个变量分配给另一个变量将移动所有权,使先前的变量无效。拥有的数据只能通过所有者访问 —— 没有别名。

但是,由于引用是非所有权的指针,因此它们需要与 box 不同的规则来确保指针安全原则。按照设计,引用旨在临时创建别名。在本节的其余部分,我们将解释 Rust 如何通过借用检查器确保引用安全性的基础知识。

引用更改对位置的权限

借用检查器的核心思想是变量对其数据具有三种类型的权限

  • 读取 (R):数据可以复制到另一个位置。
  • 写入 (W):数据可以修改。
  • 拥有 (O):数据可以移动或 drop。

这些权限不存在于运行时,仅存在于编译器中。它们描述了编译器在程序执行之前如何“思考”你的程序。

默认情况下,变量对其数据具有读取/拥有权限 (RO)。如果变量用 let mut 注释,那么它也具有写入权限 (W)。关键思想是引用可以暂时删除这些权限。

为了说明这个想法,让我们看一下上面程序的变体中的权限,该变体实际上是安全的。push 已移到 println! 之后。此程序中的权限使用一种新型图表可视化。该图表显示了每行权限的变化。

让我们逐行浏览

  1. let mut v = (...) 之后,变量 v 已初始化(用 表示)。它获得了 +R+W+O 权限(加号表示获得)。
  2. let num = &v[2] 之后,v 中的数据已被 num借用(用 表示)。发生了三件事
    • 借用从 v 中删除了
      W
      O
      权限(斜杠表示丢失)。v 不能被写入或拥有,但仍然可以读取。
    • 变量 num 获得了 RO 权限。num 不可写(缺少的 W 权限显示为破折号 ),因为它没有标记为 let mut
    • 位置 *num 获得了 R 权限。
  3. println!(...) 之后,num 不再使用,因此 v 不再被借用。因此
    • v 重新获得了其 WO 权限(用 表示)。
    • num*num 丢失了所有权限(用 表示)。
  4. v.push(4) 之后,v 不再使用,并且它丢失了所有权限。

接下来,让我们探讨一下图表的一些细微之处。首先,为什么你同时看到 num*num?因为通过引用访问数据与操作引用本身不同。例如,假设我们用 let mut 声明一个对数字的引用

请注意,x_ref 具有 W 权限,而 *x_ref 没有。这意味着我们可以为 x_ref 变量分配不同的引用(例如 x_ref = &y),但我们不能修改它指向的数据(例如 *x_ref += 1)。

更一般地,权限是在位置上定义的,而不仅仅是变量。位置是你可以放在赋值左侧的任何东西。位置包括

  • 变量,如 a
  • 位置的解引用,如 *a
  • 位置的数组访问,如 a[0]
  • 位置的字段,对于元组如 a.0,对于结构体如 a.field(下一章讨论)。
  • 以上任意组合,如 *((*a)[0].1)

其次,为什么位置在不再使用时会丢失权限?因为某些权限是互斥的。如果你写 num = &v[2],那么在 num 使用期间,v 不能被修改或 drop。但这并不意味着再次使用 num 是无效的。例如,如果我们在上面的程序中添加另一个 println!,那么 num 只是在一行之后丢失了其权限

只有在你修改 v之后尝试再次使用 num 时,才会出现问题。让我们更详细地看一下这一点。

借用检查器查找权限冲突

回想一下指针安全原则:数据不应同时被别名化和修改。这些权限的目标是确保数据在被别名化时不能被修改。创建对数据的引用(“借用”它)会导致该数据暂时变为只读,直到不再使用该引用为止。

Rust 在其借用检查器中使用这些权限。借用检查器查找涉及引用的潜在不安全操作。让我们回到我们之前看到的 unsafe 程序,其中 push 使引用无效。这次我们将向权限图添加另一个方面

每当使用位置时,Rust 都期望该位置具有某些权限,具体取决于操作。例如,借用 &v[2] 要求 v 是可读的。因此,R 权限显示在操作 & 和位置 v 之间。字母已填充,因为 v 在该行具有读取权限。

相比之下,修改操作 v.push(4) 要求 v 是可读和可写的。同时显示了 RW。但是,v 没有写入权限(它被 num 借用)。因此,字母 W 是空心的,表示期望写入权限,但 v 没有。

如果你尝试编译这个程序,那么 Rust 编译器将返回以下错误

error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> test.rs:4:1
  |
3 | let num: &i32 = &v[2];
  |                  - immutable borrow occurs here
4 | v.push(4);
  | ^^^^^^^^^ mutable borrow occurs here
5 | println!("Third element is {}", *num);
  |                                 ---- immutable borrow later used here

错误消息解释说,当引用 num 正在使用时,v 不能被修改。这是表面原因 —— 底层问题是 num 可能会因 push 而失效。Rust 捕获了这种潜在的内存安全违规行为。

可变引用提供对数据的唯一且非所有权的访问

我们到目前为止看到的引用是只读的不可变引用(也称为共享引用)。不可变引用允许别名化,但不允许修改。但是,临时提供对数据的可变访问而不移动它也很有用。

实现此目的的机制是可变引用(也称为唯一引用)。这是一个可变引用的简单示例,以及随附的权限更改

注意:当预期权限与示例不严格相关时,我们将它们缩写为点,例如
R
W
。你可以将鼠标悬停在圆圈上(或在触摸屏上点击)以查看相应的权限字母。

可变引用是使用 &mut 运算符创建的。num 的类型写为 &mut i32。与不可变引用相比,你可以看到权限方面的两个重要区别

  1. num 是不可变引用时,v 仍然具有 R 权限。现在 num 是可变引用,vnum 使用期间丢失了所有权限。
  2. num 是不可变引用时,位置 *num 仅具有 R 权限。现在 num 是可变引用,*num 也获得了 W 权限。

第一个观察结果是使可变引用安全的原因。可变引用允许修改,但防止别名化。借用的位置 v 暂时变得不可用,因此实际上不是别名。

第二个观察结果是使可变引用有用的原因。v[2] 可以通过 *num 修改。例如,*num += 1 修改 v[2]。请注意,*num 具有 W 权限,但 num 没有。num 指的是可变引用本身,例如,num 不能重新分配给不同的可变引用。

可变引用也可以临时“降级”为只读引用。例如

注意:当权限更改与示例不相关时,我们将隐藏它们。你可以通过单击 “»” 查看隐藏步骤,并且可以通过单击 “● ● ●” 查看步骤中的隐藏权限。

在这个程序中,借用 &*num*num 中删除了 W 权限,但没有删除 R 权限,因此 println!(..) 可以读取 *num*num2

权限在引用的生命周期结束时返回

我们上面说过,引用在其“使用中”时会更改权限。“使用中”这个短语描述了引用的生命周期,或代码范围,从它的诞生(引用创建的地方)到它的死亡(最后一次使用引用的时间)。

例如,在这个程序中,y 的生命周期从 let y = &x 开始,到 let z = *y 结束

xW 权限在 y 的生命周期结束后返回给 x,就像我们之前看到的那样。

在之前的示例中,生命周期一直是代码的连续区域。但是,一旦我们引入控制流,情况就不一定如此了。例如,这是一个函数,它将 ASCII 字符 vector 中第一个字符大写

变量 c 在 if 语句的每个分支中都有不同的生命周期。在 then 代码块中,c 在表达式 c.to_ascii_uppercase() 中使用。因此,*v 不会在该行之后重新获得 W 权限。

但是,在 else 代码块中,未使用 c*v 在进入 else 代码块时立即重新获得 W 权限。

数据必须比其所有引用都活得更久

作为指针安全原则的一部分,借用检查器强制执行数据必须比指向它的任何引用都活得更久。 Rust 通过两种方式强制执行此属性。第一种方式处理在单个函数作用域内创建和 drop 的引用。例如,假设我们尝试在持有对字符串的引用时 drop 该字符串

为了捕获这些类型的错误,Rust 使用了我们已经讨论过的权限。借用 &ss 中删除了 O 权限。但是,drop 需要 O 权限,从而导致权限不匹配。

关键思想是,在这个例子中,Rust 知道 s_ref 存活多长时间。但是,当 Rust 不知道引用存活多长时间时,它需要一种不同的强制执行机制。特别是,当引用是函数的输入或函数的输出时。例如,这是一个安全的函数,它返回对 vector 中第一个元素的引用

此代码片段引入了一种新的权限,即流权限 FF 权限在表达式使用输入引用(如 &strings[0])或返回输出引用(如 return s_ref)时都需要。

RWO 权限不同,F 在函数体中不会改变。如果引用允许在特定表达式中使用(即流动),则该引用具有 F 权限。例如,假设我们将 first 更改为新的函数 first_or,其中包含 default 参数

这个函数不再编译,因为表达式 &strings[0]default 缺少返回所需的 F 权限。但为什么?Rust 给出了以下错误

error[E0106]: missing lifetime specifier
 --> test.rs:1:57
  |
1 | fn first_or(strings: &Vec<String>, default: &String) -> &String {
  |                      ------------           -------     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `strings` or `default`

消息 “missing lifetime specifier” 有点神秘,但帮助消息提供了一些有用的上下文。如果 Rust查看函数签名,它不知道输出 &String 是对 strings 还是 default 的引用。为了理解为什么这很重要,假设我们像这样使用 first_or

fn main() {
    let strings = vec![];
    let default = String::from("default");
    let s = first_or(&strings, &default);
    drop(default);
    println!("{}", s);
}

如果 first_or 允许 default流入返回值,则此程序是不安全的。与之前的示例一样,drop 可能会使 s 无效。只有当 Rust确定 default 不能流入返回值时,才会允许编译此程序。

为了指定是否可以返回 default,Rust 提供了一种称为生命周期参数的机制。我们将在第 10.3 章 “使用生命周期验证引用” 中稍后解释该功能。现在,知道以下几点就足够了:(1)输入/输出引用的处理方式与函数体内的引用不同,以及(2)Rust 使用不同的机制,即 F 权限,来检查这些引用的安全性。

要在另一个上下文中查看 F 权限,假设你尝试返回对栈上变量的引用,如下所示

这个程序是不安全的,因为当 return_a_string 返回时,引用 &s 将失效。Rust 将拒绝这个程序,并报出类似的 missing lifetime specifier 错误。现在你可以理解,该错误意味着 s_ref 缺少适当的流权限。

总结

引用提供了读取和写入数据而无需消耗其所有权的能力。引用通过借用(&&mut)创建,并通过解引用(*)使用,通常是隐式的。

但是,引用很容易被误用。Rust 的借用检查器强制执行一套权限系统,以确保引用的安全使用

  • 所有变量都可以读取、拥有和(可选地)写入其数据。
  • 创建引用会将权限从借用的位置转移到引用。
  • 权限在引用的生命周期结束后返回。
  • 数据必须比指向它的所有引用都活得更久。

在本节中,你可能会觉得我们描述了更多 Rust 不能做什么,而不是 Rust 做什么。这是故意的!Rust 的核心功能之一是允许你使用指针而无需垃圾回收,同时避免未定义的行为。现在理解这些安全规则将帮助你避免以后对编译器感到沮丧。