Rust 中的所有权机制

架构大师笔记
架构大师笔记
发布于 2024-08-18 / 20 阅读
1
0

Rust 中的所有权机制

如果你是一名主要使用 Java、Python 或 JavaScript 等带有垃圾回收机制语言的开发者,那么你对自动内存管理的概念一定不陌生。

然而,如果你曾经使用过 C、C++ 或汇编语言(向你致敬!),那么你一定对手动内存管理深有体会。

内存管理是指分配内存和释放内存的过程。换句话说,它是指找到未使用的内存,并在不再需要时将其归还的过程。

  • 《Rust 程序设计语言》

但是,这两种类型的语言(垃圾回收型和非垃圾回收型(手动内存管理))之间有什么区别呢?

垃圾回收

在垃圾回收型语言中,垃圾回收器程序负责识别和回收不再使用的内存。

这是一种自动内存管理的形式,有助于防止内存泄漏并优化可用内存的使用。

垃圾回收器会自动释放分配给不再可达或不再需要的对象的内存,让你(程序员)可以专注于编码的其他方面,而无需担心手动管理内存释放。

然而,垃圾回收也会带来性能开销,因为它需要额外的操作来自动管理内存。

手动内存管理

另一方面,非垃圾回收型语言是指那些通常由程序员手动处理内存管理的语言。

这意味着程序员必须显式地分配内存和释放内存,以防止内存泄漏和其他问题。

虽然手动内存管理使开发人员可以直接控制何时以及如何分配和释放内存,并且没有垃圾回收的开销,但它也带来了一些潜在的问题,例如内存泄漏(分配的内存没有被正确释放)、悬空指针(当一个指针在内存被释放后仍然引用该内存位置时)以及重复释放(当试图多次释放同一个内存位置时)等等。

那么,Rust 在这方面是如何做的呢?

Rust 既不使用垃圾回收器,也不依赖于手动内存管理。

相反,Rust 借助借用检查器,采用了一种基于所有权的独特内存管理系统。

它保证了内存安全,防止了与手动内存管理相关的常见错误,并且没有与垃圾回收相关的运行时开销。

但在我们详细讨论所有权之前,让我们先了解一些概念。

栈与堆

如果你有传统的计算机科学背景,那么你可能还记得学习过栈和堆内存。

栈内存用于静态内存分配,通常用于生命周期和作用域已知的变量。

堆内存用于动态内存分配,适用于需要在单个函数调用之外持久存在的对象。

让我们在 Rust 的上下文中谈谈内存。

Rust 的内存模型

变量存储在栈中

当你调用 Rust 中的函数时,它会在栈上为该函数分配一个栈帧。你可以将栈帧理解为在一个作用域(例如函数)内,从变量到其值的映射。

例如:

fn main() {
    let x = 1;
    let y = 1;
}

在给定的代码中,当调用 main 函数时,Rust 会为其分配一个栈帧,并在函数返回后自动释放该函数的栈帧。

L1 - 函数入口 (fn main() {):

  • main 函数被调用。
  • main 函数创建栈帧,但当前为空。

L2 - 第一个变量声明 (let x = 1;):

  • 声明变量 x 并将其初始化为 1
  • 栈帧现在包含 x,其值为 1

L3 - 第二个变量声明 (let y = 1;):

  • 声明变量 y 并将其初始化为 1
  • 栈帧现在包含 xy,它们的值都为 1

L4 - 函数退出 (}):

  • main 函数即将退出。
  • 随着函数作用域的结束,main 函数的栈帧被清空,局部变量 xy 超出作用域。

再来看另一个例子:

fn main() {
    let x = 1;
    test();
}

fn test() {
    let y = 1;
}

L1 - 函数入口 (fn main() {):

  • main 函数被调用。
  • main 函数创建栈帧,但当前为空。

L2 - main 函数中的第一个变量声明 (let x = 1;):

  • 声明变量 x 并将其初始化为 1
  • 栈帧现在包含 x,其值为 1

L3 - 函数调用 (test();):

  • 调用 test 函数。
  • main 函数的栈帧现在显示一个对 test 函数的待处理调用。

L4 - 进入 test 函数 (fn test() {):

  • 创建 test 函数的栈帧,但当前为空。
  • 栈现在包含 main 函数和 test 函数的栈帧。

L5 - test 函数中的变量声明 (let y = 1;):

  • test 函数中声明变量 y 并将其初始化为 1
  • test 函数的栈帧现在包含 y,其值为 1

L6 - 从 test 函数退出 (}):

  • test 函数执行完毕,其栈帧被移除。
  • 控制权返回到 main 函数,其栈帧保持不变。

L7 - test 函数返回后继续执行 main 函数:

  • test 函数已返回,其栈帧现在为空。
  • main 函数的栈帧仍然包含 x

L8 - 从 main 函数退出 (}):

  • main 函数执行完毕。
  • 程序结束,main 函数的栈帧被清空。

