走进Rust:所有权

Rust大约 9136 字

所有权

Rust的主要功能是所有权。尽管该功能易于解释,但对其余语言有深远的影响。

所有程序必须在运行时管理它们使用的计算机内存。某些语言具有垃圾回收功能,该垃圾回收功能会在程序运行时不断寻找不再使用的内存;在其他语言中,程序员必须显式分配和释放内存。Rust使用第三种方法:通过所有权系统管理内存,该系统具有一组在编译时检查的规则。所有所有权功能都不会减慢程序在运行时的速度。

因为所有权是许多程序员的新概念,所以它确实需要一些时间来习惯。好消息是,你对Rust和所有权系统规则的了解越丰富,自然就能开发出安全高效的代码。

理解所有权后,你将具有坚实的基础去理解为什么所有权使Rust独树一帜。在本章中,你将通过关注一些非常常见的数据结构的示例(字符串)来学习所有权。

栈和堆

在许多编程语言中,你不必经常考虑栈和堆。但是在像Rust这样的系统编程语言中,值是在栈上还是在堆上对语言的行为以及为什么必须做出某些决定具有更大的影响。所有权的各个部分将在本章后面的栈和堆中进行介绍,因此这里是准备工作的简要说明。

栈和堆都是内存的一部分,它们都可以在你的代码运行时被使用,但它们的结构不同。栈按获取值的顺序存储值,并以相反的顺序删除值。这称为后进先出(先进后出)。想象一下有一碟盘子:当你添加更多的盘子时,将它们放在这碟盘子的顶部,而当你需要一个盘子时,则从顶部取下一个。从中间或底部添加或取出盘子都无法正常工作!添加数据称为压栈,删除数据称为弹栈。

堆中存储的所有数据必须具有已知的固定大小。编译时大小未知或大小可能更改的数据必须存储在堆中。堆的组织性较差:将数据放在堆上时,你需要一定数量的空间。操作系统在堆中找到一个足够大的空白点,将其标记为正在使用中,然后返回一个指针,该指针是该位置的地址。此过程称为在堆上分配,有时也简称为分配。将值压入栈中不视为分配。由于指针是已知的固定大小,因此可以将指针存储在栈上,但是当需要实际数据时,必须追踪指针。

想象一下坐在餐厅里。当你进入餐厅时,你告诉工作人员一共几个人,然后工作人员会找到一个满足人数的空桌子,并带你们到那里。如果你们中的某个人迟到了,他可以询问你们在哪里坐,然后来找到你们。

压栈比在堆上分配要快,这是因为操作系统无需搜索存储新数据的位置;该位置始终位于堆栈的顶部。相比之下,在堆上分配空间需要更多的工作,因为操作系统必须首先找到足够大的空间来容纳数据,然后记录下来以准备下一次分配。访问堆中的数据比访问栈中的数据要慢,因为您必须追踪指针才能到达那里。如果当代处理器在内存中的跳跃较少,则它们(当代处理器)将更快。继续类推,假设一家餐厅的一个服务员需要为许多桌子下单。最有效的做好是一张桌子上的人都下完单了,再去下一张桌子。如果桌子A下了一部分单,又转去桌子B下一部分单,在去A下另一部分单,这将是一个非常慢的过程。同样,如果处理器可以处理与其他数据接近(如栈中)而不是更远(如堆上)的数据,则可以更好地完成工作。在堆上分配大量空间也可能需要时间。

当代码中调用函数时,传递给函数的值(可能包括指向堆上数据的指针)和函数的局部变量将被压入栈中。函数结束后,这些值将从栈中弹出。

所有权要解决的所有问题都是:跟踪哪些代码正在使用堆上的哪些数据,最大程度地减少堆上的重复数据的数量以及清理堆上的未使用数据,以免耗尽空间。理解所有权后,你就不需要经常考虑栈和堆了,但是知道管理堆数据是所有权存在的原因,可以帮助你解释其工作原理。

所有权规则

首先,让我们看一下所有权规则。 在通过示例进行说明时,请牢记以下规则:

  • Rust中的每个值都有一个变量,称这个变量为这个值的所有者。
  • 一次只能有一个所有者。
  • 当所有者超出范围时,该值将被删除。

变量作用域

作为所有权的第一个示例,我们将研究一些变量的作用域。作用域是程序中项目有效的范围。假设我们有一个看起来像这样的变量:

