Caiwen的博客

Rust与内存安全

2025-12-11 13:14
培训文件
说明

本文为湖南大学易千工作室技术部门的第三次培训文档

1. 内存

1.1 什么是内存

计算机分内存和储存,我们一般认识到的存储容量是硬盘的容量,存放着我们的各种数据:

关于内存,我们可以在任务管理器中查看到关于他的容量以及占用信息等:

相比于储存

  • 内存比较小(8G、16G,32G...)。
  • 当电脑关机后,内存中的数据会立刻消失。
  • 但是内存的读写速度非常快,比硬盘读写快很多(似乎能达到70GB/s)。因此我们的操作系统会把比较常用的数据存放在内存中。

(例:如果你的手机或是电脑内存比较小的话,就容易发生卡顿,这是因为内存不够用了,操作系统就会迫不得已把数据存放在储存中,而储存的读写速度相比于内存慢的很,于是就卡了)

我们在操作系统上运行的所有程序,大部分数据,都是放在内存的:

image-20251103110019378

1.2 使用内存

内存可以视为一个非常大的数组。理论上我们可以在这个非常大的数组中随便存取数据。

对于普通数组,我们使用下标来访问

cpp
1
int data = arr[index];

对于内存,我们也需要一个“下标”来访问。对于内存,“下标”实际上就是地址。

cpp
1
int data = *addr;

* 叫做解引用,可以把一个内存地址的数据取出来,类似数组的 []

C++ 是强类型语言,我们需要注意一下类型。上面的数组的例子,arr 的类型应该是 int[]

而对于内存似乎没这么自然。

众所周知,计算机中的数据都是 0 和 1 二进制表示的。0/1 叫做位。而计算机实际上并不会一位一位地运算,计算机实际上是把 8 个位看成一个整体进行操作。8 位称为一个字节。

Unknown
1
2
3
0010100011000011 ——————-- -> 一个字节 ^ -> 一个位

在 C++ 中,字节对应于 char 类型。准确来说是 unsigned char 类型。

那么我们可以把内存视为一个非常大的 unsigned char 数组。

* 只能对“指针”这个数据类型进行操作。很多人闻指针色变,但我认为指针实际上就是一个地址,只不过又加了一个类型。

Unknown
1
2
3
4
5
12 13 14 15 16 17 18 | | | | | | | | ^--- cond1 ^------ cond2 ^------------ cond3

如上图,现在我们想获得内存中 12 这个位置的数据。按照“内存是一个非常大的 unsigned char 数组“的观点,12 处数据应该是一个 unsigned char 数据。

cpp
1
2
unsigned char *addr = (unsigned char *)12; unsigned char data = *addr;

这里的 * 出现位置不同表示不同的含义。如果出现在类型上,则表示是一个指针类型。如果用作运算符,则表示解引用操作。

这里还要再提一点,数据类型和数据本身没啥关系,数据类型只是告诉编译器应该把这个数据解释成什么东西。因此我们还可以这样:

cpp
1
2
bool *addr = (bool*)12; bool data = *addr;

上面例子中的 unsigned charbool 类型都是只占用 1 个字节的。如 cond1 所示。

如果我们写成:

cpp
1
2
short *addr = (short*)12; short data = *addr;

由于 short 是占用 2 字节的,上面这个代码就会从指针处的位置开始,向后读取两个数据,然后根据某些编码规则将其解释成 short 类型。类似 cond2 那样。

又比如我们最喜欢的 int 类型:

cpp
1
2
int *addr = (int*)12; int data = *addr;

这个 * 解引用实际上直接读了 4 个数据,类似 cond3 那样。

上面提到,我们定义的变量也是放在内存中的。那么就有个疑问,它放在了内存的那个位置?我们可以用 & 取地址运算符来得到答案,& 运算符将会得到一个指针。

cpp
1
2
3
int a; int *addr = &a; std::cout << addr << endl;

综合我们所讲的东西,我们还可以这么玩:

cpp
1
2
3
int a = 114514; double *p = (double *)&a; std::cout << *p << endl;

1.3 内存分布

如果你直接去运行上面所讲的代码,如:

cpp
1
2
int *addr = (int*)12; int data = *addr;

会发现程序出现段错误,其中的原因我们将会逐步解释。

还记得吗,我们的电脑上,各个程序之间是共享内存的:

image-20251103110019378

现在我们已经知道了怎么去利用内存,那么有一个想法就是,能不能访问别的程序的内存?理论上是可以的,但这样不太安全,因为这样一个恶意的程序可以随便破坏内存数据,使得电脑崩溃。于是,现代操作系统有一个“虚拟内存”技术,即在每个程序的眼中,他是独占所有的内存的,看不到其他程序:

同时,理论上来说,你作为程序的编写者,可以随意支配这“虚拟空间”内的所有内存。但是为了能让程序正常运行,我们不得不遵循一些约定:内存中不同的位置的内存是有他的作用的。

  • 整个内存空间的最上面,是分给操作系统用的,你不能乱用
  • 在比较靠上面的区域,叫做栈,我们后面会细说。如果这块区域的内存不够用了就向下扩容
  • 再靠下一点的区域,叫做堆,我们后面会细说。如果这块区域的内存不够用了就向上扩容
  • 再往下的区域,我们称之为段。有很多段,每个段都有自己的名称和作用,比较值得注意的是这些段:
    • .text 段:你的程序的代码(准确来说是已经编译好的机器码,CPU能够直接执行的)放在这里。当程序加载完毕之后CPU将会执行这里的代码来执行你的程序。
    • .data 段:如果你定义了有初始值的全局变量,那么这个初始值的数据就放在这里
    • .rodata 段:一些常量会放在这里,比如最常见的 cout<<"hello,world";,这个 "hello,world" 字符串就放在这里
    • .bss 段:如果你定义了没有初始值的全局变量,那么这个全局变量会存在于内存的这个位置。.bss 段会被操作系统清 0,这也就是为什么 C++ 中的全局变量默认值为 0

