【🔥前端学rust】所有权

87 阅读7分钟

前言

这是  【🔥前端学rust】  系列文章的第六篇文章了。其他内容如下:

  1. 【🔥前端学rust】花式println和注释
  2. 【🔥前端学rust】基础数据类型
  3. 【🔥前端学rust】复合数据类型
  4. 【🔥前端学rust】函数
  5. 【🔥前端学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);
}

image.png

这里我们看到了 s2 = s1 之后在访问 s1 直接就报错了,因此这里的行为就不是 copy 操作了。这里的行为可以理解为:当 s1 被赋予 s2 后,Rust 认为 s1 不再有效,因此也无需在 s1 离开作用域后 drop 任何东西,这就是把所有权从 s1 转移给了 s2s1 在被赋予 s2 后就马上失效了。可以解读为 s1 被 移动 到了 s2 中。

clone

如果我们需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的方法。

fn main() {
    let s1 = String::from("hello world!");
    let s2 = s1.clone();
    println!("{}, {}, {}", s1, s2, s1 == s2);
}

image.png

总结

rust 的所有权系统是其设计的天才想法其中的一个点,通过所有权系统避免了许多常见的内存错误,如悬空指针和内存泄漏,提供了一个安全且高效的内存管理方式。