fn main() {
    let s = "hello";
}

变量s表示字符串文字,其中字符串的值被硬编码到程序的文本中。该变量从声明那一刻开始到当前作用域结束为止一直有效。如下示例带有注释,说明了变量s在何处有效。

fn main() {             // s 在此处无效,它还没有被声明
    let s = "hello";    // 从现在开始,s是有效的

    // 用 s 做一些事情
}                       // 此作用域现已结束,并且s不再有效

换句话说,这里有两个重要的时间点:

  • s进入作用域时,它是有效的。
  • 它保持有效,直到超出作用域。

此时,作用域和变量有效的关系类似于其他编程语言。我们在此基础上介绍String类型。

String类型

先前介绍的类型都存储在栈中,并且当它们的作用域结束时会弹出栈,但是我们要查看存储在堆中的数据,并探索Rust如何知道何时清理该数据。

我们将以String为例,重点介绍与所有权相关的String部分。这些方面也适用于其他复杂数据类型,无论它们是由标准库提供还是由你创建。

我们已经看到了字符串文字,其中字符串的值被硬编码到我们的程序中。字符串文字很方便,但并不适合我们可能要使用文字的所有情况。原因之一是它们是不可变的。另一个是在编写代码时并不是每个字符串的值都是已知的:例如,如果我们想接受用户输入并存储它,该怎么办?对于这些情况,Rust具有第二个字符串类型String。这种类型分配在堆上,因此能够存储在编译时我们不知道的大量文本。你可以使用from函数从字符串文字创建字符串,如下所示:

let s = String::from("hello");

双冒号(::)是一个运算符,它使我们可以在String类型下的函数中对特定的from函数进行命名空间,而不是使用诸如string_from之类的名称。我们将在第5章的“方法语法”部分以及第7章的“在模块树中引用项目的路径”中讨论模块的命名空间时,将对这种语法进行更多讨论。

这种字符串可以被改变:

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

s.push_str(", world!"); // push_str() 字符串后追加文字

println!("{}", s); // 将打印 `hello, world!`

所以,这里有什么区别? 为什么可以对String进行改变而对文字不能进行改变?区别在于这两种类型如何处理内存。

内存和分配

对于字符串文字,我们在编译时就知道了内容,因此文本直接硬编码到最终的可执行文件中。这就是为什么字符串文字快速高效的原因。但是这些属性仅来自字符串文字的不变性。不幸的是,对于在编译时未知大小且在运行程序时大小可能会改变的每段文本,我们无法在二进制文件中添加大量的内存。

对于String类型,为了支持可变的,可增长的文本,我们需要在堆上分配编译时未知的内存量来容纳内容。这意味着:

  • 必须在运行时从操作系统请求内存。
  • 在完成String操作后,我们需要一种将该内存返回给操作系统的方法。

第一部分由我们完成:当我们调用String::from时,它的实现方法会请求所需的内存。这在编程语言中几乎是通用的。

然后,第二部门不同。在具有垃圾收集器(GC)的语言中,GC会进行跟踪并清理不再使用的内存,因此我们无需考虑它。如果没有GC,我们有责任确定何时不再使用内存,并调用代码以显式回收内存,就像我们请求内存一样。从历史上看,正确执行此操作一直是编程难题。如果我们忘记回收,我们将浪费内存。如果我们回收得太早,我们将有得到一个无效变量。如果我们重复两次,那也是一个错误。我们需要allocate(分配)和free(释放)成对出现。

Rust采取了另一条路径:拥有内存的变量超出范围后,内存将自动回收。以下是使用String代替字符串文字的一个示例

fn main() {
    let s = String::from("hello");  // s 从此刻开始有效

    // 使用 s 做一些事
}                                   // 作用域结束了,s 将不再有效

我们可以很自然地将String需要的内存交还给操作系统:当s不在作用域中时。当变量超出作用域时,Rust为我们调用一个特殊函数。此函数称为drop,程序员可以在这个drop函数中放置代码来回收内存。Rust在右大括号处自动调用drop函数。

注意:在C++中,这种在生命周期结束时释放资源的模式有时称为“资源获取即初始化(RAII)”。如果你使用过RAII模式,Rust中的drop函数将为你所熟悉。

这种模式对Rust代码的编写方式产生了深远的影响。现在看似很简单,但是在更复杂的情况下,当我们想让多个变量使用我们在堆上分配的数据时,代码的行为可能出乎意料。

