Wait the light to fall

细究 Rust 中的所有权

焉知非鱼

所以,您想学习 Rust,并不断了解所有权和借用的概念,但不能完全了解它的含义。所有权至关重要,因此在学习 Rust 的过程中尽早理解它是很好的,而且还可以避免遇到导致您无法实现程序的编译器错误。

上一篇文章中,我们已经从 JavaScript 开发人员的角度讨论了所有权模型。在本文中,我们将仔细研究 Rust 如何管理内存,以及为什么这最终会影响我们在 Rust 中编写代码并保持内存安全的方式。

那么什么是内存安全? #

首先,最重要的是要了解在讨论什么使 Rust 成为一种编程语言时,内存安全实际上意味着什么。特别是当来自非系统编程背景,或者主要具有垃圾回收语言的经验时,可能很难理解 Rust 的这一基本功能。

正如威尔·克里顿(Will Crichton)在他的伟大文章《Rust 的内存安全:C 语言案例研究》中所述:

“内存安全性是程序的属性,其中所使用的内存指针始终指向有效内存,即已分配的内存和正确的类型/大小。内存安全是一个正确性问题-内存不安全程序可能会崩溃,或者会由于错误而产生不确定的输出。”

实际上,这意味着存在允许我们编写“内存不安全”代码的语言,从某种意义上来说,引入错误非常容易。其中一些错误是:

  • 悬空指针:指向无效数据的指针(一旦我们查看数据在内存中的存储方式,这将更有意义)。您可以在此处阅读有关悬空指针的更多信息。

  • 两次释放:尝试两次释放相同的内存位置,这可能导致“未定义的行为”。在这里查看更多信息。

为了说明悬空指针的概念,让我们看一下下面的 C++ 代码及其在内存中的表示方式:

std::string s = "Have a nice day";

初始化的字符串在内存中通常使用如下的栈和堆表示:

                     buffer
                   /   capacity
                 /   /    length
               /   /    /
            +–––+––––+––––+
stack frame │ • │ 16 │ 15 │ <– s
            +–│–+––––+––––+
              │
            [–│––––––––––––––––––––––––– capacity ––––––––––––––––––––––––––]
              │
            +–V–+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+
       heap │ H │ a │ v │ e │   │ a │   │ n │ i │ c │ e │   │ d │ a │ y │   │
            +–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+

            [––––––––––––––––––––––––– length ––––––––––––––––––––––––––]

我们将在一秒钟之内介绍一下栈和堆的内容,但是现在很重要的一点是,要知道存储在栈中的是 std::string 对象本身,它的长度为三个字,固定大小。这些字段是指向堆分配缓冲区的指针,该缓冲区保存实际数据,缓冲区容量和文本长度。换句话说,std::string 拥有其缓冲区。程序销毁该字符串时,也会通过该字符串的析构函数释放相应的缓冲区。

但是,完全有可能为指向位于同一缓冲区内的字符的其他指针对象创建一个指针,该对象也不会被破坏,而在字符串被破坏后使它们变为无效,并且我们得到了它-悬空指针!

如果你要问用 JavaScript 或 Python 这样的语言编写程序时为什么这不是问题,那是因为这些语言被垃圾回收了。这意味着该语言附带了一个程序,该程序在运行时将遍历内存并释放不再使用的所有内容。这样的程序称为垃圾回收器。虽然这听起来很不错,但是垃圾回收当然是有代价的。由于它发生在程序运行时,因此肯定会影响程序的整体运行时性能。

Rust 不附带垃圾回收,而是解决了使用所有权和借用保证内存安全的问题。当我们说 Rust 具有内存安全性时,我们指的是默认情况下 Rust 的编译器甚至不允许我们编写不是内存安全的代码。多么酷啊?

栈和堆 #

在介绍 Rust 如何处理数据所有权之前,让我们快速介绍一下栈和堆是什么以及它们与将哪些数据存储在何处的关系。

栈和堆都是内存的一部分,但是以不同的数据结构表示。而栈是…好吧,在栈中,值按输入顺序存储,然后以相反顺序删除(这是非常快的操作),堆更像是树结构,需要更多的计算工作读取和写入数据。

栈中的内容和堆中的内容取决于我们正在处理的数据。在 Rust 中,任何固定大小(或在编译时为“已知”大小)的数据(例如机器整数,浮点数字类型,指针类型和其他一些类型)都存储在栈中。动态和“未固定大小”的数据存储在堆中。这是因为通常这些未知大小的类型要么需要能够动态增长,要么因为它们在被破坏时需要执行某些“清理”工作(不仅仅是从栈中弹出一个值)。

