Rust的所有权和借贷概念介绍及代码实现教程

136 阅读33分钟

如果我们不掌握真正的情况,Rust的 所有权 和 借用可能会令人困惑。当把以前学过的编程风格应用到一个新的范式时,情况尤其如此;我们称之为范式转换。所有权是一个新颖的想法,但一开始却很难理解,但我们越是这样做就越容易。

在我们进一步了解Rust的所有权 和 借用之前,让我们先了解什么是 "内存安全 "和 "内存泄漏",以及编程语言如何处理它们。

什么是内存安全?

内存安全指的是软件应用的状态,其中内存指针或引用总是指向有效内存。因为内存损坏是一种可能性,如果一个程序不是内存安全的,那么它的行为就没有什么保证。简单地说,如果一个程序不是真正的内存安全,那么对它的功能就没有什么保证。在处理一个内存不安全的程序时,恶意的一方能够利用这个缺陷在别人的机器上读取秘密或执行任意代码。

Designed by Freepik

让我们用伪代码来看看什么是有效内存:

// pseudocode #1 - shows valid reference
{ // scope starts here
  int x = 5  
  int y = &x
} // scope ends here

在上面的伪代码中,我们创建了一个变量x ,赋值为10。我们使用& 操作符或关键字来创建一个引用。因此,&x 语法让我们创建了一个引用,这个引用指的是x 的值。简单地说,我们创建了一个拥有 5 的变量x 和一个作为引用的变量y x

由于xy 这两个变量都在同一个块或范围内,所以变量y 有一个有效的引用,它指的是x 的值。因此,变量y 的值是 5。

看一下下面的伪代码。正如我们所看到的,x 的范围被限制在它被创建的块中。当我们试图在它的范围之外访问x ,我们就会陷入悬空引用。悬空引用...?它到底是什么?

// pseudocode #2 - shows invalid reference aka dangling reference
{ // scope starts here
  int x = 5
} // scope ends here
int y = &x // can't access x from here; creates dangling reference

悬空引用

悬空引用是一个指针,它指向一个已经给了别人或被释放(释放)的内存位置。如果一个程序(又称进程)指向已经被释放或清除的内存,它可能会崩溃或导致非确定性的结果。

说到这里,内存不安全是一些编程语言的属性,允许程序员处理无效的数据。因此,内存不安全引入了各种问题,可能导致以下主要的安全漏洞:

  • 界外读取
  • 越界写入
  • 自由使用后

由内存不安全引起的漏洞是许多其他严重安全威胁的根源。不幸的是,发现这些漏洞对开发人员来说是极具挑战性的。

什么是内存泄漏?

了解什么是内存泄漏以及它的后果是很重要的。

Designed by Freepik

内存泄漏是一种无意的内存消耗形式,当不再需要时,开发者未能释放分配的内存块。它与内存安全完全相反。稍后会有更多关于不同内存类型的介绍,但现在只需知道堆栈存储的是编译时已知的固定长度的变量,而以后在运行时可能发生变化的变量的大小必须放在上。

与堆内存分配相比,堆内存分配被认为是比较安全的,因为当内存不再相关或必要时,无论是程序员还是程序运行时本身都会自动释放。

然而,当程序员在堆上生成内存,并且在没有垃圾收集器的情况下没有将其删除(在C和C++的情况下),就会出现内存泄漏。另外,如果我们失去了对某块内存的所有引用,而又不去分配该内存,那么我们就会出现内存泄露。我们的程序将继续拥有该内存,但它没有办法再次使用它。

一个小的内存泄漏不是问题,但是如果一个程序分配了更多的内存,并且从未取消分配,那么这个程序的内存足迹将继续增加,导致拒绝服务。

当一个程序退出时,操作系统会立即收回它所拥有的所有内存。因此,内存泄漏只在程序运行时影响它;一旦程序终止,它就没有影响。

让我们来看看内存泄漏的主要后果。

内存泄漏通过减少可用内存(堆内存)的数量来降低计算机的性能。它最终导致整个或部分系统停止正常工作或严重减慢。崩溃通常与内存泄漏有关。