变量与数据交互的方式:移动

多个变量可以在Rust中以不同的方式与同一数据交互。如下示例

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

我们可能会猜到它在做什么:“将值5绑定到x;然后在x中复制值并将其绑定到y。” 现在,我们有两个变量xy,它们都等于5。确实是这样,因为整数是具有已知固定大小的简单值,并且这5个值被压入栈中。

让我们看下String版本的例子:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

这看起来与先前的代码非常相似,因此我们可以假设它的工作方式是相同的:也就是说,第二行将在s1中复制值并将其绑定到s2。但这不是完全会发生的事情。

看下图,看看String背后到底发生了什么。一个String由三部分组成,如左图所示:指向内存的指针(用于保存字符串的内容),长度和容量。这组数据(左图上的三个变量)存储在栈中。右边是保存字符串内容的堆上内存。

trpl0401.png

长度是String当前正在使用的内存量(以字节为单位)。容量是String从操作系统接收的内存总量(以字节为单位)。长度和容量之间的差异很重要,但在这种情况下并不重要,因此,暂时可以忽略容量。

s1分配给s2时,将复制String数据,这意味着我们将复制栈上的指针,长度和容量。我们不会在指针所指向的堆上复制数据。换句话说,内存中的数据表示如下图所示。

trpl0402.png

该表示形式看起来不像下图,如果Rust代替复制了堆数据,那么存储器的样子也如下图所示。如果Rust这样做,那么如果堆上的数据很大,则操作s2 = s1就运行时性能而言可能会非常昂贵。

trpl0403.png

前面我们说过,当变量超出作用域时,Rust自动调用drop函数并清理该变量的堆内存。但是第二幅图显示了两个指针指向相同位置。这就有一个问题:当s2s1超出作用域时,它们都将尝试释放同一个内存(图右侧的堆上内存)。这被称为双重释放错误,是我们前面提到的内存安全错误之一。释放内存两次可能导致内存损坏,从而可能导致安全漏洞。

为了确保内存安全,在Rust中还有一个更详细的细节。Rust不会尝试复制已经分配的内存,而是认为s1不再有效,因此,当s1超出作用域时,Rust不需要释放任何内容。检查创建s2之后尝试使用s1会发生什么情况:它将不再有效:

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

    println!("{}, world!", s1);
}

你会收到这样的错误消息,因为Rust阻止你使用无效的引用:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `std::string::String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 | 
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move

error: aborting due to previous error

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership`.

To learn more, run the command again with --verbose.

如果你在使用其他语言时听到过浅拷贝和深拷贝这两个术语,那么复制指针,长度和容量而不复制数据的概念听起来像是进行浅拷贝。但是由于Rust也使第一个变量无效,所以不应叫浅拷贝,而是称为move(移动)。在此示例中,我们说s1已移入(move into)到s2。因此实际发生的情况如下图所示。

trpl0404.png

这就解决了我们的问题!只有s2有效,当它超出作用域时,仅凭它就可以释放内存,我们就完成了。

此外,这暗示了一种设计选择:Rust永远不会自动创建数据的“深层”拷贝。 因此,就运行时性能而言,任何自动复制都可以被认为是不昂贵的。

译者总结:移动(Move)可以认为是Rust中的浅拷贝,只复制指针指向的位置,而不拷贝堆上的实际内容。但Rust会时前一个变量失效,因为drop函数和不能多次释放同一个内存的设计。

变量与数据交互的方式:克隆

如果我们确实想对String的堆数据进行深拷贝,而不仅仅是栈上的数据,则可以使用一种称为clone的通用方法。我们将在第5章中讨论方法语法,但是由于方法是许多编程语言中的常用功能,因此你以前可能已经看过它们。

这是运行中的克隆方法的示例:

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

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

代码运行正常,并且显式地产生了第三幅图所示的行为,在该行为中确实复制了堆数据。

当您看到clone函数调用时,你知道正在执行某些任意代码,并且这些代码可能会非常消耗资源。这是一种视觉指示器,表明发生了一些不同的情况。

仅栈数据:复制

我们还没有谈论过另一个问题。这段使用整数的代码运行正常并且有效:部分代码如前文所示:

fn main() {
    let x = 5;
    let y = x;

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

但是这段代码似乎与我们刚刚学到的东西相矛盾:我们没有调用clone函数,但是x仍然有效,并且没有移入y

原因是在编译时具有已知大小的整数之类的类型完全存储在栈中,因此可以快速拷贝实际值。这意味着在创建变量y之后我们没有理由要阻止x生效。换句话说,这里的深拷贝和浅拷贝没有区别,因此调用clone函数与通常的浅拷贝没有什么不同,我们可以将其省略。

Rust有一个特殊的注解,称为Copy特性,我们可以将其放置在类型上,比如存储在栈上的integers类型。如果类型具有Copy特性,则分配后仍然可以使用旧的变量。如果该类型或其任何部分实现了Drop特性,Rust将不允许我们使用Copy特性对该类型进行注解。如果在值超出作用域时该类型需要特殊处理,并向该类型添加Copy注解,则会出现编译时错误。要了解如何将Copy注解添加到你的类型中,请参阅附录C中的“可导出特征”。

所以,什么样的类型是Copy?你可以查阅文档中给出的类型去确认,但是作为一般规则,任何一组简单的标量值都可以是Copy,和不需要分配或某种形式的资源可以是Copy。以下是一些Copy类型:

  • 所有的整数类型,例如:u32
  • 布尔类型,truefalse
  • 所有的浮点数类型,例如:f64
  • 字符类型,char
  • 仅包含可Copy类型的元组,例如:(i32, i32)是可以Copy的,但(i32, String)就不是。

所有权和函数

用于将值传递给函数的语句类似于用于将值分配给变量的语句。就像赋值一样,将变量传递给函数将移动或复制。下列代码给出了一个示例,其中带有一些注释,这些注释显示变量进入和退出作用域的位置。

fn main() {
    let s = String::from("hello");  // s 进入作用域

    takes_ownership(s);             // s 的值移入函数 takes_ownership 中
                                    // takes_ownership 函数在这一行开始就不再有效

    let x = 5;                      // x 进入作用域

    makes_copy(x);                  // x 移入函数中,
                                    // 但是 i32 是可`Copy`,所以再这一行之后还可以继续使用 x

} // 这一行,x 超出作用域,接着是 s。但因为 s 的值已经移动了,没有特殊处理了。

fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!("{}", some_string);
} // 这一行,some_string 超出作用域,接着`drop`函数被调用。备份内存被释放。

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
    println!("{}", some_integer);
} // 这一行,some_integer 超出作用域,不发生任何事情。

如果尝试在takes_ownership函数之后使用sRust将抛出编译时错误。这些静态检查可以防止我们出错。尝试将代码添加到使用sxmain函数中,以查看可以在哪里使用它们以及所有权规则阻止你这样做。

返回值和作用域

返回值也可以转移所有权。

fn main() {
    let s1 = gives_ownership();         // gives_ownership 的返回值移入 s1

    let s2 = String::from("hello");     // s2 进入作用域

    let s3 = takes_and_gives_back(s2);  // s2 移入 takes_and_gives_back 函数中,
                                        // 并且 takes_and_gives_back 的返回值移入 s3
} // 这一行,s3 超出作用域并被销毁。s2 超出作用域但是已经被移动了,不发生任何事。s1 超出作用域并被销毁。

fn gives_ownership() -> String {             // gives_ownership 将移动它的返回值到调用它的地方

    let some_string = String::from("hello"); // some_string 进入作用域

    some_string                              // some_string 被返回,并且移出到调用这个函数的地方
}

// takes_and_gives_back 函数接收一个 String 参数,并且返回一个 String
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域

    a_string  // a_string 被返回,并且移出到调用这个函数的地方
}

变量的所有权每次都遵循相同的模式:将值分配给另一个变量将其移动。当包含堆上数据的变量超出作用域时,将删除该值,除非该数据已移至另一个变量所有。

拥有所有权然后返回所有权的函数有点乏味。如果我们要让函数使用值而不是所有权怎么办?令人非常烦恼的是,除了我们可能还想返回的函数主体产生的任何数据之外,如果我们想再次使用它们(传入的参数),也需要将它们传递回去。

可以使用元组返回多个值,如下列代码:

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

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

对于一个通用的概念来说,这种做法太古板了,并且有大量的工作量。对我们来说幸运的是,Rust具有解决此痛点的功能,称为references(参考)。

阅读 112 · 发布于 2020-07-06

————        END        ————

扫描下方二维码关注公众号和小程序↓↓↓

昵称:
随便看看换一批