前言
这是 【🔥前端学rust】 系列文章的第六篇文章了。其他内容如下:
这篇文章我们来聊一聊 rust
中的所有权系统。rust
的所有权系统是它最独特的特性之一,它是一种内存安全的保证,无需运行时的额外开销。从内存管理的方式上可以将编程语言可以分为三个类别:
- 垃圾回收机制(GC) ,在程序运行时不断寻找不再使用的内存,典型代表:Java、JavaScript。
- 手动管理内存的分配和释放, 在程序中,通过函数调用的方式来申请和释放内存,典型代表:C++。
- 通过所有权来管理内存,编译器在编译时会根据一系列规则进行检查,如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序。典型代表:Rust。
其中 rust
是第三种的典型代表,且 rust
的这种检查只发生在编译期,因此对于程序运行期,不会有任何性能上的损失。这也是 rust
的所有权系统设计的巧妙之处。
所有权
rust
的所有权系统是其独特的设计特点之一,旨在确保内存安全和并发安全,同时无需垃圾收集器。在 rust
中,所有权(Ownership)是指每个值都有一个所有者,这个所有者负责管理该值的生命周期。一个值在同一时间内只能有一个所有者。所有权的三个主要规则是:
- 每个值都有一个变量作为其“所有者”。
- 在任何时刻,一个值只能有一个所有者。
- 当所有者(变量)离开作用域,这个值将被丢弃。
栈(Stack)与堆(Heap)
所有权(Ownership)是 Rust
用于管理内存的一组规则,该规则主要是通过栈(Stack)和堆(Heap)是两种方式进行内存管理的。该规则通过栈(Stack)和堆(Heap)两种数据结构避免重复数据,及时清理可回收内存等。
栈(Stack)
在 rust
中,栈主要是为了存储占用已知且固定大小的内存空间的数据类型,如整数、布尔等基础类型数据。栈是一种后进先出的数据结构,即栈按照顺序存储值并以相反顺序取出值,这也被称作后进先出。最形象的例子就是叠盘子,当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,再从顶部拿走。增加数据叫做 进栈(pushing onto the stack),而移出数据叫做 出栈(popping off the stack)。其特点如下:
- 自动管理: 栈上的数据由编译器自动管理,不需要手动分配和释放。
- 速度快: 访问速度非常快,因为栈遵循严格的顺序原则。
- 有限空间: 每个线程的栈空间都是固定的,超过限制可能会导致栈溢出。
- 生命周期:栈上分配的变量在超出其作用域时会自动释放,避免了内存泄漏。
fn main() {
let x = 5; // 存放在栈上
let y = 6;
let sum = x + y;
}
堆(Heap)
在 Rust
中,堆主要用于存放那些大小未知或变化的数据类型,比如字符串、向量(Vec)、哈希表(HashMap)等复制数据类型。堆是一段较大的内存区域,用于动态分配内存。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的指针,该过程被称为在堆上分配内存,有时简称为 “分配”(allocating)。接着,该指针会被推入栈中,因为指针的大小是已知且固定的,在后续使用过程中,你将通过栈中的指针,来获取数据在堆上的实际内存位置,进而访问该数据。其特点如下:
- 手动管理: 在
Rust
中,尽管借助所有权和借用检查机制,内存的释放是自动管理的,但仍需要显式地使用智能指针来进行堆分配。 - 灵活性高: 可以根据实际需求动态调整大小。
- 访问较慢: 相比于栈,访问堆上的数据稍慢一些,因为它涉及指针操作。
use std::string::String;
fn main() {
let s = String::from("Hello world!"); // 动态分配的字符串,存放在堆上
let v = vec![1, 2, 3]; // 向量也是动态分配的,存放在堆上
}
变量作用域
在 rust
中,变量是否有效与作用域的关系跟 JavaScript
是类似的。变量作用域(scope)是指变量可被访问和使用的范围。也即是进入作用域时,声明的变量就是有效的。该有消息一直持续到它离开作用域为止。
fn main() {
let x = 5; // x 在整个main函数的作用域内都有效
if x == 5 {
let y = 10; // y 只在这个if分支的作用域内有效
println!("x is {} and y is {}", x, y);
}
// println!("y is {}", y); // 这里会导致编译错误,因为y不在当前作用域内
{
let z = 20; // z 只在这个大括号内的作用域内有效
println!("z is {}", z);
}
// println!("z is {}", z); // 这里会导致编译错误,因为z不在当前作用域内
}
转移所有权
在 Rust
中,所有权转移是处理内存管理的一种关键机制。当一个值的所有权从一个变量转移到另一个变量时,原始变量就不再有效。
复制
对于实现了 Copy trait
的数据类型(如整数、布尔值等),赋值或传递给函数不会转移所有权,而是进行简单的复制。rust
中哪些类型可以 Copy
呢?一个通用的规则:任何基本类型的组合可以 Copy
,不需要分配内存或某种形式资源的类型是可以 Copy
的。
如下是一些 Copy
的类型:
- 所有整数类型,比如
u32
。 - 布尔类型,
bool
,它的值是true
和false
。 - 所有浮点数类型,比如
f64
。 - 字符类型,
char
。 - 元组,当且仅当其包含的类型也都实现
Copy
的时候。比如,(i32, i32)
实现了Copy
,但(i32, String)
就没有。
fn main() {
let x = 5; // x 是 i32 类型,实现了 Copy trait
let y = x; // x 的值被复制到 y,x 仍保持有效
println!("x = {}, y = {}", x, y); // 输出: x = 5, y = 5
}
移动
我们先看一下这段代码:
fn main() {
let s1 = String::from("hello world!");
let s2 = s1;
println!("{}, world!", s1);
}
这里我们看到了 s2 = s1
之后在访问 s1
直接就报错了,因此这里的行为就不是 copy
操作了。这里的行为可以理解为:当 s1
被赋予 s2
后,Rust 认为 s1
不再有效,因此也无需在 s1
离开作用域后 drop
任何东西,这就是把所有权从 s1
转移给了 s2
,s1
在被赋予 s2
后就马上失效了。可以解读为 s1
被 移动 到了 s2
中。
clone
如果我们需要深度复制 String
中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone
的方法。
fn main() {
let s1 = String::from("hello world!");
let s2 = s1.clone();
println!("{}, {}, {}", s1, s2, s1 == s2);
}
总结
rust
的所有权系统是其设计的天才想法其中的一个点,通过所有权系统避免了许多常见的内存错误,如悬空指针和内存泄漏,提供了一个安全且高效的内存管理方式。