Rust——所有权

前言

所有权是 Rust 最独特的特性,对语言的其余部分有着深远的影响。它使 Rust 能够在不需要垃圾收集器的情况下保证内存安全,因此了解所有权的运作方式非常重要。在本章中,我们将讨论所有权以及几个相关功能:借用、切片以及 Rust 如何在内存中布局数据。

内容

什么是所有权

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

因为所有权对许多程序员来说是一个新概念,所以确实需要一些时间来适应。好消息是,您对 Rust 和所有权系统的规则越有经验,您就越容易发现它自然而然地开发出安全高效的代码。坚持下去!

当您了解所有权时,您将为理解使 Rust 与众不同的功能奠定坚实的基础。在本章中,您将通过一些示例来了解所有权,这些示例侧重于非常常见的数据结构:字符串。

栈(Stack)与堆(Heap)

许多编程语言不需要您经常考虑栈和堆。但是在像 Rust 这样的系统编程语言中,一个值是在栈上还是在堆上都会影响语言的行为方式以及您必须做出某些决定的原因。本章后面将介绍与栈和堆相关的部分所有权,因此这里有一个简短的解释。

栈和堆都是代码在运行时可供使用的内存,但是它们的结构不同。栈以放入值的顺序存储值并以相反顺序取出值。这也被称作 后进先出(last in, first out)。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做 进栈(pushing onto the stack),而移出数据叫做 出栈(popping off the stack)。

栈中的所有数据都必须占用已知且固定的大小。在编译时大小未知或大小可能变化的数据,要改为存储在堆上。堆是缺乏组织的:当向堆放入数据时,您要请求一定大小的空间。内存分配器(memory allocator)在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针(pointer)。这个过程称作 在堆上分配内存(allocating on the heap),有时简称为 “分配”(allocating)。将数据推入栈中并不被认为是分配。因为指针的大小是已知并且固定的,您可以将指针存储在栈上,不过当需要实际数据时,必须访问指针。

想象一下去餐馆就座吃饭。当进入时,您说明有几个人,餐馆员工会找到一个够大的空桌子并领您们过去。如果有人来迟了,他们也可以通过询问来找到您们坐在哪。

入栈比在堆上分配内存要快,因为(入栈时)分配器无需为存储新数据去搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为分配器必须首先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。

访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问。现代处理器在内存中跳转越少就越快(缓存)。继续类比,假设有一个服务员在餐厅里处理多个桌子的点菜。在一个桌子报完所有菜后再移动到下一个桌子是最有效率的。从桌子 A 听一个菜,接着桌子 B 听一个菜,然后再桌子 A,然后再桌子 B 这样的流程会更加缓慢。出于同样原因,处理器在处理的数据彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)能更好的工作。在堆上分配大量的空间也可能消耗时间。

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

跟踪代码的哪些部分正在使用堆上的哪些数据,最大程度地减少堆上的重复数据量,以及清理堆上未使用的数据,以免空间不足,这些都是所有权解决的问题。一旦您了解了所有权,您就不需要经常考虑栈和堆,但知道所有权的主要目的是管理堆数据可以帮助解释为什么它以这种方式工作。

所有权规则

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

  • Rust 中的每一个值都有一个被称为其 所有者owner)的变量。
  • 值在任一时刻有且只有一个所有者。
  • 当所有者(变量)离开作用域,这个值将被丢弃。

变量作用域