我们弄清楚如何防止内存泄漏的方法将根据我们使用的编程语言而有所不同。内存泄漏开始时可能是一个小的、几乎 "无法察觉的问题",但它们可以迅速升级,并使其影响的系统不堪重负。在可行的情况下,我们应该注意它们,并采取行动来纠正它们,而不是任其发展。

内存不安全与内存泄漏

内存泄漏和内存不安全是在预防和补救方面受到最大关注的两类问题。值得注意的是,修复一个问题并不会自动修复另一个问题。

Figure 1: Memory unsafety vs. memory leaks.

各种类型的内存和它们如何运作

在我们进一步讨论之前,了解我们的代码在运行时将使用的不同类型的内存是很重要的。

有两种类型的内存,如下所示,这些内存的结构是不同的。

  • 处理器寄存器
  • 静态
  • 堆栈

处理器寄存器静态内存类型都不在本篇文章的讨论范围内。

堆栈内存和它的工作原理

堆栈按照接收数据的顺序存储数据,并按照相反的顺序删除数据。项目可以按照后进先出(LIFO)的顺序从堆栈中访问。在堆栈中添加数据被称为 "推",而从堆栈中删除数据被称为 "弹"。

所有存储在堆栈中的数据必须有一个已知的、固定的大小。在编译时未知大小的数据或以后可能改变大小的数据必须被存储在堆上。

作为开发者,我们不必担心堆栈内存的分配去分配;堆栈内存的分配和去分配是由编译器 "自动完成 "的。这意味着,当堆栈上的数据不再相关时(超出范围),它就会被自动删除,而不需要我们干预。

这种内存分配也被称为临时内存分配,因为一旦函数执行完毕,属于该函数的所有数据就会 "自动 "冲出堆栈。

Rust中的所有原始类型都生活在堆栈中。像数字、字符、片断、布尔、固定大小的数组、包含基元的图元和函数指针等类型都可以在堆栈中。

堆内存和它是如何工作的

与堆栈不同,当我们把数据放在堆上时,我们要求一定数量的空间。内存分配器在堆中找到一个足够大的未被占用的位置,将其标记为使用中,并返回对该位置的引用地址。这就是所谓的分配

在堆上分配比推送到堆的速度要慢,因为分配器从来不需要寻找一个空位置来放置新的数据。此外,由于我们必须遵循一个指针来获取堆上的数据,所以它比访问堆上的数据要慢。与堆栈不同,堆栈是在编译时分配和删除的,而堆内存是在程序指令的执行过程中分配和删除的。

在一些编程语言中,为了分配堆内存,我们使用关键字new 。这个new 关键字(又称运算符)表示对堆上的内存分配的请求。如果堆上有足够的内存,new 操作符会初始化内存,并返回该新分配内存的唯一地址。

值得一提的是,堆内存是由程序员或运行时 "显式 "取消分配的。

其他各种编程语言是如何保证内存安全的?

当涉及到内存管理时,特别是堆内存,我们希望我们的编程语言具有以下特点:

  • 我们希望在不再需要内存的时候尽快释放它,并且没有运行时的开销。
  • 我们不应该维护一个已经被释放的数据的引用(又称悬空引用)。否则,可能会发生崩溃和安全问题。