其中 .text/.data/.rodata 这些段都是在编译时被写进可执行文件(如 .exe 文件中的)

除了上述这些内存区域之外,其他的内存区域你是不能够访问的。然后再来看我们刚才的代码,12 这个位置在上图中没有对应任何区域,因此你直接访问就会报错。

那么其实我们在电脑上点击一个 exe 文件时启动一个程序时,操作系统无非干了这么几件事:

  • 给我们的程序分配了一个“虚拟”的内存空间
  • .exe 文件中的 .text/.data/.rodata 部分的数据解析出来,存到内存中
  • 又开辟了 .bss 段,堆、栈这些内存区域
  • 执行 .text 段的代码

然后我们来到代码层面。一般来说,函数中的局部变量是开在栈上的:

cpp
1
int a;

定义变量,于是栈指针 %rsp 就向下移动,移动后会在栈区域腾出一块空间,这个空间就存放着我们定义的变量。

栈有个性质,你只能在栈的顶部(也就是 %rsp 所指的位置)添加数据或者是删除数据。添加和删除的时候只需要移动 %rsp 指针即可做到。

栈的这个性质也可以很方便地处理函数调用:

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void func1(); void func2(int); int main(){ int a, b; cin >> a >> b; // do something... func1(); return 0; } void func1(){ int c = 1, d; func2(c); } void func2(int x){ if(x == 2) return; char *str = "hello"; cout << str << endl; func2(x + 1); }

我们看一下上述代码的执行过程中栈的变化:

Unknown
1
2
3
|------------| | ..... | |------------| <- %rsp

首先,main 函数刚执行的时候,栈中可能有一些其他的数据。

Unknown
1
2
3
4
5
6
7
|------------| | ..... | |------------| | a | |------------| | b | |------------| <- %rsp

定义了变量。

Unknown
1
2
3
4
5
6
7
8
9
10
11
12
13
|------------| | ..... | |------------| | a | |------------| | b | |------------| |return addr | |------------| | c | |------------| | d | |------------| <- %rsp

main 函数调用 func1 前,会先在栈中放置一个返回的地址。

Unknown
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|------------| | ..... | |------------| | a | |------------| | b | |------------| |return addr | |------------| | c | |------------| | d | |------------| |return addr | |------------| | x | |------------| <- %rsp

调用 func2 时,func1 除了也在栈中放置一个返回地址外,还把当前的 c 复制了一份再放入栈中,以此来达到传递参数的目的。

Unknown
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|------------| | ..... | |------------| | a | |------------| | b | |------------| |return addr | |------------| | c | |------------| | d | |------------| |return addr | |------------| | x | |------------| | str | |------------| <- %rsp

然后声明了变量 str

cout 实际上发生了函数的调用,对栈也会产生影响,简单起见我们忽略掉中间这些。

Unknown
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|------------| | ..... | |------------| | a | |------------| | b | |------------| |return addr | |------------| | c | |------------| | d | |------------| |return addr | |------------| | x | |------------| | str | |------------| |return addr | |------------| | x | |------------| <- %rsp

然后进行一次递归,后面 func2 开始返回了,函数返回之后,函数内部所定义的变量都不会在使用到了,所以可以把他们从内存中清除掉。

Unknown
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|------------| | ..... | |------------| | a | |------------| | b | |------------| |return addr | |------------| | c | |------------| | d | |------------| |return addr | |------------| | x | |------------| | str | |------------| |return addr | |------------| <- %rsp | x | |------------|

清理时,我们只需要改变一下 %rsp 指针即可,原来的变量可能还留在内存中,但其实已经被清理掉了。

我们说局部变量的默认值不一定会是 0,就是这个道理,因为新变量的内存位置在原来可能存在了数据。

函数返回时,他要知道回到哪里去,所以此时从栈顶就能得到返回地址了。

Unknown
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|------------| | ..... | |------------| | a | |------------| | b | |------------| |return addr | |------------| | c | |------------| | d | |------------| |return addr | |------------| | x | |------------| | str | |------------| <- %rsp |return addr | |------------| | x | |------------|

只使用栈会有一些局限性。首先,栈一般比较小,如果你有比较大的数据,就不适合放入栈中。同时,我们看到,栈中的数据的“生命周期”和当前函数相当,如果当前函数结束的话,那么其在栈中的数据也会被清理,有时候我们可能希望数据在函数结束时仍然保留。还有种情况,如果我想扩容/缩容某个数据所占内存空间,那么栈将会比较难以做到。

于是我们就有堆这个东西。堆的使用就比较自由了,你的程序可以自由使用堆的内存。而且堆往往很大,而且如果堆不够用了我们还可以向操作系统申请扩容。

那么我们就来想一个问题,如果我们想在堆中放一个数据,我们该怎么做?首先数据放在哪里?我们可以简单地像栈一样维护一个指针,表示指针之后的数据都可以进行分配。然后再考虑,如果我要把堆中的某个数据释放掉怎么办?有可能我先在堆中放置了若干个数据,然后把其中的一些数据释放掉,此时就不能简单地像栈那样维护一个指针了,而是可能要记录堆中哪些位置是空闲的。同时随着堆内空间不断地被分配又释放,堆中可能出现很多零散的空间,变得碎片化,此时一个高效的分配策略就显得很重要。总之,使用堆要考虑的问题非常多...

关于堆的使用,有很多内存分配算法来解决。C/C++ 的标准库中就实现了一个内存分配算法。我们直观地来看就是为我们提供了 mallocfree 等函数

malloc 函数将自动帮我们在堆中分出一块指定大小的内存:

cpp
1
int *arr = (int*)malloc(sizeof(int)*100);

malloc 返回一个 void* 类型,需要我们再手动,转换成自己需要的指针类型。上述这个代码就相当于申请了一块内存,这块内存可以存 100 个 int。这块内存是被放在堆上的。

arr 作为一个指针位于栈中。当 arr 所处的函数结束之后,arr 将会被清理,但是只是指针本身被清理,指针指向的内存仍留在堆中。

堆中的内存的生命周期基本上和任何东西都没有什么关联。堆中的内存,除非你手动使用 free 函数释放,否则他就会一直留在堆中,直到程序结束,由操作系统释放掉程序所占有的内存。

cpp
1
free(arr);

1.4 内存安全

在堆上开内存好是好,但是如果你不小心忘记了把开在堆上的内存释放了,那么你的程序可能会一直占用着内存,使得内存只增不减,直到消耗完电脑的全部内存,程序崩溃。我们管这种情形叫做“内存泄漏”。

对于 C 和 C++ 这种语言,需要你手动管理内存,申请完的内存在不需要使用了时候要释放掉。但是有时候你可能难以做到这一点:

image-20251120120648728

image-20251120120706113

同时,如果你在释放掉内存之后,还在使用那块内存的话,就会出现"悬垂引用"。同时,栈上的内存也会出现悬垂引用。

image-20251120120948574

内存安全主要考虑这两件事:该释放的内存要释放(否则就是内存泄漏了),不该释放的内存不要释放(否则就是悬垂引用了)。

2. Rust 内存安全

关于内存安全,主要有两大阵营:

  • 第一个阵营是以 C 和 C++ 为代表的,他们希望给程序员最大限度的自由,让程序员手动管理内存。
  • 第二个阵营是以 Java、Python 为代表的,同时也是绝大部分编程语言的选择,他们设计了一套垃圾回收(GC)的算法,程序在运行过程中自动回收没有被使用的内存。有 GC 的编程语言的效率往往低一些。

Rust 则站在了第三个阵营,他精心设计了一套规则,用编译器来强制执行这套规则,使得程序员难以写出内存不安全的代码。

2.1 Vec 和 String

为了方便我们后面举例,这里先介绍两个东西:VecString

rust
1
let padovan = vec![1,1,1,2,2,3,4,5,7,9];

image-20251120134144667

Vec 既使用了栈又使用了堆。他把具体的数据都放在堆上,而栈上则只存放三个东西:指向Vec已经在堆上申请的内存的一块连续内存的指针(buffer),元素的数量(length),以及目前已申请的内存块最多能存储的数量(capacity)。

当我们往 Vec 里面添加元素时,如果 Veclength 小于 capacity 的话,那么可以直接在已经申请的内存块中添加。但如果内存块中的空间不够再继续加元素了,那么此时就需要扩容。Vec 将会在堆中申请一块更大的内存,然后再把原来的数据复制过去,再把原来的数据释放掉。

扩容也是有讲究的,每次扩容不能太大(不然会有很多浪费),也不能太小(不然会频繁进行内存复制,使得程序效率降低)。一般来说,每次扩容会扩容到原来容量的多少倍。

对于 String,我们可以将其视为元素为 u8 类型的 Vec,于是 String 的结构就不再过多赘述。(这里 u8 表示一个字节。不是 char 是因为 Rust 中的 char 和 C++ 不同,是固定 4 字节的(按 Unicode 编码))。

然后我们发现,StringVec 的主要数据都放在堆上,然后 StringVec 本身是指向堆上的指针再加上一些额外的数据。因此,某种意义上 StringVec 可以说是“胖指针”。

2.2 所有权

当我们使用 let 定义变量的时候,我们应该视为创建了一个数据,然后把这个数据的“所有权”进行了绑定。你需要把变量和数据分开来看。

rust
1
let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];