因此,在上一个示例中,字符串对象本身实际上是一个存储在栈中的指针,该指针始终具有固定的大小(缓冲区指针,容量和长度),而缓冲区(原始数据)存储在堆中。

至于 Rust,通常该语言避免在堆上存储数据,并且编译器也不会隐式地这样做。为了明确起见,Rust 附带了某些指针类型,例如 Box,我们将在另一篇文章中介绍。有关栈和堆的更多信息,我强烈建议您阅读 Rust 的“所有权”官方章节

掌握所有权 #

现在,我们对数据的存储方式有了一些更好的了解,让我们仔细研究一下 Rust 中的所有权。 在 Rust 中,每个值都有一个确定其寿命的所有者。 如果我们从上面获取 C++ 代码并查看 Rust 等效项,那么数据存储在内存中的方式几乎相同。

let s = "Have a nice day".to_string();

同样,当某个值的所有者被“释放”时,或在 Rust 术语中被“删除”时,所拥有的值也被删除。 值何时被删除? 这就是它变得有趣的地方。 当程序离开声明变量的块时,该变量将被删除,并随之删除其值。

块可以是函数,if 语句或几乎任何引入带有花括号的新代码块的东西。 假设我们具有以下函数:

fn greeting() {
  let s = "Have a nice day".to_string();
  println!("{}", s); // `s` is dropped here
}

仅通过查看代码,我们就知道 s 的生存期,因为我们知道 Rust 在到达该函数块末尾时会删除其值。 当我们处理更复杂的数据结构时,也是如此。 让我们看一下以下代码:

let names = vec!["Pascal".to_string(), "Christoph".to_string()];

这将创建一个名称向量。 Rust 中的向量就像一个数组或列表,但是大小是动态的。 我们可以在运行时将值推入(push())其中。 我们的内存将如下所示:

            [–– names ––]
            +–––+–––+–––+
stack frame │ • │ 3 │ 2 │
            +–│–+–––+–––+
              │
            [–│–– 0 –––] [–––– 1 ––––]
            +–V–+–––+–––+–––+––––+–––+–––+–––+
       heap │ • │ 8 │ 6 │ • │ 12 │ 9 │       │
            +–│–+–––+–––+–│–+––––+–––+–––+–––+
              │\   \   \  │
              │ \   \    length
              │  \    capacity
              │    buffer │
              │           │
            +–V–+–––+–––+–––+–––+–––+–––+–––+
            │ P │ a │ s │ c │ a │ l │   │   │
            +–––+–––+–––+–––+–––+–––+–––+–––+
                          │
                          │
                        +–V–+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+
                        │ C │ h │ r │ i │ s │ t │ o │ p │ h │   │   │   │
                        +–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+

请注意,向量对象本身(与之前的字符串对象类似)如何以其容量和长度存储在栈中。它还带有一个指针,该指针指向向量数据在堆中的位置。然后,向量的字符串对象将存储在堆中,而堆又拥有其专用缓冲区。

这将创建数据的树结构,其中每个值都由单个变量拥有。当 names 超出作用域时,其值将被删除,这最终也会导致字符串缓冲区也被删除。

但是,这可能会引起几个问题。 Rust 如何确保只有一个变量拥有其值?如何使多个变量指向同一数据?我们是否被迫复制所有内容以确保仅单个变量拥有某些值?

移动和借用 #

让我们从第一个问题开始:Rust 如何确保只有一个变量拥有其值?事实证明,Rust 在执行诸如值分配或将值传递给函数之类的操作时会将值移至其新所有者。这是一个非常重要的概念,因为它会影响我们在 Rust 中编写代码的方式。

让我们看一下以下代码:

let name = "Pascal".to_string();
let a = name;
let b = name;

来自 Python 或 JavaScript 之类的语言的人,可能希望 ab 都引用 name,因此它们都指向相同的数据。 但是,当我们尝试编译此代码时,我们很快意识到事实并非如此:

error[E0382]: use of moved value: `name`
 --> src/main.rs:4:11
  |
2 |   let name = "Pascal".to_string();
  |       ---- move occurs because `name` has type `std::string::String`, which does not implement the `Copy` trait
3 |   let a = name;
  |           ---- value moved here
4 |   let b = name;
  |           ^^^^ value used here after move

我们收到了包含很多(有用)信息的编译器错误。 编译器告诉我们,在将值移动到 a 后,我们正在尝试将其值从 name 分配给 b。 这里的问题是,当我们尝试将 name 的值分配给 b 时,name 实际上不再拥有该值。 为什么? 因为所有权已同时转移到 a