编程语言以不同的方式保证了内存安全,其手段是:

  • 显式内存去分配(由Java、Python和C#采用)
  • 自动或隐式内存去分配(由C、C++采用)
  • 基于区域的内存管理
  • 线性或独特的类型系统

基于区域的内存管理线性类型系统都不在本帖的讨论范围内。

手动或明确的内存去分配

在使用显式内存管理时,程序员必须 "手动 "释放或擦除分配的内存。在具有显式内存去分配的语言中,存在一个 "去分配 "操作符(例如,C语言中的delete )。

在C和C++这样的系统语言中,垃圾收集的成本太高,因此显式内存分配继续存在。

把释放内存的责任留给程序员,其好处是让程序员完全控制变量的生命周期。然而,如果去分配操作符使用不当,在执行过程中可能会出现软件故障。事实上,这种手工分配和释放的过程很容易出错。一些常见的编码错误包括:

  • 悬空引用
  • 内存泄漏

尽管这样,我们更倾向于手动内存管理,而不是垃圾收集,因为它给了我们更多的控制,并提供了更好的性能。请注意,任何系统编程语言的目标都是尽可能地 "接近金属"。换句话说,他们在权衡中更倾向于更好的性能而不是便利的功能。

我们(开发者)完全有责任确保我们释放的值的指针永远不会被使用。

在最近的过去,有几种被证明的模式可以避免这些错误,但这一切都可以归结为保持严格的代码纪律,这需要持续应用正确的内存管理方法。

关键的启示是:

  • 对内存管理有更大的控制。
  • 由于悬空引用和内存泄漏而导致的安全性降低。
  • 导致更长的开发时间。

自动或隐式内存去分配

自动内存管理已经成为所有现代编程语言的基本特征,包括Java。

在自动内存去分配的情况下,在 垃圾收集器作为自动内存管理器。这些垃圾收集器定期检查堆,回收没有被使用的内存块。它们代表我们管理内存的分配和释放。因此,我们不需要写代码来执行内存管理任务。这很好,因为垃圾收集器将我们从内存管理的责任中解放出来。另一个好处是,它减少了开发时间。

另一方面,垃圾收集也有一些缺点。在垃圾收集过程中,程序应该暂停并花时间确定它需要清理的内容,然后再继续进行。

此外,自动内存管理有更高的内存需求。这是由于垃圾收集器为我们执行内存去分配的事实,它同时消耗内存和CPU周期。因此,自动内存管理可能会降低应用性能,特别是在资源有限的大型应用中。

关键的启示是:

  • 消除了开发人员手动释放内存的需要。
  • 提供高效的内存安全,没有悬空引用或内存泄漏。
  • 更简单明了的代码。
  • 更快的开发周期。
  • 对内存管理的控制较少。
  • 导致延迟,因为它同时消耗内存和CPU周期。

Rust是如何保证内存安全的?

有些语言提供了垃圾收集,在程序运行时寻找不再使用的内存;有些语言则要求程序员明确分配和释放内存。这两种模式都有好处和坏处。垃圾收集,尽管可能是最广泛使用的,但也有一些缺点;它以牺牲资源和性能为代价,使开发者的生活变得简单。

话虽如此,但一个是给出了有效的内存管理控制,而另一个是通过消除悬空引用和内存泄漏来提供更高的安全性。Rust结合了两个世界的好处。

Figure 2: Rust has better control over memory management and provide higher safety with no memory issues.

Rust采取了与其他两种不同的方法,基于所有权 模型,有一套编译器验证的规则,以确保内存安全。如果违反了这些规则中的任何一条,程序就不会被编译。事实上,所有权用编译时对内存安全的检查取代了运行时的垃圾收集。

Explicit memory management vs. Implicit memory management vs. Rust’s ownership model.

适应所有权需要一些时间,因为它对许多程序员来说是一个新概念,比如我自己。

在这一点上,我们对数据如何存储在内存中有了基本的了解。让我们更仔细地看一下Rust中的所有权。Rust最大的特点是所有权,它确保了编译时的内存安全。

首先,让我们从字面意义上定义 "所有权"。所有权是指 "拥有 "和 "控制""某物 "的合法占有状态。说到这里,我们必须确定谁是所有者所有者拥有和控制什么。在Rust中,每个值都有一个叫做所有者的变量。简单地说,一个变量就是一个所有者,而一个变量的值就是所有者所拥有和控制的东西。

Figure 3: Variable binding shows the owner and its value/resource.

在所有权模型中,一旦拥有内存的变量超出了范围,内存就会被自动释放(freed)。当值超出范围或它们的生命周期因其他原因而结束时,它们的析构器会被调用。一个析构器,特别是一个自动析构器,是一个通过删除引用和释放内存来从程序中删除一个值的痕迹的函数。

借贷检查器

Rust通过静态分析器--借用检查器来实现所有权。借款检查器是Rust编译器中的一个组件,它跟踪整个程序中数据的使用情况,通过遵循所有权规则,它能够确定哪里需要释放数据。此外,借贷检查器确保在运行时永远不能访问已分配的内存。它甚至消除了由并发突变(修改)引起的数据竞赛的可能性。

所有权规则

如前所述,所有权模型是建立在一组被称为所有权规则的规则之上的,这些规则相对简单明了。Rust编译器(rustc)执行这些规则:

  • 在Rust中,每个值都有一个叫做所有者的变量。
  • 每次只能有一个所有者。
  • 当所有者超出范围时,该值将被丢弃。

以下内存错误受到这些编译时检查所有权规则的保护:

  • 悬空的引用。这是指一个引用指向一个不再包含指针所指向的数据的内存地址;这个指针指向空或随机数据。
  • 释放后使用。 这是内存被释放后被访问的地方,这可能会导致崩溃。这个内存位置也可以被黑客用来执行代码。
  • 双重释放。 这是指分配的内存被释放,然后又被释放。这可能会导致程序崩溃,可能会暴露敏感信息。这也使得黑客可以运行他们选择的任何代码。
  • 分段故障。这是指程序试图访问不允许访问的内存。
  • 缓冲区超限。 这是指数据量超过了内存缓冲区的存储容量,导致程序崩溃。

在讨论每个所有权规则的细节之前,了解复制移动克隆之间的区别很重要。

拷贝

一个有固定大小的类型(特别是原始类型)可以存储在堆栈中,并在其作用域结束时弹出,如果代码的另一部分需要在不同的作用域中使用相同的值,可以快速而容易地复制,以创建一个新的独立变量。因为复制堆栈内存是廉价和快速的,所以具有固定大小的原始类型被称为具有复制语义。它廉价地创建了一个完美的复制品(一个副本):

值得注意的是,具有固定大小的原始类型实现了copy 特质来进行复制。

let x = "hello";
let y = x;
println!("{}", x) // hello
println!("{}", y) // hello

在Rust中,有两种字符串:String (堆分配,可增长)和&str (固定大小,不能变异)。

因为x 是存储在堆栈中的,所以复制它的值来产生另一个副本给y 是比较容易的。而存储在上的值就不是这样了。这就是堆栈框架的样子:

Figure 4: Both x and y have their own data.

复制数据会增加程序运行时间和内存消耗。因此,复制并不适合于大块的数据。

移动

在Rust的术语中,"移动 "意味着内存的所有权被转移给另一个所有者。考虑一下存储在堆上的复杂类型的情况。

let s1 = String::from("hello");
let s2 = s1;

我们可能会认为第二行(即let s2 = s1; )会对s1 中的值进行复制,并将其绑定到s2 。但事实并非如此。

看一下下面这句话,看看String ,在这句话中发生了什么。一个字符串是由三个部分组成的,它们被存储在堆栈中。实际的内容(本例中为hello)存储在中:

  • 指针- 指向存放字符串内容的内存。
  • 长度--它是String 的内容目前使用的内存,单位是字节。
  • 容量--它是String 从分配器中得到的内存总量,单位是字节。

换句话说,元数据被保存在堆栈中,而实际数据被保存在堆中。

Figure 5: The stack holds the metadata while the heap holds the actual contents.

当我们把s1 赋值给s2 ,String元数据被复制,这意味着我们复制了堆栈中的指针、长度和容量。我们并没有复制指针所指向的堆上的数据。内存中的数据表示法看起来像下面这个:

Figure 6: Variable s2 gets a copy of the pointer, length, and capacity of s1.

值得注意的是,这个表示法 并不像下面这样,如果Rust也复制了堆上的数据,内存就会变成这样。如果Rust这样做,如果堆数据很大的话,s2 = s1 操作在运行时性能上可能会非常慢。

Figure 7: If Rust copied the heap data, another possibility for what let s2 = s1 might do is data replication. However, Rust does not copy by default.

请注意,当复杂类型不再处于范围内时,Rust会调用drop 函数来显式地去分配堆内存。然而,图6中的两个数据指针都指向同一个位置,这并不是Rust的工作方式。我们很快就会了解到这个细节。

如前所述,当我们把s1 赋值给s2 ,变量s2 收到s1 的元数据(指针、长度和容量)的副本。但是,一旦s1 被分配给s2 ,它会发生什么?Rust不再认为s1 是有效的。是的,你没看错。

让我们思考一下这个let s2 = s1 的赋值。考虑一下如果Rust在这次赋值后仍然认为s1 是有效的会发生什么。当s2s1 超出范围时,它们都会试图释放相同的内存。啊哦,这可不好。这被称为双重释放错误,它是内存安全错误之一。两次释放内存会导致内存损坏,带来安全风险。

为了保证内存安全,Rust认为在let s2 = s1 这一行之后,s1 是无效的。因此,当s1 不再在范围内时,Rust不需要释放任何东西。考察一下,如果我们试图在s2 被创建后使用s1 ,会发生什么。

let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);  // Won't compile. We'll get an error.