image-20251124170854973

Rust 中,= 不仅是赋值,还意味着所有权的转移:

rust
1
let t = s;

image-20251124171408457

上述代码会把 s 的数据的所有权交给 t,而 s 变成未初始化状态(没有拥有任何数据)

此时我们再写:

rust
1
let u = s;

image-20251124171908074

2.2.1 Drop

一个变量拥有某个数据的所有权,这意味着这个数据的内存由这个变量管理。如果一个变量是某个数据的所有者,当这个变量掉出作用域时,这个变量肯定是不会再使用到了,其拥有的数据就应该被释放。(类似于我们之前讲栈时说的那样,但是这里扩充了一下,不仅仅是函数结束后函数内的变量释放,而是变量所处作用域结束,变量就被释放)

rust
1
2
3
4
5
{ let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()]; // do something } // 这里 s 就被释放掉了 println!("{}", s); // s 已经被释放,这里不能再使用

image-20251124172429415

当然这些并不是理所当然的。Rust 在释放变量所拥有的数据的时候,会先判断其数据类型是否实现了 Drop trait,如果没有则仅释放其在栈上的数据,反之则调用其 drop 函数,然后再释放其在栈上的数据。同时这个过程是递归的,也就是如果我准备释放一个结构体,那么我会先释放结构体的成员。如果这个成员也是结构体的话,这个过程就会递归下去。