请注意,在上面的图中,这些栈帧整齐地组织成一个当前调用函数的栈,其中最后添加的栈帧总是下一个被释放的栈帧(后进先出,LIFO)。

复制数据

当表达式读取变量时(例如在赋值和函数调用中),变量的值会从其在栈帧中的位置复制。

然而,复制数据可能会占用大量内存。

想象一下,如果 x 是一个包含一百万个元素的数组!将 x 复制到 y 会导致 main 函数的栈帧包含两百万个元素。

这并不是对可用内存的有效利用。如果我们的程序处理多个这样的大型数据集,这个问题只会成倍增加。

这就是指针发挥作用的地方。

指向堆中的数据

缓解这个问题的一种方法是在堆上分配这些数据并指向它。

指针是一个描述内存位置的值。

Rust 的数据结构(如 VecStringHashMap)默认使用堆内存。

例如:

fn main() {
    let name = String::from("John");
}

L1 - 函数入口 (fn main() {):

  • main 函数被调用。
  • main 函数创建栈帧,但当前为空。

L2 - 字符串分配 (let name = String::from("John");):

  • 从字面量 "John" 创建一个 String
  • 声明变量 name 并将其存储在栈上。
  • 实际的字符串数据 "John" 存储在堆上。
  • main 函数的栈帧现在包含一个指向堆分配字符串数据的引用(指针)。

L3 - 函数退出 (}):

  • main 函数即将退出。
  • main 函数的栈帧被清空,变量 name 超出作用域。

请注意,变量仍然存在于栈中,但其值存储在堆上。

Rust 还提供了 Box 结构体,用于将数据放入堆中。

现在,让我们回到之前的例子,我们有一个包含一百万个元素的数组,但这次我们不将这些元素存储在栈中,而是使用 Box::new() 将其存储在堆中:

fn main() {
    let x = Box::new([0; 1_000_000]);
    let y = x;
}

L2 - 堆分配 (let x = Box::new([0; 1_000_000]);):

  • 创建一个 Box,它在堆上分配一个包含一百万个零的数组。
  • 声明变量 x 并将其存储在栈上,该变量指向堆分配的数组。

L3 - Box 的移动 (let y = x;):

  • 与之前不同,y 不会在堆上创建另一个包含一百万个元素的副本并指向它。
  • 相反,x 的所有权被移动到 y。(注意 x 现在是如何变灰的)

这就是移动的含义:

在 Rust 中,所有堆数据必须由且仅由一个变量拥有。当你执行 let y = x; 时,Rust 将指针从 x 复制到y,但不会复制指向的数据。

现在,新的所有者是 y,因此 x 变为无效,你不能再使用它来访问堆了:

fn main() {
    let x = Box::new([0; 1_000_000]);
    let y = x;
    println!("{}", x); // 错误
}
error[E0382]: borrow of moved value: `x`
 --> src/main.rs:4:22
  |
2 |     let x = Box::new([0; 1_000_000]);
  |         - move occurs because `x` has type `Box<[i32; 1000000]>`, which does not implement the `Copy` trait
3 |     let y = x;
  |             - value moved here
4 |     println!("{:?}", x);
  |                      ^ value borrowed here after move

堆数据只能通过其当前所有者 y 访问,而不能通过之前的x 访问:

fn main() {
    let x = Box::new([0; 1_000_000]);
    let y = x;
    println!("{:?}", y); // 正确:y 是新的所有者
}

内存释放

栈帧由 Rust 自动管理。当调用函数时,Rust 会为被调用函数分配一个栈帧。当调用结束时,Rust 会释放该栈帧。

但是堆数据呢?

当 Rust 释放拥有该 Box 的变量的栈帧时,它会自动释放 Box 的堆内存。

所以回到之前的例子:

L4 - 函数退出 (}):

  • main 函数即将退出。
  • main 函数的栈帧被清空,变量 y 超出作用域。
  • Box 的所有者超出作用域时,堆分配的数组被释放。

再来看另一个例子,其中所有权在不同的函数之间移动:

fn main() {
    let first = String::from("Ferris");
    let full = add_suffix(first);
    println!("{full}");
}

fn add_suffix(mut name: String) -> String {
    name.push_str(" Jr.");
    name
}