我们会得到一个像下面这样的错误,因为Rust阻止你使用无效的引用。

$ cargo run
   Compiling playground v0.0.1 (/playground)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:6:28
  |
3 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
4 |     let s2 = s1;
  |              -- value moved here
5 | 
6 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.

由于Rust在let s2 = s1 一行之后将s1 '的内存所有权 "转移 "到了s2,所以它认为s1 是无效的。下面是s1被废止后的内存表示:

Figure 8: Memory representation after s1 has been invalidated.

当只有s2 仍然有效时,当它超出范围时,它将独自释放内存。因此,在Rust中消除了发生双重自由错误的可能性。这真是太好了!

克隆

如果我们确实想深入复制String ,而不仅仅是堆栈数据,我们可以使用一个叫做clone 的方法。下面是一个如何使用clone方法的例子。

let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);

当使用clone方法时,堆数据确实被复制到s2中。这可以完美地工作并产生以下行为:

Figure 9: When using the clone method, the heap data does get copied into s2.

使用clone方法有严重的后果;它不仅复制了数据,而且也没有同步两者之间的任何变化。一般来说,克隆应该被仔细规划,并充分认识到后果。

到现在,我们应该能够区分复制、移动和克隆了。现在让我们更详细地看看每个所有权规则。

所有权规则1