String 举例:

rust
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
// https://doc.rust-lang.org/src/alloc/string.rs.html pub struct String { vec: Vec<u8>, } // https://doc.rust-lang.org/src/alloc/vec/mod.rs.html pub struct Vec<T, #[unstable(feature = "allocator_api", issue = "32838")] A: Allocator = Global> { buf: RawVec<T, A>, len: usize, } // https://doc.rust-lang.org/nomicon/vec/vec-raw.html struct RawVec<T> { ptr: NonNull<T>, cap: usize, } impl<T> Drop for RawVec<T> { fn drop(&mut self) { if self.cap != 0 { let layout = Layout::array::<T>(self.cap).unwrap(); unsafe { alloc::dealloc(self.ptr.as_ptr() as *mut u8, layout); } } } }

我们可以在 drop 函数中来释放掉当前类型在堆上关联的数据。于是堆上内存生命周期和栈上内存相绑定,我们之前所说的堆上内存释放时机不好把控的问题就解决了。这样的数据类型本质上也是指针,由于其能够在合适的时机自动释放栈上内存,因此也叫做智能指针。

drop 函数非常类似 C++ 中的析构函数。

同时这里再强调一下,我们说某个变量“拥有”某个数据,这个数据指的是栈上数据,栈上的数据有且仅有一个所有者。堆上数据并没有所有者,多数情况下我们是通过 drop 函数和栈上的指针,将堆上数据和栈上数据关联起来的。

总结:所有权 -> Drop -> 堆上内存生命周期和栈上数据相绑定 -> 内存安全

2.2.2 Clone

如果我们在 C++ 中写类似上述的代码,s 赋值给 u 后也能赋值给 t。这是因为 C++ 中,赋值时的行为是由一个叫做赋值拷贝构造函数决定的。C++ 中很多类型默认的行为是将其在栈上的数据都完全赋值一份:

qq_pic_merged_1765167653151

所以在 C++ 中我们可能不知不觉进行了很多内存的复制,但是复制内存是有代价的,Rust 希望你自己决定要不要来复制。

Rust 的设计是,赋值相当于数据的转移而非像 C++ 这样隐含着数据的复制(并且由于赋值拷贝构造函数的存在,你也不好直接判断使用 = 赋值之后发生了什么,而 Rust 则更简单直接,就是数据的转移)。如果你确实需要复制数据,那么你需要手动写出代码来进行这个操作。这也使得代码的行为更加确定。

有些数据类型,如 StringVec,这样的数据类型实现了 Clone trait,表示可以复制。实现了 Clone trait 的类型就可以使用 .clone 这个函数:

image-20251208133201323

这个函数可以允许在你只获得某个变量的借用的情况下,将该变量的数据复制一份,然后返回这个数据的所有权。例如,在Vec上使用.clone()会将堆上的的每个元素都.clone(),再以此构建一个新的Vec

Rust 默认的 Clone 只是赋值了栈上数据,和堆上数据没有什么关系。StringVec 这种往往在实现 Clone trait 时,自定义了 clone 函数,在这个函数内进行了堆上数据的复制。类似 Drop。

又比如,如果我们也想让上面的赋值行为和 C++ 一样的话,我们可以这么写:

rust
1
2
let t = s.clone(); let u = s.clone();

并非所有的数据类型都是可以复制的,这样的数据类型往往有着特殊含义。比如 std::sync::MutexGuard,当调用 Mutex::lock() 时就会获得他。他是当前线程持有某个数据的凭证,只有获得该凭证的线程才能访问对应的数据。Mutex 要求每时每刻最多只能存在一个 MutexGuardMutexGuard 没有实现 Clone trait。

2.2.3 Copy

有些数据类型,比如 i32 这种基本数据类型,他的复制是没成本的,而且也没什么特殊含义,可以随便复制。这种数据类型往往实现了 Copy trait,带有这种 trait 就意味着编译器可以在赋值时自动将其在栈上的数据原封不动地复制过去,而不再搞什么所有权的转移。

rust
1
2
3
let a = 114514; let b = a; let c = a;

总结:

  • Rust 的赋值含义很简单,就表示栈上数据的移动(对于没有实现 Copy trait 的数据类型)(没有堆上数据的移动)。
  • 而 C++ 的赋值行为则很不确定
    • 默认情况下是复制栈上内存,这相当于给所有数据类型都实现了 Copy trait
    • 赋值的行为还可以通过赋值拷贝构造函数自定义,这相当于赋值时自动调用 .clone

同时,所有权和 Drop trait 的配合使得内存被正确地,自动地释放。当然他不只是针对于内存,我们可以在 drop 函数中关联到任何的资源(内存也是一种资源),比如文件描述符,当你在向操作系统申请一个文件描述符时,表示你正在访问一个文件,文件描述符是有限的,我们需要在合适的时机释放掉,此时我们遇到了和管理内存同样的问题,我们像管理堆上内存那样在 drop 函数中释放文件描述符写,使得其被正确地,自动地释放。