让我们看一下内存中发生的事情,以便更好地了解正在发生的事情。 初始化 name 后,它看起来与之前的示例非常相似:

            +–––+–––+–––+
stack frame │ • │ 8 │ 6 │ <– name
            +–│–+–––+–––+
              │
            +–V–+–––+–––+–––+–––+–––+–––+–––+
       heap │ P │ a │ s │ c │ a │ l │   │   │
            +–––+–––+–––+–––+–––+–––+–––+–––+

但是,当我们将 name 的值分配给 a 时,也会将所有权移到 a 上,而 name 尚未初始化:

            [–– name ––] [––– a –––]
            +–––+–––+–––+–––+–––+–––+
stack frame │   │   │   │ • │ 8 │ 6 │ 
            +–––+–––+–––+–│–+–––+–––+
                          │
              +–––––––––––+
              │
            +–V–+–––+–––+–––+–––+–––+–––+–––+
       heap │ P │ a │ s │ c │ a │ l │   │   │
            +–––+–––+–––+–––+–––+–––+–––+–––+

在这时,let b = name 发生错误将不足为奇。 这里要领会的是,所有这些都是由编译器完成的静态分析,而没有实际运行我们的代码!

还记得我说过 Rust 的编译器不允许我们编写内存不安全的代码吗?

那么,我们该如何处理此类情况? 如果我们真的想让多个变量指向同一数据怎么办? 有两种方法可以解决此问题,根据情况,我们要选择一种。 处理此方案的最简单但也是最昂贵的方法是复制或克隆值。 显然,这也意味着我们将最终复制内存中的数据:

let name = "Pascal".to_string();
let a = name;
let b = a.clone();

请注意,我们不需要将 name 中的值克隆到 a 中,因为在将值分配给 a 之后我们不会尝试从 name 中读取值。 当我们运行该程序时,数据将在删除之前像这样在内存中表示:

            [–– name ––] [––– a –––][–––– b ––––]
            +–––+–––+–––+–––+–––+–––+–––+–––+–––+
stack frame │   │   │   │ • │ 8 │ 6 │ • │ 8 │ 6 │
            +–––+–––+–––+–│–+–––+–––+–│–+–––+–––+
                          │           │
              +–––––––––––+           +–––––––+
              │                               │
            +–V–+–––+–––+–––+–––+–––+–––+–––+–V–+–––+–––+–––+–––+–––+–––+–––+
       heap │ P │ a │ s │ c │ a │ l │   │   │ P │ a │ s │ c │ a │ l │   │   │
            +–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+

显然,克隆数据并不总是一种选择。 根据我们要处理的数据,这可能是一个非常昂贵的操作,需要大量内存。 通常,我们真正需要的只是对值的引用。 当我们编写实际上不需要值所有权的函数时,这特别有用。 想象一个函数 greet() 接受一个名字并简单地输出它:

fn greet(name: String) {
  println!("Hello, {}!", name);
}

此函数不需要所有权即可输出其所接收的值。 而且,这将阻止我们使用相同的变量多次调用该函数:

let name = "Pascal".to_string();
greet(name);
greet(name); // Move happened earlier so this won't compile

为了获得对变量的引用,我们使用 & 符号。 这样一来,我们就可以明确何时期望值引用:

fn greet(name: &String) {
  println!("Hello, {}!", name);
}

出于记录考虑,出于各种原因,我们可能会将此 API 设计为期望使用 &str 代替,但是我不想因为需要而使其变得更加混乱,因此我们现在仅使用 &String

greet() 现在需要一个字符串引用,这也使我们可以多次调用它,如下所示:

let name = "Pascal".to_string();
greet(&name);
greet(&name);

当一个函数期望引用一个值时,它借用它。 注意,它永远不会获得传递给它的值的所有权。

我们可以以类似的方式解决早期的变量赋值问题:

let name = "Pascal".to_string();
let a = &name;
let b = &name;

使用此代码,name 永远不会失去其值的所有权,并且 ab 只是指向相同数据的指针。 可以这样表示:

let name = "Pascal".to_string();
let a = &name;
let b = a;

在这些赋值之间调用 greet() 不再是问题:

let name = "Pascal".to_string();
let a = &name;
greet(a);
let b = a;
greet(a);

结论 #

这实际上只是冰山一角。 关于所有权,借用和移动数据,还有其他一些要考虑的问题,但是希望本文能很好地理解 Rust 如何确保内存安全的幕后情况。

有关 Rust 的更多文章即将发布!

  • 原文:https://blog.thoughtram.io/ownership-in-rust/