每个值都有一个叫做所有者的变量。它意味着所有的值都被变量所拥有。在下面的例子中,变量s 拥有指向我们的字符串的指针,而在第二行中,变量x 拥有一个值1。

let s = String::from("Rule 1");
let n = 1;

所有权规则2

在一个特定的时间内,一个值只能有一个主人。一个人可以有很多宠物,但是当涉及到所有权模式时,在任何时候都只有一个值 :-)

Designed by Freepik

让我们看看使用基元的例子,这些基元在编译时是固定大小的:

let x = 10;
let y = x;
let z = x;

我们已经取了 10,并将其分配给x ;换句话说,x 拥有 10。然后我们把x 赋给了y ,我们也把它赋给了z 。我们知道在一个特定的时间只能有一个所有者,但是我们在这里没有得到任何错误。所以这里发生的事情是,编译器在我们每次把x 赋值给一个新的变量时都会复制它。

这方面的堆栈框架将如下。x = 10,y = 10z = 10 。然而,情况似乎不是这样的。x = 10,y = x, 和z = x 。我们知道,x 是这个值10的唯一拥有者,而yz 都不能拥有这个值。

Figure 10: Compiler made copies of x to both y and z.

因为复制堆栈内存既便宜又快速,具有固定大小的原始类型被称为具有复制语义,而复杂类型则是移动所有权,如前所述。因此,在这种情况下,编译器会进行复制

在这一点上,*变量绑定*的行为与其他编程语言的行为类似。为了说明所有权的规则,我们需要一个复杂的数据类型。

让我们看看存储在堆上的数据,看看Rust如何理解何时清理它;String类型是这个用例的一个很好的例子。我们将重点关注String的所有权相关行为;然而,这些原则也适用于其他复杂数据类型。

正如我们所知,复杂类型在堆上管理数据,其内容在编译时是未知的。让我们看一下我们之前看到的同一个例子:

let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);  // Won't compile. We'll get an error.