2.3 引用/借用

rust
1
2
3
4
5
6
7
8
fn f(a: String, b: String) { // do something }// a 和 b 在这里被释放 let x = ... let y = ... f(x, y) // x 和 y 就不能够使用了

上面这个函数的参数直接拿走了数据的所有权,这导致调用完函数之后 xy 就不能继续使用了,但可能这个函数就只是想“借用”一下某个数据,那么我们可以这样写:

rust
1
2
3
4
5
6
7
8
9
10
fn f(a: &String, b: &String) { // do something } fn main() { let x = String::from("xxx"); let y = String::from("yyy"); f(&x, &y); // x 和 y 仍然可以在这里使用 }

& 在这里有两个含义,它可以用作类型,表示数据的引用,也可以用作运算符,表示引用数据。

从 C++ 的角度看,Rust 的引用就相当于是指针。& 用作运算符就相当于 C++ 中的取地址运算符,用作类型里面就相当于 C++ 中的指针。

& 产生的引用也叫做不可变引用:

rust
1
2
3
let s = String::from("hello world"); let r: &String = &s; (*r) = String::from("hello rust");

image-20251208141949292

Rust 还有一种可变引用:&mut。我们可以根据上面的报错提示将代码改写成下面这种:

rust
1
2
3
let s = String::from("hello world"); let r = &mut s; (*r) = String::from("hello rust");

但还是报错:

image-20251208142144426

这是因为 Rust 中,变量默认是不可变的。如果我们想要可变地借用,那么必须变量本身就是可变的:

rust
1
2
3
let mut s = String::from("hello world"); let r = &mut s; (*r) = String::from("hello rust");

2.3.1 解引用

C++ 中可以解引用,在 Rust 中也可以,我们上面例子就已经看到。需要注意的是解引用会获得其所有权,如果你再把所有权转移出去,就会报错:

rust
1
2
3
let x = String::from("xxx"); let r = &x; let y = *r;

我们明明是借用 x 的,后面又通过这个借用尝试获得其所有权,这显然是不行的:

image-20251208132748149

他建议你使用 .clone。还记得吗,.clone 函数的只需要变量的引用即可进行复制:

image-20251208133201323

又或者是实现了 Copy trait,这意味着数据可以随便复制,于是解引用就相当于给你复制一份出来。

如果我解引用不是为了转移所有权,那还是允许的:

rust
1
2
3
4
5
6
7
8
struct Point { x: i32, y: i32, s: String, } let p = Point { x: 10, y: 20, s: String::from("hello") }; let r = &p; let s = &(*r).s;

2.3.2 自动引用/解引用

考虑到在 Rust 中,引用比较常见,所以 . 这个东西还有些额外作用。首先他可以自动解引用:

rust
1
2
3
4
5
6
7
8
9
10
struct Point { x: i32, y: i32, s: String, } let p = Point { x: 10, y: 20, s: String::from("hello") }; let r = &p; let x = r.x; // ---- 等价于 ---- let x = (*r).x;

Rust 中引用也是一个“实体”,比如上面,r 相当于拥有了某个对 p 的不可变引用的所有权。我们可以对引用进行引用:

rust
1
2
3
4
5
6
let rr = &r; let rrr = &rr; let rrrr = &rrr; let x = rrrr.x; // ---- 等价于 ---- let x = (*(*(*rrrr))).x;

. 调用函数时,如果需要引用,那么会自动引用:

rust
1
2
3
4
let s = String::from("a b c"); let arr = s.split(" "); // ---- 等价于 ---- let arr = (&s).split(" ");

image-20251208141309177

又比如:

rust
1
2
3
4
let mut v = vec![1, 2, 3, 4, 5]; v.sort(); // ---- 等价于 ---- (&mut v).sort()

image-20251208143332796

2.3.3 引用结构体

如果要借用某个变量,那么这个变量必须是完整的。

rust
1
2
3
4
5
6
7
8
9
10
11
struct S { x: String, y: String, } let s = S { x: String::from("long string is long"), y: String::from("xyz"), }; let x = s.x; // 此时 s 不再拥有 x 的所有权,s 不再完整 let r = &s; let y = s.y; // 但我可以继续从残缺的 s 中再把 y 取出来

image-20251208171218039

Rust 的这种设计带来了一个好处,我们可以很清楚的从函数的签名中大概知道函数想要做什么。

  • 如果函数要求传递所有权,那么这个函数就表示我想“消耗”掉这个数据,数据被消耗掉之后就不应该被继续使用了。
  • 如果函数要求传递不可变引用,那么这个函数就表示我就是想“借用”一下这个数据,而且不会对这个数据进行任何修改。
  • 如果函数要求传递可变引用,那么这个函数不仅想“借用”一下这个数据,还会对这个数据进行修改。

2.4 内存安全

2.4.1 内存泄漏

妥。

2.4.2 Sharing Versus Mutation

当共享和可变之间结合时可能会出问题,因此有一些规则限制引用的使用:

引用期间不能够移动

rust
1
2
3
4
let v = vec![1, 2, 3, 4]; let r = &v; let aside = v; r[0];

如果我们借用了某个变量,那么在整个借用期间,这个变量指向的数据不能被移动到别的地方去,不然我们的引用就变成悬垂引用了。

image-20251208172152505

这样就可以了,确保移动时不存在其他的引用。注意,尽管我们之前可能一直在说函数结束之后某个变量才会被释放,但是 Rust 的编译器会足够聪明,如果从某个地方开始你就不再使用一个变量了,那么 Rust 会认为这个变量的生命周期就持续到那里。

rust
1
2
3
4
5
6
{ let v = vec![1, 2, 3, 4]; let r = &v; r[0]; let aside = v; }

而在 C++ 中,我们很可能将某个指针指向的数据释放掉了,但忘了这个事,继续用这个指针。而 Rust 中你想释放内存肯定要有所有权,而引用期间所有权不会发生转移,所以不会出现这种情况。

又比如:

rust
1
2
3
4
let mut x = 10; let r1 = &mut x; x += 10; // 处在引用期间,不能够被移动 println!("{}", r1);

不可变引用期间不能够有可变引用

从直觉上来说,如果不可变引用期间,我们把数据改变了,但是后面又忘了这个事,可能就容易出 bug(类似于变量默认不可变的设计思想),但有时可能没这么简单:

rust
1
2
3
4
let mut v = vec![1, 2, 3, 4]; let r = &v[0]; v.push(5); println!("r: {}", r);

image-20251208174732360

.push 虽然可能看起来只会改变数组最后面的部分,并不会影响到我对 v[0] 的引用。但是你注意,.push 接受可变引用就意味着他可能改变整个 Vector 的任何部分。一种情况是,如果我们 push 时发现 vector 容量不够了,需要扩容,那么我们将会重新再堆上分配一个空间,把数据复制过去,然后再释放掉原来的数据,此时之前对 v[0] 的引用就失效了。

C++ 的 vector 也是类似的。而 C++ 并不会去检查这些,一旦出现这种问题将会非常棘手。

可变引用期间不能够有其他引用

其中的道理和上面是差不多的。

rust
1
2
3
4
let mut x = 10; let r1 = &mut x; let r2 = &x; (*r1) = 12;

image-20251208175551838

更多

Rust 中,每个数据有且仅有一个拥有其所有权的变量,因此这可以形成一个树形结构:

image-20251208180312171

当我们不可变地借用某个变量时,其所拥有的变量(即图中子树部分)是只读的(毕竟你只是借用,甚至还是不可变的借用)。其父节点一直到根节点这条链上的点也都是只读的(否则的话就可以在祖先节点上将数据修改掉,制造出悬垂引用)。

当我们可变地借用某个变量时,其所拥有的变量是可读可写的,也就是整个子树部分都相当于是进行了一个可变引用。如果直接引用其子节点,Rust 会认为你在可变引用的基础上又进行了一个不可变引用。但你可以通过这个可变引用来引用:

rust
1
2
3
4
5
6
7
8
9
#[derive(Debug)] struct S { x: i32, y: i32 } let mut s = S { x: 10, y: 20 }; let t_mut = &mut s; let r1 = &s.x; println!("{:#?}", t_mut);

image-20251208181336379

rust
1
2
3
4
5
6
7
8
9
#[derive(Debug)] struct S { x: i32, y: i32 } let mut s = S { x: 10, y: 20 }; let t_mut = &mut s; let r1 = &t_mut.x; println!("{:#?}", t_mut);

同时,产生可变引用的祖先节点不仅是不能够写,甚至连引用都不行了,因为有可能顺着祖先节点的引用来引用到可变引用部分,此时就违背了可变引用单独存在的规则。

2.4.3 引用的生命周期

生命周期就是变量/数据从产生到释放的这个范围。在之前我们看到过,变量的生命周期是从定义到离开变量的作用域的这段时间。通过所有权,我们将栈上数据的生命周期绑定到变量上。通过 Drop trait,我们将堆上数据的生命周期绑定到变量上。现在我们又遇到了引用,引用的生命周期是其保持有效的范围。如果我们引用了某个东西,那么我们需要保证在引用的这段时间,被引用的数据没有被释放:

rust
1
2
3
4
5
6
7
8
{ let r; { let x = 1; r = &x; } assert_eq!(*r, 1); }

image-20251208144538892

image-20251208144713692

蓝色表示 x 的生命周期,绿色表示 r 的生命周期,我们可以看到,在 x 生命周期已经结束,被释放后,r 的生命周期还没结束。Rust 会阻止这样的代码通过编译。

如果我们改成这样就可以了:

image-20251208145626337

而我们在 C++ 中很容易写出这样的代码:

image-20251208173518262

使用结构体也会有类似的问题:

rust
1
2
3
4
5
6
7
8
9
struct S { r: &i32 } let s; { let x = 10; s = S { r: &x }; } assert_eq!(*s.r, 10);

image-20251208153020329

Rust 还是发现了这个问题。但是即使我们改成下面这样,不再尝试引用已经被释放掉的变量了,Rust 还是会报错:

rust
1
2
3
4
5
6
7
8
9
struct S { r: &i32 } let x = 10; { let s; s = S { r: &x }; assert_eq!(*s.r, 10); }

手动标注生命周期

这是因为 Rust 要求结构体中的引用必须手动标注生命周期。

rust
1
2
3
struct S<'a> { // <> 里写结构体内所有手动标注的生命周期 r: &'a i32 }

其中 'a 中的 a 是可以自己命名的,我们一般用 abc... 这样的字母来手动标注。在 Rust 中,生命周期也是数据类型的一部分,起到约束的作用。只不过在大多数情况下,Rust 会自动推导生命周期,无需我们手动标注。在结构体中,结构体的生命周期显然取各个引用成员中生命周期最短的那个就好了,但是各个成员之间的生命周期 Rust 无法决定。上面只有一个成员还看不出什么问题来,但如果有多个,比如,对于有两个成员变量:

写法一

rust
1
2
3
4
struct S<'a> { x: &'a i32, y: &'a i32 }

写法二

rust
1
2
3
4
struct S<'a, 'b> { x: &'a i32, y: &'b i32 }

有两种写法,并且表示的含义完全不同,Rust 无法确定。这就是为什么你需要手动标注生命周期。

写法一表示,结构体内的 x 和结构体内的 y 和结构体本身,三者的生命周期一致。注意,这并不意味着创建结构体的时候必须是两个活得一样长的变量才能够赋值进去。Rust 会自动将 'a 推导成带有 'a 的成员中较短的生命周期。

rust
1
2
3
4
5
6
7
8
9
10
let x = 10; let r; { let y = 20; { let s = S { x: &x, y: &y }; r = s.x; } } println!("{}", r);

理论上来说,上面的代码是不会产生悬垂引用的,Rust 没理由报错,但是还是报错了:

image-20251208165848267

这是因为 Rust 会将 'a 推导为 y 的生命周期。于是结构体中的 s.x 的生命周期就和 y 相当了,尽管 x 的生命周期是比较长的。

而写法二则不同。写法二则表示 xy 的生命周期没有关联。这样的话上面的代码就正确了,s.x 就会和 x 的生命周期一致,s.y 就会和 y 的生命周期一致,而 s 的生命周期会取 'a'b 中较小的那个,也就是会和 y 生命周期一致(尽管实际上 s 会先比 y 释放掉)。这样即使结构体释放掉了,其中较长生命周期的那个引用还能够接着用。

又比如:

rust
1
2
3
4
5
struct S<'a, 'b> { x: &'a i32, y: &'b i32, z: &'b i32 }

这就表示 xyz 的生命周期没有关联,但是 yz 生命周期是一样的。也就是比如结构体被实例化为变量 sy 生命周期较大而 z 较小,当 s.z 生命结束,s.y 也跟着结束,尽管 y 还没结束生命。

总的来说,给结构体手动标注生命周期相当于对成员之间生命周期关系做出了约束。

当函数需要返回引用的时候也会遇到这种问题,比如:

rust
1
2
3
4
5
6
7
8
9
10
fn longest(x: &String, y: &String) -> &String { if x.len() > y.len() { x } else { y } } fn main() { longest(&String::from("hello"), &String::from("world!")); }

此时 Rust 不清楚返回值的生命周期是什么,有可能生命周期和 x 是一样的,有可能和 y 是一样的,有可能取 xy 中存活最短的那个,也有可能和 xy 没有关系(生命周期为 'static)。此时我们分析一下我们究竟要干什么,我们想要返回两个字符串之间最长的那个的引用,那么我们的返回值的生命周期应该和 xy 之间较长的那个是一致的。但是我们只有运行的时候才知道 xy 哪个存活时间更久,而 Rust 作为静态类型语言,需要在编译期间就知道所有类型的情况。我们可以保守一点,令返回值的生命周期为两者之中较小的那个:

rust
1
2
3
4
5
6
7
fn longest<'a>(x: &'a String, y: &'a String) -> &'a String { if x.len() > y.len() { x } else { y } }
rust
1
2
3
4
5
6
7
8
9
fn main() { let y = String::from("world!"); let r; { let x = String::from("hello"); r = longest(&x, &y); } println!("The longest string is {}", r); }

给函数标注生命周期相当于约束了返回值之间,返回值和参数之间生命周期的关系。

我们可以设想,在 C++ 中,可能也会有两个指针传入一个函数,然后经过比这个例子复杂多的逻辑处理后返回一个指针,此时我们可能就不好去分析这个指针和原来的参数之间有什么关系,此时就可能出现什么悬垂指针,double free 等各种问题。

2.5 杂项

2.5.1 String,&String,&str

String 在内存中的结构不再赘述。但我们需要注意到,String 在栈上就是一个指向其在堆上数据的指针再加了两个额外信息,其实 String 本质上就是一个指针,是一个“胖”指针。

而对于 &str 就纯粹了一些,&str 只在栈上有数据,其包含了一个指向字符串首字符的指针还有字符串的长度,因此 &str 也是个胖指针,但是 &str 并不会像 String 那样通过 Drop trait 将堆上数据和自己的生命周期关联起来。

&String 就更纯粹了,他就是一个完全的指针,本质就是一串数字,指向被引用的 String 的栈上数据。他不像 &str 那样还包含字符串长度这种额外数据。当然,在 Rust 中他的含义会更多一点,他还表示是一个引用。

之前我们提到,代码中的字符串字面量是放在 .rodata 段上的,所以 Rust 中的字符串字面量的类型应该是 &str.rodata 段的内存会一直到程序结束后才被操作系统释放,因此我们说字符串字面量有一种特殊的生命周期:'static ,他是最长的生命周期,表示从程序的开始到程序的结束。也就是完整的说,字符串字面量类型为 &'static str

Rust 中,String 有 .as_str() 可以将其转化为 &str 类型。对于 &str,我们也有 .to_string() 来将其复制从而得到 String 类型,这是我们用的比较多的。

考虑到 &String&str 的主要区别是

Unknown
1
2
&String ---指向---> String 的栈上数据 ---包含---> 指向堆上数据的指针 ---指向---> 堆上数据 &str -----------------------------指向--------------------------------------> 堆上数据

所以其实 &String&str 是简单的。Rust 实现了前者到后者的隐式数据类型转换(From trait),直观的说就是很多接受 &str 类型参数的地方可以传 &String 类型。

2.5.2 智能指针

我们前面说 StringVec 可以通过 Drop trait 自动管理其在堆上的数据,因此他两个比较智能,可以算是智能指针。但他俩是针对于字符串和数组这种场景的,如果我也想让我自己的类型的数据也能被这么智能地管理,就需要用真正意义上的智能指针。

Box

如果我创建了一个结构体,那么其大概是在栈上的:

rust
1
2
3
4
5
struct S { x: u32, y: u32 } let s = S { x: 1, y: 2 };

我可以使用 Box 这个东西将其搞到堆上面:

rust
1
2
3
4
let s = Box::new(S { x: 1, y: 2 }); // s 的类型是 Box<S> (*s).x s.x

涉及到堆的话我们就要讨论一下内存安全问题。Box 会将其包裹的数据放到堆上面,然后 Box 本身相当于一个指针,指向堆上的数据。

rust
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// https://doc.rust-lang.org/src/alloc/boxed.rs.html#231-234 pub struct Box< T: ?Sized, #[unstable(feature = "allocator_api", issue = "32838")] A: Allocator = Global, >(Unique<T>, A); impl<T> Box<T> { #[cfg(not(no_global_oom_handling))] #[inline(always)] #[stable(feature = "rust1", since = "1.0.0")] #[must_use] #[rustc_diagnostic_item = "box_new"] #[cfg_attr(miri, track_caller)] // even without panics, this helps for Miri backtraces pub fn new(x: T) -> Self { return box_new(x); } } // https://docs.rs/unique/latest/src/unique/lib.rs.html#47 /// Constructs a `Box<T>` by calling the `exchange_malloc` lang item and moving the argument into /// the newly allocated memory. This is an intrinsic to avoid unnecessary copies. /// /// This is the surface syntax for `box <expr>` expressions. #[rustc_intrinsic] #[unstable(feature = "liballoc_internals", issue = "none")] pub fn box_new<T>(x: T) -> Box<T>; #[stable(feature = "rust1", since = "1.0.0")] unsafe impl<#[may_dangle] T: ?Sized, A: Allocator> Drop for Box<T, A> { #[inline] fn drop(&mut self) { // the T in the Box is dropped by the compiler before the destructor is run let ptr = self.0; unsafe { let layout = Layout::for_value_raw(ptr.as_ptr()); if layout.size() != 0 { self.1.deallocate(From::from(ptr.cast()), layout); } } } }

上面这个例子一眼看去感觉没什么实际意义,无非就是换了个地方放数据。但有这样一种情形需要考虑:

比如我们想实现一个类似链表的东西:

rust
1
2
3
4
5
struct S { x: u32, y: u32, next: Option<S> }

image-20251208190224551

Rust 要求在编译期间知道每个类型的大小,但是上面这种“自我引用”的类型,会让 Rust 无法计算。我们需要一种固定大小的东西,还能通过这个东西间接访问到数据。那么这个东西就是指针了。然后考虑一下指针大概指向上或是堆上。

如果我们把数据放到栈上,那么这个指针就相当于是引用,我们可以这么写:

rust
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct S<'a> { x: u32, y: u32, next: Option<&'a S<'a>> } let node = S { x: 5, y: 6, next: None }; let root = S { x: 1, y: 2, next: Some(&node) };

不过这样的话,这里的 node 的所有权是在 root 的外面的,换句话说就是链表中节点的所有权在链表外,这多少看起来有点不妥,比如我想把一个链表的所有权转移出去,我可能要把所有节点的所有权也跟着一个个转走。而且这后面可能会涉及到生命周期的问题。

如果把数据放到堆上,就可以使用 Box 了:

rust
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct S { x: u32, y: u32, next: Option<Box<S>> } let root = S { x: 1, y: 2, next: Some(Box::new(S { x: 3, y: 4, next: None })) };

这样我们就可以认为链表内节点的所有权都在链表内部,看起来更舒服一点(准确来说我们只是有了 Box 的所有权,然后再通过 Box 的 Drop trait 将其再与堆上数据关联起来)

Rc

目前 Rust 的所有权机制使得每个数据只能由一个所有者,这样一个数据的生命周期就比较清晰。但是很多时候一个数据的生命周期是不好说的。比如我有多个结构体都需要引用一个数据,那么这个数据的所有者是不好说的,因为这个数据的生命周期就不好确定,其被释放的最好时机应该是这些结构体都被释放了,也就没人引用这个数据了,那么这个数据就应该被释放了。但是单纯依赖单一所有者机制是不好实现这个东西。

Rc 就帮助我们解决了这一点,其基本原理是引用计数,大概如图所示。

image-20251208192002688

To be continued...

最后更新于:2025-12-21 05:24

Caiwen
本文作者
一只蒟蒻,爱好编程和算法