什么是所有权?

Beginner

This tutorial is from open-source community. Access the source code

简介

欢迎来到《什么是所有权?》实验。本实验是 Rust 程序设计语言 的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,你将学习 Rust 中的所有权,这是一组管理程序如何管理内存的规则,以及它如何影响语言的行为和性能。

什么是所有权?

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

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

当你理解了所有权,你就为理解使 Rust 独特的特性奠定了坚实的基础。在本章中,你将通过一些专注于非常常见的数据结构:字符串的示例来学习所有权。

栈和堆

许多编程语言并不要求你经常考虑栈和堆。但在像 Rust 这样的系统编程语言中,一个值是在栈上还是在堆上会影响语言的行为方式,以及你为什么必须做出某些决定。本章后面会结合栈和堆来描述所有权的部分内容,所以这里先做一个简要的解释作为铺垫。

栈和堆都是你的代码在运行时可使用的内存部分,但它们的结构不同。栈按照获取值的顺序存储值,并以相反的顺序移除值。这被称为 后进先出。想象一叠盘子:当你添加更多盘子时,你把它们放在最上面,当你需要一个盘子时,你从最上面拿一个。从中间或底部添加或移除盘子就不太可行!添加数据称为 压入栈,移除数据称为 弹出栈。所有存储在栈上的数据必须有已知的、固定的大小。在编译时大小未知或可能改变大小的数据必须存储在堆上。

堆的组织性较差:当你在堆上放置数据时,你请求一定量的空间。内存分配器在堆中找到一个足够大的空位,将其标记为已使用,并返回一个 指针,即该位置的地址。这个过程称为 在堆上分配,有时简称为 分配(将值压入栈不被视为分配)。因为指向堆的指针是已知的、固定大小的,所以你可以将指针存储在栈上,但当你想要实际数据时,必须跟随指针。想象一下在餐厅就座。当你进入时,你说出你的团体人数,服务员会找到一个能容纳所有人的空桌子并带你过去。如果你的团体中有其他人来晚了,他们可以询问你坐在哪里来找到你。

压入栈比在堆上分配快,因为分配器不必搜索存储新数据的位置;那个位置总是在栈顶。相比之下,在堆上分配空间需要更多工作,因为分配器必须首先找到一个足够大的空间来容纳数据,然后进行簿记以准备下一次分配。

访问堆中的数据比访问栈中的数据慢,因为你必须跟随指针才能到达那里。现代处理器在内存中跳转越少就越快。继续这个比喻,想象餐厅里的一个服务员从许多桌子那里接单。在去下一张桌子之前先从一张桌子接完所有订单是最有效的。先从 A 桌接一个订单,然后从 B 桌接一个订单,再从 A 桌接一个订单,然后再从 B 桌接一个订单,这将是一个慢得多的过程。同样,处理器如果处理的数据彼此靠近(就像在栈上那样)而不是相距较远(就像在堆上那样),就能更好地完成工作。

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

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

所有权规则

首先,让我们看看所有权规则。在我们讲解说明这些规则的示例时,请记住以下规则:

  • Rust 中的每个值都有一个 所有者
  • 同一时间只能有一个所有者。
  • 当所有者超出作用域时,该值将被丢弃。

变量作用域