String 类型的情况下,大小可能会扩大并存储在堆上。这意味着:;

  • 在运行时,必须向内存分配器请求内存(让我们称之为第一部分)。
  • 当我们使用完我们的String ,我们需要将这个内存返回(释放)到分配器(让我们称之为第二部分)。

我们(开发者)照顾到了第一部分:当我们调用String::from ,它的实现请求它所需要的内存。这一部分在各种编程语言中几乎是通用的。

然而,第二部分是不同的。在有垃圾收集器(GC)的语言中,GC会跟踪并清理不再使用的内存,而我们不必担心这个问题。在没有垃圾收集器的语言中,我们的责任是确定何时不再需要内存,并要求明确地释放它。要正确地做到这一点,一直是一项具有挑战性的编程任务:

  • 如果我们忘记了,我们就会浪费内存。
  • 如果我们做得太早,我们会有一个无效的变量。
  • 如果我们做了两次,就会出现错误。

Rust以一种新颖的方式处理内存去分配,使我们的生活更轻松:一旦拥有内存的变量超出了范围,内存就会自动返回。

让我们回到正题。在Rust中,对于复杂类型,像给变量赋值、将其传递给函数或从函数返回这样的操作并不是复制值:而是移动它。简单地说,复杂类型移动所有权。

当复杂类型不再在范围内时,Rust会调用drop 函数来显式地取消内存。

所有权规则3

当所有者超出范围时,该值将被丢弃。再考虑一下前面的情况。

当复杂类型不再在范围内时,Rust将调用drop函数来显式地去分配堆内存。

let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);  // Won't compile. The value of s1 has already been dropped.

s1 赋值给s2 (在let s2 = s1 赋值语句中)后,s1 的值已经下降。因此,s1 在这次赋值后不再有效。下面是s1被删除后的内存表示。

Figure 11: Memory representation after s1 has been dropped.

所有权如何转移

在Rust程序中,有三种方式可以将所有权从一个变量转移到另一个变量。

  1. 将一个变量的值赋给另一个变量(已经讨论过了)。
  2. 将值传递给一个函数。
  3. 从一个函数中返回。

向函数传值

向函数传值的语义类似于向变量赋值。就像赋值一样,将一个变量传递给一个函数会使它移动或复制。看看这个例子,它同时展示了复制移动的使用情况。