现在我们已经过了基本的 Rust 语法,我们不会在示例中包含所有 fn main() { 代码,所以如果您正在学习,请确保手动将以下示例放入 main 函数中。因此,我们的示例将更加简洁,让我们专注于实际细节而不是样板代码。

作为所有权的第一个示例,我们将查看一些变量的范围。作用域是程序中项目对其有效的范围。以以下变量为例:

let s = "hello";

该变量 s 引用字符串文本,其中字符串的值被硬编码到我们程序的文本中。该变量从声明该变量的点起一直有效,直到当前范围结束。

    {                      // s is not valid here, it’s not yet declared
        let s = "hello";   // s is valid from this point forward
        // do stuff with s
    }                      // this scope is now over, and s is no longer valid

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

  • s 进入作用域 时,它就是有效的。
  • 这一直持续到它 离开作用域 为止。

目前为止,变量是否有效与作用域的关系跟其他编程语言是类似的。现在我们在此基础上介绍 String 类型。

String 类型

为了演示所有权的规则,我们需要一个比第 中讲到的都要复杂的数据类型。前面介绍的类型都是已知大小的,可以存储在栈中,并且当离开作用域时被移出栈,如果代码的另一部分需要在不同的作用域中使用相同的值,可以快速简单地复制它们来创建一个新的独立实例。不过我们需要寻找一个存储在堆上的数据来探索 Rust 是如何知道该在何时清理数据的。

这里使用 String 作为例子,并专注于 String 与所有权相关的部分。这些方面也同样适用于标准库提供的或您自己创建的其他复杂数据类型。

我们已经见过字符串字面量,即被硬编码进程序里的字符串值。字符串字面量是很方便的,不过它们并不适合使用文本的每一种场景。原因之一就是它们是不可变的。另一个原因是并非所有字符串的值都能在编写代码时就知道:例如,要是想获取用户输入并存储该怎么办呢?为此,Rust 有第二个字符串类型,String。这个类型管理被分配到堆上的数据,所以能够存储在编译时未知大小的文本。可以使用 from 函数基于字符串字面量来创建 String,如下:

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

双冒号(::)运算符允许我们将特定的 from 函数置于 String 类型的命名空间(namespace)下,而不需要使用类似 string_from 这样的名字。

修改此类字符串:

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 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。

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

        // 使用 s
    }                                  // 此作用域已结束,
                                       // s 不再有效
}

我们可以将我们需要 String 的内存返回给分配器:当 s 离开作用域的时候 。当一个变量超出范围时,Rust 会为我们调用一个特殊的函数。这个函数被称为 drop ,在这里 String 的作者可以放置代码释放内存的代理。Rust 在结尾的 } 处自动调用 drop 。

注意:在 C++ 中,这种在项目生存期结束时解除分配资源的模式有时称为资源获取即初始化 (RAII)。如果您使用过 RAII 模式,您就会熟悉 Rust drop 中的函数。

变量和数据的交互:Move

在 Rust 中,多个变量可以以不同的方式与相同的数据进行交互。让我们使用整数来做个示例:

let x = 5;
let y = x;

们大致可以猜到这在干什么:“将 5 绑定到 x;接着生成一个值 x 的拷贝并绑定到 y”。现在有了两个变量,x 和 y,都等于 5。这确实是正在发生的事情,因为整数是具有已知固定大小的简单值,所以这两个 5 被放入了栈中。

现在让我们看一下 String 版本:

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

这看起来与上面的代码非常类似,所以我们可能会假设他们的运行方式也是类似的:也就是说,第二行可能会生成一个 s1 的拷贝并绑定到 s2 上。不过,事实上并不完全是这样。

String 由三部分组成,如图左侧所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。

长度表示 String 的内容当前使用了多少字节的内存。容量是 String 从分配器总共获取了多少字节的内存。长度与容量的区别是很重要的,不过在当前上下文中并不重要,所以现在可以忽略容量。

当我们将 s1 赋值给 s2,String 的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。换句话说,内存中数据的表现如图所示。

如果 Rust 也复制堆数据,则内存会是什么样子。如果 Rust 这样做,如果堆上的数据很大,则该操作 s2 = s1 在运行时性能方面会有很大的影响。

前面我们说过,当一个变量离开作用域时,Rust 会自动调用该 drop 函数并清理该变量的堆内存。但图 2 显示了指向同一位置的两个数据指针。这就会产生一个问题:当 s1 和 s2 都离开作用域的时候 ,它们都会尝试释放相同的内存。就会发生二次释放的错误,是我们之前提到的内存安全错误之一。释放两次相同内存可能会导致内存损坏,从而可能导致安全漏洞。

为了保证内存安全,在 let s2 = s1; 之后,Rust 认为 s1 不再有效。因此,当 s1 超出作用域的时候 ,Rust 不需要释放任何东西。现在让我们来看看在 s2 创建后, 尝试使用 s1 时会发生什么;

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

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

当我们尝试打印 s1 的时候,会得到一个编译错误,因为 Rust 禁止你使用无效的引用:

error[E0382]: borrow of moved value: `s1`
  --> src/main.rs:23:28
   |
20 |     let s1 = String::from("hello");
   |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
21 |     let s2 = s1;
   |              -- value moved here
