何谓所有权

What is Ownership?

所谓 所有权,ownership,是一套掌管着 Rust 程序如何管理内存的规则。所有程序在其运行期间,都必须管理他们使用计算机内存的方式。有些语言有着可在程序运行时,定期查找不再使用的内存的垃圾回收;而在其他语言中,程序员则必须明确分配和释放内存。Rust 使用了第三种方法:通过带有编译器会检查的一套规则的所有权系统,内存得以管理。如果违反了任何规则,程序将不会编译。在程序运行过程中,所有权的所有特性,都不会减慢程序的运行速度。

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

当咱们理解了所有权,就为理解 Rust 独特的功能,打下了坚实的基础。在本章中,咱们将通过一些以字符串,这种常见数据结构为重点的示例,来掌握所有权。

内存栈与堆

The Stack and the Heap

许多编程语言,都不要求咱们经常考虑堆栈和堆。但是,在 Rust 这样的系统编程语言中,某个值是在栈上,还是在堆上,会影响这门语言的行为方式,以及咱们必须做出某些决定的原因。本章稍后所有权的一些部分,就会与栈和堆结合讲解,因此在此要预先简要说明。

栈和堆都是内存的部分,供代码在运行时使用,但他们的结构方式不同。栈是以其获取到值的顺序存储值,并按照相反的顺序移除值。这就是所谓的 后进先出,last in, first out。请设想一摞盘子:当咱们添加更多盘子时,就会把他们放到这堆盘子的顶端;当咱们需要某个盘子时,就从顶端取下一个。从中间或底部添加或移除盘子的效果并不好!添加数据被称为 推入堆栈,pushing onto the stack,移除数据被称为 弹出栈,popping off the stack。栈上存储的所有数据,必须有已知、固定的大小。编译时大小未知,或大小可能改变的数据,必须存储在堆上。

堆的组织程度则较低:在咱们把数据放在堆上时,咱们要请求一定数量的空间。内存分配器会在堆中,找到足够大的空位,将其标记为在用,并返回一个 指针,pointer,即那个位置的地址。这个过程称为 在堆上分配,allocating on the heap,有时也简称为 分配,allocating(将值推入栈,则不被视为分配)。由于到堆的指针,属于已知、固定的大小,因此咱们可以将该指针,存储在栈上,而当咱们需要具体数据时,就必须跟随这个指针。请设想在餐厅等待安排座位的情景。当咱们进入某家餐厅时,咱们要说明咱们团体的人数,然后接待员会找到一张适合每个人的空桌,并把咱们领到那里。如果咱们团队中有人来晚了,他们可以询问,咱们的座位在哪里,然后找到咱们。

压入栈要比在堆上分配空间更快,因为分配器无需寻找存储新数据的位置;该位置总是在栈的顶部。相比之下,在堆上分配空间,则需要更多的工作,因为分配器必须首先找到一个足够大的空间来存放数据,然后进行簿记,为下一次分配做好准备。

访问堆中的数据,比访问栈上的数据要慢,因为咱们必须跟随指针才能到达那里。如果减少在内存中的跳转,那么现代处理器的速度就会更快。继续类比,请设想一下某个餐厅的服务员,从许多桌子上点菜的情况。最有效的方法是先处理一张桌子上的所有点餐,然后再处理下一张桌子上的点餐。从 A 桌点菜,然后从 B 桌点菜,然后再从 A 桌点菜,然后再从 B 桌点菜,这个过程就会慢得多。同样,如果处理器处理的数据,与其他数据距离较近(如栈上的数据),而不是较远(如堆中的数据),那么处理器就能更好地完成工作。

当咱们的代码调用某个函数时,传入函数的值(可能包括指向堆上数据的指针)和函数的局部变量,会被推入栈。函数结束后,这些值会从栈上弹出。

跟踪代码的哪些部分,正在使用堆上的哪些数据、尽量减少堆上的重复数据量,以及清理堆上未使用数据以免空间耗尽,这些都是所有权要解决的问题。一旦咱们掌握了所有权,咱们就不需要经常考虑栈和堆了,而清楚所有权的主要目的,是为管理堆数据这一点,有助于解释为什么他以这种方式工作。

所有权规则

Ownership Rules

首先,我们来看看这些所有权规则。在我们举例说明他们时,请牢记这些规则:

  • Rust 中的每个值,都有个 所有者,owner

  • 同一时间,只能有一个所有者;

  • 当所有者超出作用域时,该值将被丢弃。