fn main() {
    let s = String::from("hello");  // s comes into scope

    move_ownership(s);              // s's value moves into the function...
                                    // so it's no longer valid from this 
																		// point forward

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // x would move into the function
                                    // It follows copy semantics since it's 
																		// primitive, so we use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
  // special happens.


fn move_ownership(some_string: String) { // some_string comes into scope
    println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. 
  // The occupied memory is freed.


fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.

如果我们试图在调用move_ownership 之后使用s ,Rust会抛出一个编译时错误。

从一个函数返回

返回值也可以转移所有权。下面的例子显示了一个返回值的函数,其注释与前面的例子相同。

fn main() {
    let s1 = gives_ownership();         // gives_ownership moves its return
                                        // value into s1

    let s2 = String::from("hello");     // s2 comes into scope

    let s3 = takes_and_gives_back(s2);  // s2 is moved into
                                        // takes_and_gives_back, which also
                                        // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.


fn gives_ownership() -> String {             // gives_ownership will move its
                                             // return value into the function
                                             // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                              // some_string is returned and
                                             // moves out to the calling
                                             // function
}


// This function takes a String and returns it
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
                                                      // scope

    a_string  // a_string is returned and moves out to the calling function
}

变量的所有权总是遵循相同的模式:当一个值被分配给另一个变量时,它就被转移了。除非数据的所有权被转移到另一个变量上,否则当一个包含堆上数据的变量超出范围时,该值将被drop

希望这能让我们对什么是所有权模型有一个基本的了解,以及它是如何影响Rust处理数值的方式的,比如把它们互相赋值以及把它们传入和传出函数。

等一下。还有一件事...

Rust的所有权模型,就像所有好东西一样,确实有某些缺点。一旦我们开始在Rust上工作,我们很快就会意识到某些不便之处。我们可能已经观察到,在每个函数中获取所有权然后返回所有权是有点不方便的。

Designed by Freepik

如果我们想再次使用一个函数,除了该函数返回的任何其他数据外,我们传入该函数的所有东西都必须返回,这很令人讨厌。如果我们想让一个函数使用一个值而不取得它的所有权呢?

考虑一下下面的例子。下面的代码将导致一个错误,因为一旦所有权转移到print_vector ,变量,v 就不能再被最初拥有它的main 函数(在println! )使用。

fn main() {
   let v = vec![10,20,30];
   print_vector(v);
   println!("{}", v[0]); // this line gives us an error
}

fn print_vector(x: Vec<i32>) {
   println!("Inside print_vector function {:?}",x);
}

追踪所有权似乎很容易,但当我们开始处理大型复杂程序时,它就会变得很复杂。因此,我们需要一种方法,在不转移所有权的情况下转移价值,这就是借用的概念发挥作用的地方。

借贷

借用,从字面上看,是指接受某样东西并承诺将其归还。在Rust的背景下,借贷是一种获取价值的方式,而不要求对它的所有权,因为它必须在某个时候归还其所有者。

Designed by Freepik

当我们借用一个值时,我们用& 操作符引用它的内存地址。一个& 被称为一个引用。引用本身并没有什么特别之处--在引擎盖下,它们只是地址而已。对于那些熟悉C语言指针的人来说,引用是一个指向内存的指针,它包含一个属于另一个变量的值(又称所有权)。值得注意的是,在Rust中,引用不能是空的。事实上,引用就是一个指针;它是指针的最基本类型。在大多数语言中只有一种类型的指针,但Rust有不同种类的指针,而不是只有一种。指针和它们的各种类型是一个不同的话题,将被单独讨论。

简单地说,Rust把创建某个值的引用称为借入值,最终必须返回给它的主人。

让我们看一下下面的一个简单的例子:

let x = 5;
let y = &x;

println!("Value y={}", y);
println!("Address of y={:p}", y);
println!("Deref of y={}", *y);

以上产生的输出结果如下:

Value y=5
Address of y=0x7fff6c0f131c
Deref of y=5

在这里,y 变量借用了变量x拥有的数字,而x 仍然拥有该值。我们把y 称作对x 的引用。当y 超出范围时,借用就结束了,由于y 并不拥有这个值,所以它不会被销毁。要借用一个值,通过& 操作符获取一个引用。p格式,{:p} 输出为十六进制的内存位置。

解除引用: 在上面的代码中,"*"(即星号)是一个解除引用的操作符,它对引用变量进行操作。这种解除引用操作符允许我们获得存储在指针的内存地址中的值。

让我们来看看一个函数如何在不通过借用获得所有权的情况下使用一个值。

fn main() {
   let v = vec![10,20,30];
   print_vector(&v);
   println!("{}", v[0]); // can access v here as references can't move the value
}

fn print_vector(x: &Vec<i32>) {
   println!("Inside print_vector function {:?}", x);
}

我们将一个引用(&v)(又称逐一传递)传递给print_vector 函数,而不是转移所有权(即逐一传递值)。因此,在主函数中调用print_vector 函数后,我们可以访问v

用解除引用操作符跟踪指向值的指针

如前所述,引用是一种指针,而指针可以被认为是指向存储在其他地方的值的箭头。考虑一下下面的例子。

let x = 5;
let y = &x;

assert_eq!(5, x);
assert_eq!(5, *y);

在上面的代码中,我们为一个i32 类型的值创建了一个引用,然后使用解除引用操作符来跟随数据的引用。变量x 持有一个i32 类型的值,5 。我们将y ,等于对x 的引用。

这就是堆栈内存的出现方式:

This is how the stack memory appears:

我们可以断言x 等于5 。然而,如果我们想对y 中的值进行断言,我们必须使用*y 来跟踪它所指向的值的引用(因此在这里解除引用)。一旦我们解除对y 的引用,我们就可以获得y 所指向的整数值,我们可以将其与5 进行比较。

如果我们试图写assert_eq!(5, y); ,我们会得到这个编译错误:

error[E0277]: can't compare `{integer}` with `&{integer}`
  --> src/main.rs:11:5
   |
11 |     assert_eq!(5, y);
   |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`

因为它们是不同的类型,比较一个数字和一个数字的引用是不允许的。因此,我们必须使用解除引用操作符来跟随引用到它所指向的值。

引用在默认情况下是不可变的

和变量一样,引用在默认情况下是不可变的--它可以通过mut ,但前提是它的所有者也是可变的:

let mut x = 5;
let y = &mut x;

不可变的引用也被称为共享引用,而可变的引用也被称为独占引用

考虑一下下面的情况。由于我们使用的是& 操作符而不是&mut ,所以我们授予了对引用的只读访问权。即使源n 是可变的,ref_to_n ,和another_ref_to_n 也不是,因为它们是只读的n个借入。

let mut n = 10;
let ref_to_n = &n;
let another_ref_to_n = &n;

借阅检查器会给出下面的错误:

error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
 --> src/main.rs:4:9
  |
3 | let x = 5;
  |     - help: consider changing this to be mutable: `mut x`
4 | let y = &mut x;
  |         ^^^^^^ cannot borrow as mutable

借款规则

有人会问,为什么借贷不总是比移动更受欢迎。如果是这样的话,为什么Rust甚至有移动语义,而且为什么它不默认借用?原因是,在Rust中借用一个值并不总是可能的。借用只在某些情况下被允许。

借用有自己的一套规则,借用检查器在编译时严格执行这些规则。这些规则是为了防止数据竞赛而制定的。它们如下:

  1. 借用者的范围不能超过原所有者的范围。
  2. 可以有多个不可变的引用,但只有一个可变的引用。
  3. 所有者可以有不可变的或可变的引用,但不能同时有这两种引用。
  4. 所有的引用必须是有效的(不能是空的)。

引用必须不超过所有者的寿命

一个引用的范围必须包含在值的所有者的范围内。否则,引用可能会指向一个被释放的值,从而导致一个使用后的错误。

let x;
{ 
    let y = 0;
    x = &y;
}
println!("{}", x);

上面的程序试图在所有者y 超出范围后解除引用x 。Rust可以防止这种 "使用后"错误。

许多不可变的引用,但只允许一个可变的引用

我们可以在同一时间对某一特定数据有尽可能多的不可变引用(又称共享引用),但在同一时间只允许一个可变引用(又称独占引用)。这个规则的存在是为了消除数据竞赛。当两个引用同时指向同一个内存位置时,其中至少有一个在写,而它们的动作并同步,这就是所谓的数据竞赛。

我们可以有任意多的不可变的引用,因为它们不会改变数据。而借用则限制我们每次只保留一个可变引用(&mut),以防止在编译时出现数据竞赛的可能性。

让我们来看看这个:

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{}, {}", r1, r2);
}

上面的代码试图为s 创建两个易变的引用 (r1r2) 将会失败。

error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:6:14
  |
5 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
6 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
7 | 
8 |     println!("{}, {}", r1, r2);
  |                        -- first borrow later used here

结束语

希望这能阐明所有权和借用的概念。我还简要地谈到了借贷检查器,它是所有权和借贷的骨干。正如我在一开始提到的,所有权是一个新颖的概念,一开始可能很难理解,即使是经验丰富的开发者,但越是这样,就越容易理解。这只是对Rust中如何执行内存安全的一个梳理。我试图让这篇文章尽可能的简单易懂,同时提供足够的信息来掌握这些概念。关于Rust的所有权特性的更多细节,请查看他们的在线文档

当性能重要时,Rust是一个很好的选择,它解决了困扰许多其他语言的痛点,从而在学习曲线很陡峭的情况下取得了重大进展。Rust连续六年成为Stack Overflow最受欢迎的语言,这意味着许多有机会使用它的人都爱上了它。Rust社区在继续发展。

根据Rust调查2021年的结果。2021年无疑是Rust历史上最重要的年份之一。它见证了Rust基金会的成立,2021年的版本,以及比以往更大的社区。在我们走向未来的时候,Rust似乎正走在一条强劲的道路上。

祝你学习愉快!