引用与借用
所有权、box 和移动为安全地使用堆进行编程提供了基础。然而,仅移动的 API 使用起来可能不太方便。例如,假设你想读取一些字符串两次
在这个例子中,调用 greet
将数据从 m1
和 m2
移动到 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
指向栈上的 x
和 r2
指向堆值 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);
}
这个例子展示了三种方式的隐式转换
-
i32::abs
函数期望输入类型为i32
。要使用Box<i32>
调用abs
,你可以显式地解引用 box,例如i32::abs(*x)
。你也可以使用方法调用语法(例如x.abs()
)隐式地解引用 box。点语法是函数调用语法的语法糖。 -
这种隐式转换适用于多层指针。例如,对 box 的引用
r: &Box<i32>
调用abs
将插入两个解引用。 -
这种转换也适用于相反的方向。函数
str::len
期望一个引用&str
。如果你对一个拥有的String
调用len
,那么 Rust 将插入一个借用运算符。(事实上,还有从String
到str
的进一步转换!)
我们将在后面的章节中更多地讨论方法调用和隐式转换。现在,重要的结论是,这些转换发生在方法调用和一些宏(如 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!
之后。此程序中的权限使用一种新型图表可视化。该图表显示了每行权限的变化。
让我们逐行浏览
- 在
let mut v = (...)
之后,变量v
已初始化(用 表示)。它获得了 +R+W+O 权限(加号表示获得)。 - 在
let num = &v[2]
之后,v
中的数据已被num
借用(用 表示)。发生了三件事- 借用从
v
中删除了 WO 权限(斜杠表示丢失)。v
不能被写入或拥有,但仍然可以读取。 - 变量
num
获得了 RO 权限。num
不可写(缺少的 W 权限显示为破折号 ‒),因为它没有标记为let mut
。 - 位置
*num
获得了 R 权限。
- 借用从
- 在
println!(...)
之后,num
不再使用,因此v
不再被借用。因此v
重新获得了其 WO 权限(用 表示)。num
和*num
丢失了所有权限(用 表示)。
- 在
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
是可读和可写的。同时显示了 R 和 W。但是,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 捕获了这种潜在的内存安全违规行为。
可变引用提供对数据的唯一且非所有权的访问
我们到目前为止看到的引用是只读的不可变引用(也称为共享引用)。不可变引用允许别名化,但不允许修改。但是,临时提供对数据的可变访问而不移动它也很有用。
实现此目的的机制是可变引用(也称为唯一引用)。这是一个可变引用的简单示例,以及随附的权限更改
注意:当预期权限与示例不严格相关时,我们将它们缩写为点,例如。你可以将鼠标悬停在圆圈上(或在触摸屏上点击)以查看相应的权限字母。
可变引用是使用 &mut
运算符创建的。num
的类型写为 &mut i32
。与不可变引用相比,你可以看到权限方面的两个重要区别
- 当
num
是不可变引用时,v
仍然具有 R 权限。现在num
是可变引用,v
在num
使用期间丢失了所有权限。 - 当
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
结束
x
的 W 权限在 y
的生命周期结束后返回给 x
,就像我们之前看到的那样。
在之前的示例中,生命周期一直是代码的连续区域。但是,一旦我们引入控制流,情况就不一定如此了。例如,这是一个函数,它将 ASCII 字符 vector 中第一个字符大写
变量 c
在 if 语句的每个分支中都有不同的生命周期。在 then 代码块中,c
在表达式 c.to_ascii_uppercase()
中使用。因此,*v
不会在该行之后重新获得 W 权限。
但是,在 else 代码块中,未使用 c
。*v
在进入 else 代码块时立即重新获得 W 权限。
数据必须比其所有引用都活得更久
作为指针安全原则的一部分,借用检查器强制执行数据必须比指向它的任何引用都活得更久。 Rust 通过两种方式强制执行此属性。第一种方式处理在单个函数作用域内创建和 drop 的引用。例如,假设我们尝试在持有对字符串的引用时 drop 该字符串
为了捕获这些类型的错误,Rust 使用了我们已经讨论过的权限。借用 &s
从 s
中删除了 O 权限。但是,drop
需要 O 权限,从而导致权限不匹配。
关键思想是,在这个例子中,Rust 知道 s_ref
存活多长时间。但是,当 Rust 不知道引用存活多长时间时,它需要一种不同的强制执行机制。特别是,当引用是函数的输入或函数的输出时。例如,这是一个安全的函数,它返回对 vector 中第一个元素的引用
此代码片段引入了一种新的权限,即流权限 F。F 权限在表达式使用输入引用(如 &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 的核心功能之一是允许你使用指针而无需垃圾回收,同时避免未定义的行为。现在理解这些安全规则将帮助你避免以后对编译器感到沮丧。