Xz's blog Xz's blog
首页
时间序列
多模态
合成生物学
其他方向
生活
工具相关
PyTorch
导航站

Xu Zhen

首页
时间序列
多模态
合成生物学
其他方向
生活
工具相关
PyTorch
导航站
  • Rust

    • Cargo
    • 变量
    • 函数和控制流
    • 所有权
    • 引用与借用
      • 引用与借用
        • 引用是“非拥有型”指针
        • 解引用操作符:访问指针所指的数据
        • 引用会改变变量对数据的“权限”
        • 借用检查器(Borrow Checker)如何发现权限违规
        • 可变引用:为数据提供唯一且非拥有型的访问权限
        • 权限会在引用生命周期结束时归还
        • 数据的生命周期必须长于所有对它的引用
        • 总结
    • Slice切片
  • Docker

  • VMware虚拟机

  • Python常用代码片段

  • 工具相关
  • Rust
xuzhen
2025-07-26
目录

引用与借用

# 引用与借用

所有权、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...)
1
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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14

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

  1. i32::abs 函数期望输入类型为 i32。如果你用 Box<i32> 调用 abs,可以像 i32::abs(*x) 这样显式地对 box 解引用。你也可以用方法调用语法 x.abs() 隐式地解引用。点号语法(.)实际上就是函数调用语法的语法糖。

  2. 这种隐式解引用对于多层指针同样适用。例如,对一个指向 box 的引用 r: &Box<i32> 调用 abs,Rust 会自动插入两次解引用。

  3. 这种转换也支持相反的方向。例如,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
1
2
3
4
5
6
7
8
9

错误信息解释说,在引用 num 还在被使用时,v 不能被修改。这只是表面原因——更深层的原因在于,push 操作可能让 num 变成无效引用。Rust 能检测到这种潜在的内存安全隐患。

# 可变引用:为数据提供唯一且非拥有型的访问权限

到目前为止,我们看到的引用都是只读的不可变引用(也叫共享引用)。不可变引用允许别名,但不允许修改数据。然而,在某些场景下,我们也需要临时获得对数据的可变访问权,而又不想移动所有权。

为此,Rust 提供了“可变引用”(也叫唯一引用)。下面是一个带有权限变化说明的可变引用简单示例:

可变引用用 &mut 操作符创建,num 的类型写作 &mut i32。与不可变引用相比,你可以看到权限上有两个重要区别:

  1. 当 num 是不可变引用时,v 依然保有 R(读)权限;但现在 num 是可变引用时,只要 num 在被使用,v 失去了所有权限。
  2. 当 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`
1
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);
}
1
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 的核心特性之一就是:让你能不用垃圾回收安全地使用指针,并且避免未定义行为。现在理解这些安全规则,会让你以后和编译器打交道时少走弯路。

#Rust
上次更新: 2025/07/28, 08:42:07

← 所有权 Slice切片→

最近更新
01
Slice切片
07-26
02
所有权
07-26
03
函数和控制流
07-24
更多文章>
Theme by Vdoing | Copyright © 2025-2025 Xu Zhen | 鲁ICP备2025169719号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式