L1 - 函数入口 (fn main() {):

  • main 函数被调用。
  • main 函数创建栈帧。

L2 - 字符串创建 (let first = String::from("Ferris");):

  • 创建一个名为 firstString,其值为 "Ferris"
  • 变量 first 被分配在栈上,而实际的字符串数据存储在堆上。
  • first 存储一个指向堆数据的指针。

L3 - 函数调用 (let full = add_suffix(first);):

  • 调用 add_suffix 函数,并将 first 作为参数传递。
  • first 字符串的所有权被移动到 add_suffix 函数,first 变为无效。

L4 - 函数入口 (fn add_suffix(mut name: String) -> String {):

  • add_suffix 函数被调用,name 被初始化为 "Ferris"
  • add_suffix 函数创建栈帧,变量 name 被分配在栈上。
  • name 现在是堆数据的新所有者。

L5 - 修改字符串 (name.push_str(" Jr.");):

  • name 调用 push_str 方法,在其后面追加 " Jr."。这会做三件事。首先,它会创建一个新的更大的分配空间。其次,它会将 "Ferris Jr." 写入新的分配空间。第三,它会释放原始的堆内存。first 现在指向已释放的内存(用 ⦻ 表示)。
  • 变量 name 现在包含值 "Ferris Jr."

L6 - 返回修改后的字符串 (name):

  • 修改后的 name 字符串从 add_suffix 函数返回。
  • 字符串的所有权被移回调用者(main 函数)。
  • add_suffix 函数的栈帧被清空。

L7 - 在 main 函数中继续执行:

  • add_suffix 函数返回后,控制权回到 main 函数。
  • 变量 full 现在拥有字符串 "Ferris Jr."

L8 - 打印结果 (println!("{full}");):

  • 调用 println! 宏来打印 full 的值。
  • 打印值 "Ferris Jr."

L9 - 函数退出 (}):

  • main 函数退出。
  • main 函数的栈帧被清空,变量 full 超出作用域。
  • 字符串的堆分配内存也被释放,因为它归 full 所有。

请注意,在上面的例子中,堆数据并不仅仅绑定到一个栈帧,而是根据哪个变量拥有它而附加到不同的栈帧。

现在你可能会想,这种所有权的概念是如何在 Rust 中保证内存安全的?

简而言之,它可以防止未定义行为,从而确保内存安全。

未定义行为会导致各种问题,例如程序崩溃、安全漏洞或数据损坏。

但是,我们所说的未定义行为是什么意思呢?

未定义行为

Rust 中的未定义行为是指 Rust 编译器和运行时不保证其行为可预测或正确的操作。

考虑一下之前代码的一个略有不同的版本:

fn main() {
    let first = String::from("John");
    let mut name = first;
    name.push_str(" Doe");
    println!("{first}"); // 错误
}

在这里,当你执行 name.push_str(" Doe"); 时,它会释放之前的字符串并重新分配新的更新后的字符串。这会导致 first 指向已释放的内存。

如果没有所有权,你将能够访问 first,从而导致未定义行为。

然而,Rust 在编译期间就阻止了这种未定义行为:

error[E0382]: borrow of moved value: `first`
 --> src/main.rs:5:15
  |
2 |     let first = String::from("John");
  |         ----- move occurs because `first` has type `String`, which does not implement the `Copy` trait
3 |     let mut name = first;
  |                    ----- value moved here
4 |     name.push_str(" Doe");
5 |     println!("{first}");
  |               ^^^^^^^ value borrowed here after move

类似地,想象一下,如果 Rust 允许你使用像 free 这样的函数手动释放内存:

let b = Box::new([0; 100]);
free(b);
assert!(b[0] == 0); // 未定义行为

在这里,它也会导致未定义行为,因为你试图在释放内存后读取指针 b。这将试图访问无效的内存,从而导致程序崩溃。或者更糟糕的是,它可能不会崩溃并返回任意数据。因此,这个程序是不安全的。

相反,Rust 使用所有权模型自动释放 Box 的堆内存,以防止这种未定义行为。

Rust 的一个基本目标是确保你的程序永远不会出现未定义行为。这就是“安全”的含义。

Rust 的第二个目标是在编译时而不是运行时防止未定义行为。

总结

Rust 采用了一种基于所有权、借用和生命周期的独特内存管理系统,以确保内存安全,而无需垃圾回收器或手动内存管理。

  • 栈与堆:Rust 区分栈内存和堆内存。栈内存用于生命周期已知且较短的局部变量,而堆内存用于需要在当前作用域之外持久存在的数据。
  • 复制和移动语义:Rust 通过允许移动所有权而不是复制数据来最大程度地减少不必要的数据复制。这对于大型数据结构特别有用,可以减少内存使用并提高性能。
  • 所有权:Rust 中的每条数据都有一个唯一的所有者,当所有者超出作用域时,数据会自动释放。这可以防止内存泄漏和悬空指针。
  • 防止未定义行为:通过确保始终安全正确地访问内存,Rust 可以防止未定义行为,而未定义行为可能导致程序崩溃、安全漏洞和数据损坏。

但是,这只是难题的一部分。Rust 使用所有权、借用和切片等概念的组合来确保内存安全。


评论