引用与借用
# 引用与借用
所有权、Box 以及 move 操作为我们安全地使用堆内存提供了基础。然而,只有“移动”所有权的 API 用起来可能会很不方便。比如说,如果你想读取某些字符串两次:
在这个例子中,调用 greet
会把 m1
和 m2
的数据移动到 greet
的参数里。这两个字符串会在 greet
结束时被释放,因此在 main
中无法再使用它们。如果我们像后面的 format!(..)
那样试图再次读取它们,就会导致未定义行为。因此,Rust 编译器会像上一节那样拒绝编译这个程序,并报出相同的错误:
error[E0382]: borrow of moved value: `m1`
--> test.rs:5:30
(...rest of the error...)
2
3
这种 move 行为非常不方便。程序中经常需要多次使用同一个字符串。我们可以把 greet
改成返回字符串的所有权,像这样:
然而,这种编程风格会让代码变得非常冗长。为此,Rust 提供了一种无需移动所有权、更加简洁的读写方式——引用。
# 引用是“非拥有型”指针
引用(reference)其实也是一种指针。下面是一个使用引用改写 greet
的示例,代码就简洁多了:
表达式 &m1
使用了 & 运算符,创建了对 m1
的一个引用(也称“借用”)。greet
的参数类型也变成了 &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 和字符串的例子,并没有直接展示 Rust 是如何“跟随”指针找到其数据的。比如,println!
这个宏,既能接受所有权类型 String
,也能接受字符串引用 &String
,这其实都离不开解引用操作符(*
)。下面是一个展示多种解引用用法的小程序:
请注意,r1
指向的是栈上的 x
,而 r2
指向的是堆上的值 2,这两者有所不同。
实际上,在日常的 Rust 代码中,你可能很少直接看到 *
这个解引用操作符。Rust 在某些场景下会自动帮你插入解引用和引用,比如用点操作符调用方法时。例如,下面这个程序展示了两种等价的方式来调用 i32::abs
(绝对值)和 str::len
(字符串长度)函数:
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);
2
3
4
5
6
7
8
9
10
11
12
13
14
这个例子展示了三种隐式转换:
i32::abs
函数期望输入类型为i32
。如果你用Box<i32>
调用abs
,可以像i32::abs(*x)
这样显式地对 box 解引用。你也可以用方法调用语法x.abs()
隐式地解引用。点号语法(.
)实际上就是函数调用语法的语法糖。这种隐式解引用对于多层指针同样适用。例如,对一个指向 box 的引用
r: &Box<i32>
调用abs
,Rust 会自动插入两次解引用。这种转换也支持相反的方向。例如,
str::len
函数期望一个&str
类型的引用。如果你对一个拥有所有权的String
调用len
,Rust 会自动插入一次借用操作(实际上,还会有一个从String
到str
的自动转换!)
我们会在后续章节更详细地介绍方法调用和隐式转换。现在你只需要记住:这些转换会在方法调用和像 println
这样的宏中自动发生。我们的目标就是帮你揭开 Rust 中所有的“魔法”,让你能清晰地理解 Rust 的工作原理。
宏 vec!
用于创建一个包含方括号内所有元素的向量。变量 v
的类型是 Vec<i32>
,这里的 <i32>
表示该向量的元素类型为 i32
。
有一个重要的实现细节:v
会在堆上分配一个具有一定容量的数组。我们可以窥探一下 Vec
的内部结构,来看看这个细节:
注意:你可以点击图表右上角的望远镜图标,在任何运行时内存图中切换是否显示详细视图。
你可以看到,这个向量的长度(len)是 3,容量(cap)也是 3。此时向量已满。所以当我们调用 push
往里面添加新元素时,向量必须分配一块更大的新内存,将所有元素复制过去,然后释放原本的堆数组。在上面的图中,数组 1 2 3 4
(新内存)可能和原来的数组 1 2 3
位于不同的内存位置。
为了说明这和内存安全的关系,让我们再把引用引入进来。假如我们创建了一个指向向量堆数据的引用,在 push
之后,这个引用就可能变得无效。可以参考下面的模拟:
最开始,v
指向的是堆上的一个有 3 个元素的数组。接着,我们用 num
创建了对第三个元素的引用,这在 L1 处可以看到。然而,执行 v.push(4)
后,v
发生了扩容。扩容操作会释放原有的数组,然后分配一个更大的新数组。在这个过程中,num
变成了悬空引用(指向了无效内存)。因此,在 L3 处,解引用 *num
读取的就是无效内存,会造成未定义行为。
用更抽象的语言来说,这里的问题在于:向量 v
被引用 num
共享(aliasing)的同时,又被 v.push(4)
修改(mutated)了。为避免这类问题,Rust 遵循这样一个基本原则:
指针安全原则:数据不能在被别名(aliased)和被修改(mutated)的同时发生。
——也就是说,数据可以被别名,也可以被修改,但不能同时既被别名又被修改。例如,对于 Box(拥有型指针),Rust 通过禁止别名来遵守这一原则。将一个 box 赋值给另一个变量时,所有权会被 move,之前的变量就失效了。拥有型数据只能通过拥有者访问,无法被别名。
但由于引用是“非拥有型指针”,要保证指针安全原则,就需要采用不同于 box 的规则。引用本身就是为了临时制造别名而设计的。在本节的后续内容中,我们会介绍 Rust 如何通过借用检查器(borrow checker)来确保引用的安全。
# 引用会改变变量对数据的“权限”
借用检查器(borrow checker)的核心思想,是每个变量对其数据有三种“权限”:
- 读取(R):数据可以被复制到其他地方。
- 写入(W):数据可以被修改。
- 拥有(O):数据可以被移动或释放(drop)。
这些权限只存在于编译器的分析阶段,并不存在于运行时。它们描述了编译器在程序运行前是如何“思考”你的程序的。
默认情况下,一个变量对它的数据拥有读和拥有(RO)权限。如果变量用 let mut
标记,那么它还会有写(W)权限。关键在于,引用可以临时移除这些权限。
为了说明这个思想,让我们来看一个对前面程序的改动版,这个程序是安全的——我们把 push
操作放在了 println!
之后。下图用新方式展示了每一行代码对变量权限的影响。
我们来逐行分析:
在
let mut v = (...)
之后,变量v
被初始化(图中有对应标记)。它获得了+R+W+O
权限(加号表示获得权限)。在
let num = &v[2]
之后,v
中的数据被num
借用了(图中有对应箭头)。此时有三件事发生:- 借用操作会从
v
上移除 W(写)和 O(拥有)权限(斜杠表示丢失权限)。此时v
不能再被写或 move,但仍然可以被读。 - 变量
num
获得了 RO(读和拥有)权限。由于没有标记let mut
,所以没有 W(写)权限,图中用横线(‒)表示缺失的写权限。 - 通过
*num
,获得了 R(读)权限。
- 借用操作会从
在
println!(...)
之后,num
不再被使用,因此v
不再被借用:v
重新获得 W 和 O 权限(用加号表示)。num
和*num
失去所有权限(图中有相应标记)。
在
v.push(4)
之后,v
也不再被使用,失去了所有权限。
接下来,我们还可以探索一下图中的细节。比如,为什么会同时看到 num
和 *num
?这是因为通过引用访问数据,和直接操作引用本身不是一回事。例如,如果我们用 let mut
声明一个对数字的引用:
注意,这里 x_ref
拥有 W(写)权限,而 *x_ref
并没有写权限。这意味着我们可以为 x_ref
变量重新赋值(比如 x_ref = &y
),但不能修改它指向的数据(比如 *x_ref += 1
是不允许的)。
更一般地说,权限是定义在“位置”(place)上的,而不仅仅是变量。一个 place 就是任何可以出现在赋值语句左边的东西。place 的例子包括:
- 变量,比如
a
- 对 place 的解引用,比如
*a
- 对 place 的数组访问,比如
a[0]
- 对 place 的字段访问,比如元组的
a.0
或结构体的a.field
(下一章会讲) - 以及上述形式的任意组合,比如
*(((*a)[0].1))
另外,为什么 place 在不用时会失去权限?这是因为某些权限是互斥的。如果你写了 num = &v[2]
,那么在 num
还在使用期间,v
不能被修改或销毁。但这并不意味着之后再使用 num
是非法的。比如,如果我们在上面的程序里多加一个 println!
,那么 num
只是会在多一行代码后才失去权限:
只有在你修改了 v
之后还试图再次使用 num
时,才会出现问题。我们来详细看看这一点。
# 借用检查器(Borrow Checker)如何发现权限违规
回忆一下“指针安全原则”:数据不能在被别名和被修改的同时发生。这些权限的目的,就是确保当数据被引用(借用)时,不能被修改。也就是说,当你创建了对数据的引用(“借用”),那么在这个引用被使用期间,该数据就是临时只读的,直到引用生命周期结束。
Rust 在借用检查器中使用这些权限。借用检查器会检测所有涉及引用的潜在不安全操作。我们再回到前面那个不安全的例子,也就是 push
导致引用失效的程序。这次我们还会在权限图里加上一些新内容:
每当一个 place 被使用时,Rust 都会根据不同的操作,要求它具备特定的权限。例如,借用 &v[2]
这个操作要求 v
具有读(R)权限。因此,在操作符 &
和 place v
之间,会标注字母 R。如果 v
在该行拥有读权限,这个字母就是实心的。
相反,修改操作 v.push(4)
需要 v
同时具备读(R)和写(W)权限,因此会同时显示 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
2
3
4
5
6
7
8
9
错误信息解释说,在引用 num
还在被使用时,v
不能被修改。这只是表面原因——更深层的原因在于,push
操作可能让 num
变成无效引用。Rust 能检测到这种潜在的内存安全隐患。
# 可变引用:为数据提供唯一且非拥有型的访问权限
到目前为止,我们看到的引用都是只读的不可变引用(也叫共享引用)。不可变引用允许别名,但不允许修改数据。然而,在某些场景下,我们也需要临时获得对数据的可变访问权,而又不想移动所有权。
为此,Rust 提供了“可变引用”(也叫唯一引用)。下面是一个带有权限变化说明的可变引用简单示例:
可变引用用 &mut
操作符创建,num
的类型写作 &mut i32
。与不可变引用相比,你可以看到权限上有两个重要区别:
- 当
num
是不可变引用时,v
依然保有 R(读)权限;但现在num
是可变引用时,只要num
在被使用,v
失去了所有权限。 - 当
num
是不可变引用时,*num
只有 R(读)权限;但现在作为可变引用,*num
还获得了 W(写)权限。
第一个区别保证了可变引用的安全性:可变引用允许修改数据,但不允许产生别名。被借用的 v
会暂时不可用,因此实际上不会出现别名。
第二个区别体现了可变引用的实用性:可以通过 *num
修改 v[2]
,比如 *num += 1
就会修改 v[2]
。注意,*num
有写权限,而 num
本身没有,也就是说 num
不能被重新赋值为别的可变引用,只能操作其所指向的数据。
此外,可变引用还可以被临时“降级”为只读引用。例如:
注意:如果某些示例中权限的变化不重要,我们会将其隐藏。你可以点击“»”查看隐藏的步骤,也可以点击“● ● ●”在某个步骤中查看被隐藏的权限细节。(这是图片点不了的,需要去原书去点)
在这个程序中,对 *num
进行借用(&*num
)会移除 *num
的写(W)权限,但不会移除读(R)权限,因此 println!(..)
可以读取 *num
和 *num2
的值。
# 权限会在引用生命周期结束时归还
前面提到,引用在“被使用”期间会改变权限。这里的“被使用”其实指的是引用的生命周期,也就是从引用创建那一行(出生),到最后一次被使用(死亡)之间的代码范围。
比如,在下面这个程序中,y
的生命周期从 let y = &x
开始,到 let z = *y
这一行结束:
正如前面所见,在 y
的生命周期结束后,x
的 W(写)权限会被归还给 x
。
在前面的例子中,生命周期通常是连续的一段代码区域。但一旦我们引入了控制流(如循环、条件判断),生命周期就不一定是连续的了。例如,下面这个函数会把 ASCII 字符向量的第一个字符转换为大写:
变量 c
在 if 语句的每个分支中有不同的生命周期。在 then 分支中,c
会被用于表达式 c.to_ascii_uppercase()
,所以 *v
要等到这一行之后才会重新获得 W(写)权限。
而在 else 分支中,c
并没有被使用,所以一进入 else 分支,*v
就立刻重新获得了写权限。
# 数据的生命周期必须长于所有对它的引用
作为指针安全原则的一部分,借用检查器强制要求:数据的生命周期必须覆盖所有引用它的引用的生命周期。Rust 通过两种方式来保证这一点。第一种方式,是针对在同一个函数作用域内创建和释放引用的情况。例如,如果我们在持有某个字符串引用的同时尝试释放这个字符串:
为了捕捉这类错误,Rust 使用了前面提到的权限系统。&s
这个借用操作会移除 s
的 O(拥有)权限,而 drop
操作需要 O 权限,这就产生了权限冲突。
关键在于,在这个例子里,Rust 能准确知道 s_ref
的生命周期有多长。但当引用的生命周期不确定时(比如引用作为函数的输入或输出时),Rust 需要另一套约束机制。例如,下面是一个安全的函数,它返回一个指向向量第一个元素的引用:
这段代码引入了一种新的权限类型:流动权限(F,flow)。每当表达式用到一个输入引用(比如 &strings[0]
),或者返回一个输出引用(比如 return s_ref
),Rust 就会要求有 F 权限。
和 R(读)、W(写)、O(拥有)这些权限不同,F 权限在函数体内是不会变化的。只要一个引用被允许在某个表达式中使用(即允许“流动”到那里),它就有 F 权限。例如,我们把 first
改成一个带有默认参数的新函数 first_or
,来看 F 权限的具体表现:
这个函数现在无法通过编译,因为表达式 &strings[0]
和 default
都没有被允许获得作为返回值所需的 F(flow)权限。为什么会这样?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`
2
3
4
5
6
7
报错信息中的“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);
}
2
3
4
5
6
7
如果 first_or
允许 default
流向返回值,这个程序就是不安全的。就像前面的例子一样,drop
可能会让 s
变成悬空引用。只有在 Rust 能够确信 default
不会被返回时,才会允许这个程序编译通过。
要声明 default
能否作为返回值,Rust 提供了生命周期参数(lifetime parameters)机制。我们将在第 10.3 章“用生命周期验证引用”中详细讲解。现在你只需要知道:(1)输入/输出引用与函数体内部的引用处理方式不同;(2)Rust 用另一套机制——F 权限——来检查这些引用的安全性。
你还可以在其他场景看到 F 权限。比如,如果你尝试返回对栈上变量的引用,像这样:
这个程序是不安全的,因为 &s
这个引用在 return_a_string
返回时就会失效。Rust 会像之前一样拒绝编译这段代码,并报出类似“缺少生命周期标注”的错误。现在你可以理解,这个错误其实就是在说:s_ref
缺少了相应的流动(F)权限。
# 总结
引用让我们能够在不获取所有权的情况下读取和修改数据。引用通过借用操作(&
和 &mut
)创建,并通过解引用(*
)使用,很多时候这些都是隐式完成的。
但引用也很容易被误用。Rust 的借用检查器(borrow checker)强制执行一套权限系统,确保引用的安全使用:
- 所有变量都可以读取、拥有,并且(可选)写入自己的数据。
- 创建引用会将被借用位置的权限转移给引用本身。
- 当引用的生命周期结束时,权限会被归还。
- 数据的生命周期必须覆盖所有引用它的引用的生命周期。
在本节中,你可能感觉我们描述的更多是 Rust “不能做什么”,而不是“能做什么”。这其实是有意为之!Rust 的核心特性之一就是:让你能不用垃圾回收安全地使用指针,并且避免未定义行为。现在理解这些安全规则,会让你以后和编译器打交道时少走弯路。