22 |     // println!("{}, world!", s2);
23 |     println!("{}, world!", s1);
   |                            ^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
   |
21 |     let s2 = s1.clone();
   |                ++++++++

如果您在使用其他语言时听说过浅拷贝和深拷贝这两个术语,那么在不复制数据的情况下复制指针、长度和容量的概念可能听起来像是制作浅拷贝。但是,由于 Rust 也使第一个变量无效,因此它被称为移动,而不是称为浅拷贝。在此示例中,我们会说它 s1 已移至 s2 。因此,实际发生的情况如图所示:

这样就解决了我们的问题!因为只有 s2 是有效的,当其离开作用域,它就释放自己的内存,完毕。

另外,这里还隐含了一个设计选择:Rust 永远也不会自动创建数据的 “深拷贝”。因此,可以假定任何自动复制在运行时性能方面都是低成本的。

变量和数据的交互:Clone

这里还有一个没有提到的小窍门。这些代码使用了整型并且是有效的,他们是示例 2 中的一部分:

    let x = 5;
    let y = x;

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

但是这段代码似乎与我们刚刚学到的内容相矛盾:我们没有对 clone 的调用,但仍然 x 有效,并且没有被移入 y 。

原因是像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y 后使 x 无效。换句话说,这里没有深浅拷贝的区别,所以这里调用 clone 并不会与通常的浅拷贝有什么不同,我们可以不用管它。

Rust 有一个特殊的注解,称为 Copy trait,我们可以将其放在存储在堆栈上的类型上,就像整数一样(我们将在第 10 章中详细讨论 traits )。如果某个类型实现了该 Copy 特征,则使用该特征的变量不会移动,而是被简单复制,从而使它们在分配给另一个变量后仍然有效。

Rust 不允许自身或其任何部分实现了 Drop trait 的类型使用 Copy trait。如果我们对其值离开作用域时需要特殊处理的类型使用 Copy 标注,将会出现一个编译时错误。

那么哪些类型实现了 Copy trait 呢?你可以查看给定类型的文档来确认,不过作为一个通用的规则,任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:

  • 所有整数类型,例如 u32
  • 布尔类型 bool ,具有值 true 和 false
  • 所有浮点类型,例如 f64
  • 字符类型 char
  • 元组,如果它们只包含也实现 Copy 的类型。例如, (i32, i32) 实现 Copy ,但 (i32, String) 不实现。

所有权和函数

将值传递给函数的机制类似于将值赋给变量的机制。将变量传递给函数将移动或复制,就像赋值一样。下面有一个示例,其中包含一些注释,显示了变量进入和超出范围的位置。

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

  takes_ownership(s);             // s 的值移动到函数里 ...
                                  // ... 所以到这里不再有效

  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 后使用 s 时,Rust 会抛出一个编译时错误。这些静态检查使我们免于犯错。试试在 main 函数中添加使用 s 和 x 的代码来看看哪里能使用他们,以及所有权规则会在哪里阻止我们这么做。

返回值和作用域

返回值也可以转移所有权。下面的代码显示了一个返回某个值的函数示例,其注释与function中的注释类似。


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 中,
    // 它也将返回值移给 s3
} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
// 所以什么也不会发生。s1 移出作用域并被丢弃

fn gives_ownership() -> String {           // gives_ownership 将返回值移动给
    // 调用它的函数

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

    some_string                              // 返回 some_string 并移出给调用的函数
}

// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域

    a_string  // 返回 a_string 并移出给调用的函数
}

变量的所有权总是遵循相同的模式:将值赋给另一个变量时移动它。当持有堆中数据值的变量离开作用域时,其值将通过 drop 被清理掉,除非数据被移动为另一个变量所有。

在每一个函数中都获取所有权并接着返回所有权有些啰嗦。如果我们想要函数使用一个值但不获取所有权该怎么办呢?如果我们还要接着使用它的话,每次都传进去再返回来就有点烦人了,除此之外,我们也可能想返回函数体中产生的一些数据。

Rust 允许我们使用元组返回多个值,如下所示:

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

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

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

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

    (s, length)
}

但是,对于一个应该很常见的概念来说,这太形式主义了,而且工作量很大。幸运的是,Rust 有一个功能,可以在不转移所有权的情况下使用值,称为引用 (references)。

posted @ 2024-07-24 13:26  。思索  阅读(3)  评论(0编辑  收藏  举报