变量作用域

Variable Scope

既然我们已经掌握了 Rust 的基本语法,我们就不会在示例中,包含所有 fn main() { 代码,所以如果咱们正在学习,请务必手动将下面的示例,放在某个 main 函数中。如此,我们的示例将更加简洁,让我们专注于具体细节,而不是样板代码了。

作为所有权的首个例子,我们来看看,一些变量的作用域。作用域是指某个项目在程序中,有效的范围。以下面的变量为例:

#![allow(unused)]
fn main() {
let s = "hello";
}

变量 s 指向某个字符串字面值,其中该字符串的值,被硬编码到我们程序的文本中。这个变量从其被声明时开始,直到当前作用域结束,都是有效的。下面清单 4-1 给出了一个带有说明这个变量 s 的于何处有效注释的程序。

#![allow(unused)]
fn main() {
{                       // 变量 s 在这里是无效的,他还没被声明出来
    let s = "hello";    // s 自此往下都是有效的

    // 对变量 s 执行一些操作
}                       // 此时该作用域结束,而变量 s 不再有效
}

清单 4-1:一个变量及其有效的作用域

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

  • s 作用域时,他便有效了;

  • 在超 作用域之前,他会一直有效。

此时,作用域和变量何时有效之间的关系,与其他编程语言中的类似。现在我们将通过引入 String 类型,在这种理解的基础上构建。

String 类型

The String Type

为了说明所有权规则,我们需要一种比第 3 章 数据类型 小节中,介绍的数据类型更复杂的一种数据类型。前面介绍的类型大小已知,可被存储在栈上,并在其作用域结束时,从栈上弹出,如果代码的另一部分,需要在不同的作用域中使用同一个值,他们就可以快速、简便地复制,以创建一个新的、独立的实例。但我们打算看看存储在堆上的数据,并探讨 Rust 如何知道,何时清理这些数据,而 String 类型就是个很好的例子。

我们将重点关注 String 中,与所有权相关的部分。这些方面也适用于其他复杂的数据类型,无论它们是由标准库提供,还是由咱们自己创建。我们将在 第 8 章 中,更深入地讨论 String

我们已经见过字符串的字面值,其中某个字符串值,被硬编码到咱们的程序中。字符串字面值很方便,但他们并不适合我们打算使用文本的所有情况。其中一个原因是,他们是不可变的。另一个原因是,在我们编写代码时,并非每个字符串值,都是已知的:例如,如果我们打算获取用户输入,并将其存储起来,该怎么办?针对这些情况,Rust 有着第二种字符串类型 -- String。该类型管理堆上分配的数据,因此可以存储编译时咱们未知数量的文本。咱们可以使用 from 函数,从某个字符串字面值创建出一个 String,如下所示:

#![allow(unused)]
fn main() {
let s = String::from("hello");
// 变量 s 的类型为:String, 而此前字面值中的变量 s 的类型为:&str
}

其中双冒号 :: 操作符,允许我们在 String 类型下,命名这个特殊的 from 函数,而不是使用诸如 string_from 的某种名字。我们将在第 5 章的 方法语法 小节,以及第 7 章的 用于引用模块树中某个项目的路径 小节,讨论模块命名空间时,进一步讨论这种语法。

这种字符串, 被改变:

#![allow(unused)]
fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() 方法会追加一个字面值,到某个 String

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

那么,这里有什么区别呢?为什么 String 可被改变,而字面值却不能呢?区别在于,这两种类型如何处理内存。

内存与内存分配

Memeory and Allocation

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

而在 String 类型下,为了支持某段可变、可增长的文本,我们需要在堆上,分配编译时未知的某个数量的内存,来保存内容。这意味着:

  • 必须在运行时,向内存分配器申请该内存;

  • 我们需要某种,当咱们是使用完咱们的 String 后,将此内存返回给分配器的方法。

第一部分是由我们完成的:当我们调用 String::from 时,其实现会请求所需的内存。这在编程语言中非常普遍。

不过,第二部分有所不同。在有 垃圾回收器,garbage collector,GC 的语言中,GC 会跟踪并清理不再使用的内存,我们不需要考虑这个问题。在大多数没有 GC 的语言中,我们有责任识别内存何时不再被使用,并调用代码显式释放内存,就像我们请求内存时一样。正确做到这一点,历来是编程中的难题。如果我们忘记了,就会浪费内存。如果过早释放,就会产生无效变量。如果我们做了两次,那也是一个错误。我们需要在 allocate 一次内存的同时,严格 free 一次内存。

Rust 采取了不同路径:一旦拥有内存的变量超出作用域,该内存就会自动退回。下面是清单 4-1 中,咱们作用域示例的一个使用了 String,而非字符串字面值的版本:

#![allow(unused)]
fn main() {
    {
        let s = String::from("hello");  // 变量 s 自此往下是有效的

        // 以变量 s 完成一些操作
    }                                   // 该作用域到此时结束,而变量 s
                                        // 不再有效
}

其中有个我们可将 String 所需的内存,归还给分配器的天然时间点:当 s 超出作用域时。当某个变量超出作用域时,Rust 会为我们,调用一个特殊函数。这个函数叫做 drop,这便是 String 的作者,可将返回内存代码放置的地方。Rust 会在那个结尾的大括号处,自动调用 drop 函数。

注意:在 C++ 中,这种在某个项目生命周期结束时,解分配资源的模式,有时被称为 资源获取即初始化,Resource Acquisition Is Initialization,RAII。如果咱们使用过 RAII 模式,那么 Rust 中的 drop 函数对咱们一定不会陌生。

这种模式对 Rust 代码的编写方式,影响深远。现在看来可能很简单,但在当我们打算让多个变量,使用我们在堆上分配的数据,这种更为复杂的情况下,代码的行为就会出乎意料。现在我们来探讨一下,其中的一些情况。

变量与数据相互作用:迁移

Variables and Data Interacting with Move

在 Rust 中,多个变量可以不同的方式,与同一数据交互。我们来看看清单 4-2 中,使用整数的示例。

#![allow(unused)]
fn main() {
let x = 5;
let y = x;
}

清单 4-2:将变量 x 的整数值,赋值给 y

我们大致可以猜到这是在做什么: “将值 5 绑定到 x;然后构造 x 中值的一份拷贝,并将其绑定到 y”。现在我们有两个变量,xy,他们都等于 5。这确实是正在发生的事情,因为整数属于有着已知、固定大小的简单值,而这两个 5 值,都会被压入栈上。

:这就是下面会讲到的 唯栈数据:拷贝 情形。

现在我们来看看 String 这个版本:

#![allow(unused)]
fn main() {
let s1 = String::from("hello");
let s2 = s1;
}

这看起来非常相似,因此我们可能会认为,其工作方式是一样的:即第二行构造出一份 s1 中值的拷贝,并将其绑定到 s2。但事实并非如此。

:下面的代码将打印出 s1: 你好!, s2: 你好!,表示类型 &str (字符串切片)是存储在栈上的。

fn main() {
    let s1 = "你好!";
    let s2 = s1;

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

请看图 4-1,了解 String 的到底发生了什么。一个 String 由三部分组成(如左图所示):指向存放该字符串内容的内存的一个指针、一个长度与一个容量。这组数据,是存储在栈上的。而图的右边,则是堆上存放着内容的内存。

Rust 中 String 类型的本质

图 4-1:保存绑定到 s1 的值 "hello"String 在内存中的表示

String 类似属于 灵巧指针,他是个包含了指针与其他一些元数据,有着一些方法特别能力的结构体。

长度是这个 String 的内容当前所使用的内存容量(以字节为单位)。容量则是这个字符串,从内存分配器获得的内存总量(以字节为单位)。长度和容量之间的差异很重要,但在现在这个上下文中并不重要,所以忽略这个容量就可以了。

当我们把 s1 赋值给 s2 时,这个 String 数据会被复制,这意味着我们拷贝了栈上的指针、长度和容量。我们不会拷贝该指针所指向的堆上数据。换句话说,内存中的数据表示,会如下图 4-2 所示。

有着变量 s1 的指针、长度与容量拷贝的变量 s2 在内存中的表示

图 4-2:变量 s2 在内存中的表示,其有着 s1 的指针、长度和容量的副本

如果 Rust 也拷贝了堆数据,那么内存的表示就不会如图 4-3 所示。如果 Rust 这样做了,那么在堆上的数据很大时,操作 s2 = s1 在运行时的性能方面,可能会非常昂贵。

s2 = s1 操作的另一种可能:Rust 拷贝内存堆数据

图 4-3:如果 Rust 也复制了堆数据,则 s2 = s1 可能会执行的另一种可能性

前面我们说过,当某个变量超出作用域时,Rust 会自动调用 drop 函数,并清理该变量的堆内存。但图 4-2 显示,两个数据指针都指向同一位置。这就有问题了:当 s2s1 超出作用域时,他们都会尝试释放相同的内存。这就是所谓的 双重释放,double free 错误,也是我们之前提到的内存安全漏洞之一。释放两次内存,会导致内存损坏,从而可能导致安全漏洞。

为了确保内存安全,在 let s2 = s1; 之后,Rust 便将 s1 视为不再有效。因此,当 s1 离开作用域时,Rust 不需要释放任何东西。请在 s2 创建出之后,看看尝试使用 s1 时会发生什么;他不会工作:

#![allow(unused)]
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

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

因为 Rust 阻止咱们使用无效引用,咱们就会得到一个下面这样的报错:

$ cargo run
   Compiling ownership_demo v0.1.0 (/home/peng/rust-lang-zh_CN/projects/ownership_demo)
warning: unused variable: `s2`
 --> src/main.rs:3:9
  |
3 |     let s2 = s1;
  |         ^^ help: if this is intentional, prefix it with an underscore: `_s2`
  |
  = note: `#[warn(unused_variables)]` on by default

error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:29
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `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
  |
  = 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
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
warning: `ownership_demo` (bin "ownership_demo") generated 1 warning
error: could not compile `ownership_demo` (bin "ownership_demo") due to previous error; 1 warning emitted

如果咱们在使用其他语言时,听说过 浅拷贝,shallow copy深拷贝,deep copy 这两个术语,那么拷贝指针、长度和容量,而不拷贝数据的这个概念,可能听起来就像是在构造一份浅拷贝。但是,由于 Rust 还会使第一个变量失效,所以这不叫浅拷贝,而叫 迁移,move。在这个例子中,我们会说 s1 被迁移到了 s2 中。因此,实际发生的情况,如下图 4-4 所示。

在变量 s1 失效后内存中的表示

图 4-4:变量 s1 被失效后内存中的表示

这就解决了我们的问题!由于只有 s2 有效,当他超出作用域时,只有他将释放内存,我们就大功告成了。

此外,这还暗含一种设计取舍: Rust 绝不会自动创建咱们数据的 “深” 拷贝。因此,可以假定任何 自动 拷贝,在运行时性能方面,都是低成本的。

变量与数据相互作用:克隆

Variables and Data Interacting with Clone

在我们 确实 想要深度拷贝该 String 的堆数据,而不仅仅是其栈数据时,我们可以使用一个名为 clone 的常用方法。我们将在第 5 章讨论方法语法,但由于方法是许多编程语言的共同特征,你可能已经见过了。

下面是这个 clone 方法的一个实际应用示例:

#![allow(unused)]
fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

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

这工作得很好,并会显式地产生如图 4-3 所示的行为,其中堆数据 确实 得以拷贝。

当咱们看到某个对 clone 的调用时,咱们就知道一些任意代码正在被执行,而这些代码可能很昂贵。这是个直观的指示器,表明正在发生一些不同寻常的事情。

唯栈数据:拷贝

Stack-Only Data: Copy

还有一个咱们尚未谈到过的问题。使用整数的这段代码 -- 其中一部分已在清单 4-2 中给出 -- 是有效的:

#![allow(unused)]
fn main() {
let x = 5;
let y = x;

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

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

原因是像整数这种在编译时就已知大小的类型,会被整个地存储在栈上,所以具体值的拷贝很快就构造出来了。换句话说,这里的深拷贝和浅拷贝,没有区别,所以调用 clone 与通常的浅拷贝,就不会有什么不同,我们可以不使用他。

Rust 有个名为 Copy 特质的特殊注解,我们可以把他放在像整数这种,存储在栈上的类型上(我们将在 第 10 章 详细讨论特质)。在某个类型实现了 Copy 特质时,用到他的变量就不会迁移,而是被简单拷贝,从而令到他们在赋值给另一个变量后,仍然有效。

如果某个类型或其任何部分,已经实现了 Drop 这个特质,Rust 就不会让我们以 Copy 注解该类型了。如果该类型需要在其值超出作用域时,发生某些特殊事情,且我们又为该类型添加了 Copy 注解,我们将得到一个编译时报错。要了解如何把 Copy 注解添加到咱们的类型,以实现这个特质,请参阅附录 C 中的 派生特质

那么,哪些类型实现了 Copy 特质呢?咱们可以查看给定类型的文档来确定,但一般来说,任何一组简单的标量值,都可以实现 Copy,而任何要求内存分配,或属于某种形式资源的类型,都不能实现 Copy。下面是一些实现了 Copy 的类型:

  • 所有整数类型,如 u32

  • 布尔类型,bool,值为 truefalse

  • 所有浮点类型,如 f64

  • 字符类型 char

  • 只包含同时实现了 Copy 类型的元组。例如,(i32, i32) 实现了 Copy,但 (i32, String) 则没有。

所有权与函数

Ownership and Functions

将某个值传递给函数的机制,与为将一个值赋给某个变量类似。将一个变量传递给某个函数,会像赋值一样,进行迁移或拷贝。下面清单 4-3 有着一个,带有一些显示其中变量进入和超出作用域位置注释的示例。

文件名:src/main.rs

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

    takes_ownership(s);             // 变量 s 的值迁移到这个函数中......
                                    // ......进而在这里不再有效

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

    makes_copy(x);                  // x 将迁移到这个函数中,
                                    // 但由于 i32 实现了 `Copy` 特质,因此
                                    // 后面在使用变量 x 没有问题
    println! ("x = {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 超出作用域。没有特别事情发生。

清单 4-3:带有所有权与作用域注解的函数

:下面的代码,仍然会报出:`use of moved value: ``some_string```错误:

#![allow(unused)]
fn main() {
fn takes_ownership(some_string: String) {
    println! ("{}", some_string);
    another_takes_ownership(some_string);
    third_takes_ownership(some_string);
}
}

如果我们试图在调用 takes_ownership 后使用 s,Rust 就会抛出一个编译时报错。这些静态检查,可以防止我们犯错。请尝试在 main 中,添加使用 sx 的代码,看看在哪些地方咱们可以使用他们,在哪些地方所有权规则会阻止咱们这样做。

返回值与作用域

Return Values and Scope

返回值也会转移所有权。下面清单 4-4 给出了一个返回某个值的函数示例,有着与清单 4-3 中类似的注释。

文件名:src/main.rs

fn main() {
    let s1 = gives_ownership();         // gives_ownership 会将其返回值
                                        // 迁移到 s1 中

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

    let s3 = takes_and_gives_bake(s2);  // s2 会被迁移到 takes_and_gives_back
                                        // 中,其又会将他的返回值迁移到 s3 中

    println! ("s1: {s1}, s3: {s3}");
}   // 这里,s3 会超出作用域而被丢弃。变量 s2 已被迁移,因此什么也不会发生。而
    // s1 则超出作用域而被丢弃。

fn gives_ownership() -> String {                // gives_ownership 将把他的返回值,迁移
                                                // 到调用他的函数中(即 main 函数)

    let some_string = String::from("归你了");   // some_string 进到作用域

    some_string                                 // some_string 被返回并
                                                // 迁出到调用函数
}

// 此函数取个 String 并会返回一个 String
fn takes_and_gives_bake(a_string: String) -> String {   // a_string 进入作用域
    a_string    // a_string 被返回,并会迁出到调用函数
}

清单 4-4:转移返回值的所有权

变量的所有权每次都会遵循同一模式:将某个值赋值给另一变量,就会迁移他。当某个包含了堆中数据的变量,超出作用域时,除非该数据的所有权已被迁移到另一变量,否则该值就将被 drop 清理。

虽然这种模式可行,但在每个函数中取得所有权,并返回所有权,会有点繁琐。如果我们打算让某个函数使用一个值,但又不打算取得所有权,该怎么办呢?如果我们传入的任何数据,在我们打算再次使用时,都需要传回,此外,我们可能还打算返回函数主体产生的任何数据,这一点非常恼人。

Rust 确实允许咱们使用元组返回多个值,如下清单 4-5 中所示。

文件名:src/main.rs

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

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

    println! ("字符串 '{}' 的长度为:{}", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();

    (s, length)
}

清单 4-5:返回参数的所有权

但是,对于一个本应很常见的概念来说,这样做太过仪式化和工作量都太大了。幸运的是,Rust 有一种使用某个值,而不转移所有权的特性,叫做 引用,references

Last change: 2023-12-13, commit: 157ea4c