【Fitz】Rust 所有权和借用
说明:本文主要是Rust语言圣经相关章节的学习笔记,大部分与其内容相同,欢迎阅读原文。
在其他语言中,内存安全几乎通常是通过 GC 的方式实现,但是 GC 会引来性能、内存占用以及 stop the world 等问题,在高性能场景和系统编程上是不可接受的。Rust 使用 所有权系统 来解决内存安全性问题。
所有权
对于释放内存空间,不同语言使用了不同的方法:
- 垃圾回收机制(GC),在程序运行时不断寻找不再使用的内存,典型代表:Java、Go
- 手动管理内存的分配和释放,通过函数调用的方式来申请和释放内存,典型代表:C++
- 通过所有权来管理内存,编译期在编译时会根据一系列规则进行检查
栈(Stack)和堆(Heap)
栈和堆是编程语言最核心的数据结构,对于 Rust 来说,值位于栈上还是堆上非常重要。栈和堆的核心目标就是为程序在运行时提供可供使用的内存空间。
栈
栈是一种后进先出的数据结构,栈中的所有数据都必须占用已知且固定大小的内存空间,假如数据大小是未知的,那么在取出数据时,将无法取到想要的数据。
堆
对于大小未知或者可能变化的数据,需要将它存储在堆上。
当向堆上放入数据时,需要请求一定大小的内存空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已用,并返回一个表示该位置地址的指针 , 该过程被称为 在堆上分配内存 ,有时简称为 “分配”(allocating)。接着,该指针会被推入栈中,因为指针的大小是已知且固定的,在后续使用过程中,你将通过栈中的指针 ,来获取数据在堆上的实际内存位置,进而访问该数据。
因此,堆是一种缺乏组织的数据结构。
性能区别
堆在写入时需要分配空间,读取时只能从内存中读取,因此处理器处理和分配在栈上的数据会比在堆上的数据更加高效。
所有权与堆栈
当你的代码调用一个函数时,传递给函数的参数(包括可能指向堆上数据的指针和函数的局部变量)依次被压入栈中,当函数调用结束时,这些值将被从栈中按照相反的顺序依次移除。
因为堆上的数据缺乏组织,因此跟踪这些数据何时分配和释放是非常重要的,否则堆上的数据将产生内存泄漏 —— 这些数据将永远无法被回收。这就是 Rust 所有权系统为我们提供的强大保障。
所有权规则
- Rust 中的每一个值都有一个被称为其 所有者(owner)的变量。
- 值在任一时刻有且只有一个所有者。
- 当所有者(变量)离开作用域,这个值将被丢弃。
作用域是一个变量在程序中有效的范围。变量从创建处还是有效,直到离开其作用域为止。
前面使用的字符串字面值 let s = "hello",s 是被硬编码进程序里的字符串值(类型为 &str)。字符串字面值有其限制:1)字符串字面值是不可变的,2)并非所有字符串的值都能在编码时得知。
Rust 提供了动态字符串类型:String,该类型被分配到堆上,因此可以动态伸缩也能存储在编译时大小未知的文本。如 let s = String::from("hello"); 就定义了一个 String 类型的变量。
变量绑定背后的数据交互
转移所有权
let x = 5;
let y = x;
上面代码示例中,因为整数是 Rust 的基本数据类型,所以 x 的值是通过拷贝的方式赋给 y 的,这个拷贝过程由于数据类型简单因此可以存在栈中,而无需再堆上分配内存。
let s1 = String::from("hello"):
let s2 = s1;
上面代码示例中,String不是基本数据类型,因此 s1 的值存在堆上,并且 s1 不能自动拷贝。
String 类型是一个复杂类型,由存储在栈中的栈指针、字符串长度、字符串容量共同组成,其中堆指针最重要,它指向了真实存储字符串内容的堆内存。
对于 let s2 = s1; ,实际上是将字符串类型指向的堆内存的所有权从 s1 转移给了 s2,此后 s1 不再有效,也无需在 s1 离开作用域后 drop 任何东西,这个操作被成为 移动(move),而不同于其他语言中的浅拷贝(shallow copy)。
let x: &str = "hello, world";
let y = x;
println!("{},{}",x,y);
上面的例子中,x 只是引用了存储在二进制中的字符串 "hello, world",并没有持有所有权,因此代码不会报错。
克隆(深拷贝)
Rust 永远也不会自动创建数据的“深拷贝”。因此任何自动的复制都不是深拷贝,可以被认为对运行时性能影响较小。
如果确实需要复制堆上的数据而不仅仅是栈上的数据,可以使用 clone 方法,如果能够正常运行,说明确实完成了堆上数据的深拷贝。对于执行频繁的代码,使用 clone 会极大降低程序性能,需要小心使用。
拷贝(浅拷贝)
浅拷贝只发生在栈上,因此性能很高,日常编程中浅拷贝无处不在。
Rust 中的基本数据类型会被存储在栈上,因此拷贝其值是快速的。这里调用 clone 和通常的浅拷贝没有什么不同。
Rust 中的 copy trait,可以用在存储在栈中的类型。如果一个类型拥有 Copy trait,那么旧的变量在被赋值给其他变量后仍然可用。
具体含有 copy trait 的类型在文档中可以查看,也有通用的规则:任何基本类型的组合可以 Copy,不需要分配内存或某种形式资源的类型是可以 Copy 的。如下是一些含有 Copy trait 的类型:
- 所有整数类型,比如
u32。 - 布尔类型,
bool,它的值是true和false。 - 所有浮点数类型,比如
f64。 - 字符类型,
char。 - 元组,当且仅当其包含的类型也都是
Copy的时候。比如,(i32, i32)是Copy的,但(i32, String)就不是。 - 不可变引用
&T,如上一个代码示例,但是注意: 可变引用&mut T是不可以 Copy的
函数传值与返回
将值传递给函数,同样会发生 移动 或者 复制,就像 let 语句一样。同样的,函数返回值也有所有权。
所有权很强大,避免了内存的不安全性,但是也麻烦:总是把一个值传来传去来使用它。Rust 使用 借用(Borrowing) 的方法来解决这个问题。
引用与借用
借用(Borrowing)即 获取变量的引用,正如生活中,某人拥有某样东西,需要的时候我们可以从他那里借用,用完之后再还回去。
引用与解引用
常规引用是一个指针类型,指向了对象存储的内存地址。使用解引用符*可以解出引用所指向的值。在使用引用时必须使用解引用运算符解出引用所指向的值。
不可变引用
通过 & 符号引用变量,可以使用变量的值,但是不获取所有权不能对变量的值进行修改,即不可变引用。正如变量默认不可变,引用指向的值默认也是不可变的。由于不获取所有权,当引用离开作用域后,其指向的值也不会被丢弃。
可变引用
使用可变引用 &mut 即可借用一个可变的变量,然后通过该可变引用使用或修改变量的值。
可变引用同时只能存在一个
可变引用的一个很大的限制就是:同一作用域,特定数据只能有一个可变引用,这也是编译器 borrow checker 特性之一。
这种限制可以使 Rust 在编译器就避免数据竞争,而数据竞争可由以下行为造成:
- 两个或更多的指针同时访问同一数据
- 至少有一个指针被用来写入数据
- 没有同步数据访问的机制
很多时候,大括号可以帮助我们通过手动限制变量的作用域来解决一些编译不通过的问题。
可变引用与不可变引用不能同时存在
正在借用不可变引用的用户,肯定不希望他借用的东西,有可能被另一个人修改了。多个不可变引用被允许是因为没有人会去试图修改数据,每个人都只读不写,不用担心数据修改问题。
注意:引用的作用域从创建开始,一直持续到它最后一次使用的地方,这个跟变量的作用域有所不同,变量的作用域从创建持续到某一个花括号
}为止。
Rust 的编译器一直在优化,早期的时候(Rust 1.31前),引用的作用域跟变量的作用域一致。但在新的编译器中,使用了上述的引用作用域规则。如下面代码片段所示。
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
// 新编译器中,r1,r2作用域在这里结束
let r3 = &mut s;
println!("{}", r3);
} // 老编译器中,r1、r2、r3作用域在这里结束
// 新编译器中,r3作用域在这里结束
NLL
对于上面提到的这种编译器优化行为,Rust 专门起了一个名字 —— Non-Lexical Lifetimes(NLL),专门用于找到某个引用在作用域(})结束前就不再被使用的代码位置。
悬垂引用(Dangling References)
悬垂引用也叫做悬垂指针,意思为指针指向某个值后,这个值被释放掉了,但指针仍然存在,其指向的内存可能不存在任何值或已被其他变量重新使用。在 Rust 中编译器可以确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器可以确保数据不会在其引用之前被释放,要想释放数据,必须先停止其引用的使用。
借用规则总结
- 同一时刻,你只能拥有要么一个可变引用,要么任意多个不可变引用
- 引用必须总是有效的

浙公网安备 33010602011771号