既然我们已经学过了 Rust 的基本语法,那么在示例中就不会再包含所有的 fn main() { 代码了。所以如果你要跟着做,确保手动将以下示例代码放在 main 函数中。这样,我们的示例会更简洁,能让我们专注于实际细节而非样板代码。

作为所有权的第一个示例,我们来看看一些变量的 作用域。作用域是程序中一个项有效的范围。看下面这个变量:

let s = "hello";

变量 s 指向一个字符串字面量,其字符串值被硬编码到我们程序的文本中。该变量从声明点开始有效,直到当前 作用域 结束。清单 4-1 展示了一个带有注释的程序,注释标明了变量 s 在何处有效。

{                      // s 在此处无效,因为它尚未声明
    let s = "hello";   // 从这一点起 s 有效

    // 对 s 进行一些操作
}                      // 这个作用域现在结束了,s 不再有效

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

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

  • s 进入 作用域时,它是有效的。
  • 它一直有效,直到 离开 作用域。

此时,作用域与变量何时有效的关系与其他编程语言类似。现在我们将在此理解的基础上引入 String 类型。

String 类型

为了阐释所有权规则,我们需要一种比“数据类型”一章中所涵盖的类型更复杂的数据类型。之前涵盖的类型具有已知的大小,可以存储在栈上,并在其作用域结束时从栈上弹出,并且如果代码的其他部分需要在不同作用域中使用相同的值,可以快速且简单地进行复制以创建一个新的独立实例。但我们想要研究存储在堆上的数据,并探索 Rust 如何知道何时清理这些数据,而 String 类型就是一个很好的例子。

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

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

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

双冒号 :: 运算符允许我们将这个特定的 from 函数置于 String 类型的命名空间下,而不是使用类似 string_from 这样的名称。我们将在“方法语法”中更详细地讨论这种语法,以及在“模块树中引用项的路径”中讨论使用模块进行命名空间时。

这种字符串 可以 被修改:

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

s.push_str(", world!"); // push_str() 将一个字面量追加到一个 String

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

那么,这里的区别是什么呢?为什么 String 可以被修改而字面量却不能呢?区别在于这两种类型处理内存的方式。

内存与分配

对于字符串字面量,我们在编译时就知道其内容,所以文本会直接被硬编码到最终的可执行文件中。这就是字符串字面量快速且高效的原因。但这些特性仅源于字符串字面量的不可变性。不幸的是,对于每一段在编译时大小未知且在程序运行时大小可能改变的文本,我们无法将一块内存放入二进制文件中。

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

  • 必须在运行时从内存分配器请求内存。
  • 当我们用完 String 后,需要有一种方法将这块内存归还给分配器。

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

然而,第二部分则有所不同。在具有 垃圾回收器(GC) 的语言中,垃圾回收器会跟踪并清理不再使用的内存,我们无需对此操心。在大多数没有垃圾回收器的语言中,我们有责任确定内存何时不再被使用,并调用代码来显式释放它,就像我们请求内存时所做的那样。从历史上看,正确地做到这一点一直是一个困难的编程问题。如果我们忘记了,就会浪费内存。如果我们做得太早,就会有一个无效的变量。如果我们做了两次,那也是一个错误。我们需要将一个 allocate 与一个 free 精确配对。

Rust 采取了不同的方式:一旦拥有内存的变量超出作用域,内存就会自动被归还。下面是清单 4-1 中作用域示例的一个版本,这里使用 String 而不是字符串字面量:

{
    let s = String::from("hello"); // 从这一点起 s 有效

    // 对 s 进行一些操作
}                                  // 这个作用域现在结束了,s 不再有效

有一个自然的时机可以将我们的 String 所需的内存归还给分配器:当 s 超出作用域时。当一个变量超出作用域时,Rust 会为我们调用一个特殊的函数。这个函数叫做 dropString 的作者可以在这个函数中编写归还内存的代码。Rust 会在右花括号处自动调用 drop

注意:在 C++ 中,在一个对象生命周期结束时释放资源的这种模式有时被称为 资源获取即初始化(RAII)。如果你使用过 RAII 模式,那么 Rust 中的 drop 函数对你来说会很熟悉。

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

通过移动来与变量和数据交互

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

let x = 5;
let y = x;

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

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

现在让我们看看 String 版本:

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

这看起来非常相似,所以我们可能会认为它的工作方式是相同的:也就是说,第二行会复制 s1 中的值并将其绑定到 s2。但实际情况并非完全如此。

看看图 4-1 来了解 String 在底层是如何工作的。一个 String 由三部分组成,如左边所示:一个指向存储字符串内容的内存的指针、一个长度和一个容量。这组数据存储在栈上。右边是堆上存储内容的内存。

图 4-1:存储值为 "hello" 且绑定到 s1String 在内存中的表示

长度是 String 的内容当前使用的字节数。容量是 String 从分配器获得的总字节数。长度和容量之间的差异很重要,但在这种情况下并不重要,所以目前可以忽略容量。

当我们将 s1 赋给 s2 时,String 数据被复制,这意味着我们复制了栈上的指针、长度和容量。我们并没有复制指针所指向的堆上的数据。换句话说,内存中的数据表示看起来像图 4-2。

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

这种表示 不像 图 4-3,如果 Rust 也复制堆上的数据,内存看起来会是那样。如果 Rust 这样做,那么如果堆上的数据很大,s2 = s1 操作在运行时性能方面可能会非常昂贵。

图 4-3:如果 Rust 也复制堆上的数据,s2 = s1 可能的另一种情况

之前我们说过,当一个变量超出作用域时,Rust 会自动调用 drop 函数并清理该变量的堆内存。但是图 4-2 显示两个数据指针都指向同一个位置。这是个问题:当 s2s1 超出作用域时,它们都会尝试释放相同的内存。这被称为 双重释放 错误,是我们之前提到的内存安全漏洞之一。两次释放内存可能会导致内存损坏,这可能会潜在地导致安全漏洞。

为了确保内存安全,在 let s2 = s1; 这一行之后,Rust 认为 s1 不再有效。因此,当 s1 超出作用域时,Rust 不需要释放任何东西。看看在创建 s2 之后尝试使用 s1 会发生什么;它不起作用:

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

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

你会得到这样一个错误,因为 Rust 阻止你使用无效的引用:

error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
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!("{s1}, world!");
  |                ^^ value borrowed here after move

如果你在使用其他语言时听说过术语 浅拷贝深拷贝,那么只复制指针、长度和容量而不复制数据的概念可能听起来像是在进行浅拷贝。但因为 Rust 也会使第一个变量无效,所以它不被称为浅拷贝,而是被称为 移动。在这个例子中,我们会说 s1移动 到了 s2 中。所以,实际发生的情况如图 4-4 所示。

图 4-4:s1 无效后的内存表示

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

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

通过克隆来与变量和数据交互

如果我们 确实 想要深度复制 String 的堆数据,而不仅仅是栈数据,可以使用一种称为 clone 的常用方法。我们将在第 5 章讨论方法语法,但由于方法是许多编程语言中的常见特性,你可能之前已经见过。

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

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

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

这运行得很好,并且明确产生了图 4-3 中所示的行为,即堆数据 确实 被复制了。

当你看到对 clone 的调用时,你就知道正在执行一些任意代码,并且该代码可能成本较高。这是一个直观的指示,表明正在发生不同的事情。

仅存储在栈上的数据:Copy

还有一个我们尚未讨论的细节。这段使用整数的代码(清单 4-2 展示了其中一部分)能够正常运行且有效:

let x = 5;
let y = x;

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

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

原因是像整数这样在编译时具有已知大小的类型完全存储在栈上,所以实际值的复制很快就能完成。这意味着在创建变量 y 之后,我们没有理由阻止 x 继续有效。换句话说,这里的深拷贝和浅拷贝没有区别,所以调用 clone 与普通的浅拷贝没有任何不同,我们可以省略它。

Rust 有一个特殊的标注,称为 Copy 特性,我们可以将其应用于存储在栈上的类型,整数就是这样的类型(我们将在第 10 章更多地讨论特性)。如果一个类型实现了 Copy 特性,那么使用它的变量不会被移动,而是会被简单地复制,这使得它们在赋值给另一个变量后仍然有效。

如果一个类型或其任何部分实现了 Drop 特性,Rust 不会允许我们为该类型标注 Copy。如果一个类型在值超出作用域时需要进行特殊处理,而我们为该类型添加了 Copy 标注,将会得到一个编译时错误。要了解如何为你的类型添加 Copy 标注以实现该特性,请参阅“可派生特性”。

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

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

所有权与函数

将一个值传递给函数的机制与将值赋给变量时的机制类似。将一个变量传递给函数时,它会被移动或复制,就像赋值时一样。清单 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

} // 在这里,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:带有所有权和作用域注释的函数

如果我们在调用 takes_ownership 之后尝试使用 s,Rust 会抛出一个编译时错误。这些静态检查可以保护我们避免犯错。尝试在 main 中添加使用 sx 的代码,看看你可以在哪里使用它们,以及所有权规则在哪里会阻止你这样做。

返回值与作用域

返回值也可以转移所有权。清单 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_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 被返回并
                                             // 移动到调用函数中
}

// 此函数接受一个 String 并返回一个 String
fn takes_and_gives_back(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!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() 返回一个 String 的长度

    (s, length)
}

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

但对于一个应该很常见的概念来说,这太繁琐且工作量太大了。幸运的是,Rust 有一个用于在不转移所有权的情况下使用值的特性,称为 引用

总结

恭喜你!你已经完成了“什么是所有权?”实验。你可以在 LabEx 中练习更多实验来提升你的技能。