From 377ccdf2052d50d19c70273882f1454fcda7f63c Mon Sep 17 00:00:00 2001 From: "Peng Hailin," Date: Tue, 12 Dec 2023 21:48:05 +0800 Subject: [PATCH] Refining Ch04. --- projects/ownership_demo/src/main.rs | 42 +---------- src/ownership/about_ownership.md | 104 +++++++++++++++++----------- 2 files changed, 65 insertions(+), 81 deletions(-) diff --git a/projects/ownership_demo/src/main.rs b/projects/ownership_demo/src/main.rs index 7bd91cc..bf19772 100644 --- a/projects/ownership_demo/src/main.rs +++ b/projects/ownership_demo/src/main.rs @@ -1,42 +1,6 @@ fn main() { - let s = String::from("The quick brown fox jumps over the lazy dog."); + let s1 = String::from("hello"); + let s2 = s1; - // 函数 first_word 在 String 值的切片上有效,不管是部分还是全部的切片 - let word = first_word(&s[0..6]); - println! ("{}", word); - - let word = first_word(&s[..]); - println! ("{}", word); - - // 函数 first_word 还在 String 变量的引用上有效,而 String 变量的引用 - // 与 String 值的整个切片是等价的 - let word = first_word(&s); - println! ("{}", word); - - let s_string_literal = "hello word"; - - // 函数 first_word 在字符串字面值上有效,不论是部分还是整体 - let word = first_word(&s_string_literal[0..6]); - println! ("{}", word); - - let word = first_word(&s_string_literal[..]); - println! ("{}", word); - - // 由于字符串字面值已经 是 字符串切片,因此无需切片语法,这 - // 也是有效的! - let word = first_word(s_string_literal); - - println! ("{}", word); -} - -fn first_word(s: &str) -> &str { - let bytes = s.as_bytes(); - - for (i, &item) in bytes.iter().enumerate() { - if item == b' ' { - return &s[0..i]; - } - } - - &s[..] + println! ("{}, world!", s1); } diff --git a/src/ownership/about_ownership.md b/src/ownership/about_ownership.md index 3e447ca..afb7ac7 100644 --- a/src/ownership/about_ownership.md +++ b/src/ownership/about_ownership.md @@ -16,15 +16,15 @@ > > 许多编程语言,都不要求咱们经常考虑堆栈和堆。但是,在 Rust 这样的系统编程语言中,某个值是在栈上,还是在堆上,会影响这门语言的行为方式,以及咱们必须做出某些决定的原因。本章稍后所有权的一些部分,就会与栈和堆结合讲解,因此在此要预先简要说明。 > -> 栈和堆都是内存的部分,供代码在运行时使用,但他们的结构方式不同。栈是以其获取到值的顺序存储值,并按照相反的顺序移除值。这就是所谓的 *后进先出,last in, first out*。请设想一摞盘子:当咱们添加更多盘子时,就会把他们放到这堆盘子的顶端;当咱们需要某个盘子时,就从顶端取下一个。从中间或底部添加或移除盘子的效果并不好!添加数据被称为 *推入堆栈,pushing onto the stack*,移除数据被称为 *弹出栈,popping off the stack*。栈中存储的所有数据,必须有已知、固定的大小。编译时大小未知,或大小可能改变的数据,必须存储在堆上。 +> 栈和堆都是内存的部分,供代码在运行时使用,但他们的结构方式不同。栈是以其获取到值的顺序存储值,并按照相反的顺序移除值。这就是所谓的 *后进先出,last in, first out*。请设想一摞盘子:当咱们添加更多盘子时,就会把他们放到这堆盘子的顶端;当咱们需要某个盘子时,就从顶端取下一个。从中间或底部添加或移除盘子的效果并不好!添加数据被称为 *推入堆栈,pushing onto the stack*,移除数据被称为 *弹出栈,popping off the stack*。栈上存储的所有数据,必须有已知、固定的大小。编译时大小未知,或大小可能改变的数据,必须存储在堆上。 > > 堆的组织程度则较低:在咱们把数据放在堆上时,咱们要请求一定数量的空间。内存分配器会在堆中,找到足够大的空位,将其标记为在用,并返回一个 *指针,pointer*,即那个位置的地址。这个过程称为 *在堆上分配,allocating on the heap*,有时也简称为 *分配,allocating*(将值推入栈,则不被视为分配)。由于到堆的指针,属于已知、固定的大小,因此咱们可以将该指针,存储在栈上,而当咱们需要具体数据时,就必须跟随这个指针。请设想在餐厅等待安排座位的情景。当咱们进入某家餐厅时,咱们要说明咱们团体的人数,然后接待员会找到一张适合每个人的空桌,并把咱们领到那里。如果咱们团队中有人来晚了,他们可以询问,咱们的座位在哪里,然后找到咱们。 > > 压入栈要比在堆上分配空间更快,因为分配器无需寻找存储新数据的位置;该位置总是在栈的顶部。相比之下,在堆上分配空间,则需要更多的工作,因为分配器必须首先找到一个足够大的空间来存放数据,然后进行簿记,为下一次分配做好准备。 > -> 访问堆中的数据,比访问栈中的数据要慢,因为咱们必须跟随指针才能到达那里。如果减少在内存中的跳转,那么现代处理器的速度就会更快。继续类比,请设想一下某个餐厅的服务员,从许多桌子上点菜的情况。最有效的方法是先处理一张桌子上的所有点餐,然后再处理下一张桌子上的点餐。从 A 桌点菜,然后从 B 桌点菜,然后再从 A 桌点菜,然后再从 B 桌点菜,这个过程就会慢得多。同样,如果处理器处理的数据,与其他数据距离较近(如栈中的数据),而不是较远(如堆中的数据),那么处理器就能更好地完成工作。 +> 访问堆中的数据,比访问栈上的数据要慢,因为咱们必须跟随指针才能到达那里。如果减少在内存中的跳转,那么现代处理器的速度就会更快。继续类比,请设想一下某个餐厅的服务员,从许多桌子上点菜的情况。最有效的方法是先处理一张桌子上的所有点餐,然后再处理下一张桌子上的点餐。从 A 桌点菜,然后从 B 桌点菜,然后再从 A 桌点菜,然后再从 B 桌点菜,这个过程就会慢得多。同样,如果处理器处理的数据,与其他数据距离较近(如栈上的数据),而不是较远(如堆中的数据),那么处理器就能更好地完成工作。 > -> 当咱们的代码调用某个函数时,传入函数的值(可能包括指向堆上数据的指针)和函数的局部变量,会被推入栈。函数结束后,这些值会从栈中弹出。 +> 当咱们的代码调用某个函数时,传入函数的值(可能包括指向堆上数据的指针)和函数的局部变量,会被推入栈。函数结束后,这些值会从栈上弹出。 > > 跟踪代码的哪些部分,正在使用堆上的哪些数据、尽量减少堆上的重复数据量,以及清理堆上未使用数据以免空间耗尽,这些都是所有权要解决的问题。一旦咱们掌握了所有权,咱们就不需要经常考虑栈和堆了,而清楚所有权的主要目的,是为管理堆数据这一点,有助于解释为什么他以这种方式工作。 @@ -88,7 +88,7 @@ let s = "hello"; **The `String` Type** -为了说明所有权规则,我们需要一种比第 3 章 [数据类型](../programming_concepts/data_types.md) 小节中,介绍的数据类型更复杂的一种数据类型。前面介绍的类型大小已知,可被存储在栈中,并在其作用域结束时,从栈中弹出,如果代码的另一部分,需要在不同的作用域中使用同一个值,他们就可以快速、简便地复制,以创建一个新的、独立的实例。但我们打算看看存储在堆上的数据,并探讨 Rust 如何知道,何时清理这些数据,而 `String` 类型就是个很好的例子。 +为了说明所有权规则,我们需要一种比第 3 章 [数据类型](../programming_concepts/data_types.md) 小节中,介绍的数据类型更复杂的一种数据类型。前面介绍的类型大小已知,可被存储在栈上,并在其作用域结束时,从栈上弹出,如果代码的另一部分,需要在不同的作用域中使用同一个值,他们就可以快速、简便地复制,以创建一个新的、独立的实例。但我们打算看看存储在堆上的数据,并探讨 Rust 如何知道,何时清理这些数据,而 `String` 类型就是个很好的例子。 我们将重点关注 `String` 中,与所有权相关的部分。这些方面也适用于其他复杂的数据类型,无论它们是由标准库提供,还是由咱们自己创建。我们将在 [第 8 章](../common_collections/strings.md) 中,更深入地讨论 `String`。 @@ -136,7 +136,7 @@ let s = String::from("hello"); 不过,第二部分有所不同。在有 *垃圾回收器,garbage collector,GC* 的语言中,GC 会跟踪并清理不再使用的内存,我们不需要考虑这个问题。在大多数没有 GC 的语言中,我们有责任识别内存何时不再被使用,并调用代码显式释放内存,就像我们请求内存时一样。正确做到这一点,历来是编程中的难题。如果我们忘记了,就会浪费内存。如果过早释放,就会产生无效变量。如果我们做了两次,那也是一个错误。我们需要在 `allocate` 一次内存的同时,严格 `free` 一次内存。 -Rust 采用了不同的路径:一旦拥有内存的变量超出作用域,该内存就会自动返回。下面是清单 4-1 中,咱们作用域示例的一个使用了 `String`,而非字符串字面值的版本: +Rust 采取了不同路径:一旦拥有内存的变量超出作用域,该内存就会自动退回。下面是清单 4-1 中,咱们作用域示例的一个使用了 `String`,而非字符串字面值的版本: ```rust @@ -148,91 +148,107 @@ Rust 采用了不同的路径:一旦拥有内存的变量超出作用域,该 // 不再有效 ``` -其中就存在可将那个 `String` 类型的值所需的内存,退回给内存分配器的一个天然时间点:即在变量 `s` 超出作用域时。在变量超出作用域时,Rust 就会主动调用一个特殊函数。该函数名为 `drop`,其正是 `String` 类型的编写者,放置用于内存退回的代码之处。在那个结束花括号处,Rust 会自动调用这个 `drop` 函数。 -> 注意:在 C++ 中,在某程序中项目生命周期结束时,资源重分配的这种模式,有时被称为 *资源获取即初始化*(in C++, this pattern of deallocating resources at the end of an item's lifetime is sometimes called *Resource Acquisition Is Initialization, RAII*)。若曾用过 RAII 模式,那么 Rust 中的这个 `drop` 函数就会不那么陌生了。 - -这种模式对 Rust 代码编写方式有深远影响。在此刻他可能看起来还算简单,但在想要让多个变量,使用早先在内存堆上分配的数据,这种更为复杂情形时,代码行为就会无法被预见到。现在就来探讨一下一些这样的情况。 +其中有个我们可将 `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 中,使用整数的示例。 -在 Rust 中,多个变量可以多种方式,与同一数据进行互操作。来看看下面清单 4-2 中用到整数的示例: ```rust let x = 5; let y = x; ``` -*清单 4-2:将变量 `x` 的整数值,赋值给变量 `y`* - -或许能猜到这段代码正在完成的事情:“把值 `5` 绑定到变量 `x`;随后构造一份 `x` 中值的拷贝并将其绑定到变量 `y`。” 现在就有了两个变量,`x` 与 `y`,且他们都等于 `5`。由于整数是有着已知的、固定大小的简单值,因此这实际上就是正在发生的事情,且这两个 `5` 的值都是被压入到栈上的。 - -> **注**:这就是下面会讲到的 [栈上数据的拷贝,copy](#唯栈数据拷贝stack-only-data-copy) 情形。 +*清单 4-2:将变量 `x` 的整数值,赋值给 `y`* -那么现在来看看 `String` 的版本: +我们大致可以猜到这是在做什么: “将值 `5` 绑定到 `x`;然后构造 `x` 中值的一份拷贝,并将其绑定到 `y`”。现在我们有两个变量,`x` 和 `y`,他们都等于 `5`。这确实是正在发生的事情,因为整数属于有着已知、固定大小的简单值,而这两个 `5` 值,都会被压入栈上。 + + +> **注**:这就是下面会讲到的 [唯栈数据:拷贝](#唯栈数据拷贝stack-only-data-copy) 情形。 + + +现在我们来看看 `String` 这个版本: + ```rust let s1 = String::from("hello"); let s2 = s1; ``` -这代码看起来与上面的非常相似,那么这里就可以假定其工作方式也是一样的:那就是,第二行将构造出一个 `s1` 中值的拷贝,并将该拷贝绑定到 `s2`。不过这并非真的是实际发生的样子。 +这看起来非常相似,因此我们可能会认为,其工作方式是一样的:即第二行构造出一份 `s1` 中值的拷贝,并将其绑定到 `s2`。但事实并非如此。 -> **注**:下面的代码将打印出 `s1 = 你好, s2 = 你好`,表示类型 `&str` (字符串切片)是存储在栈上的。 + +> **注**:下面的代码将打印出 `s1: 你好!, s2: 你好!`,表示类型 `&str` (字符串切片)是存储在栈上的。 ```rust fn main() { - let s1 = "你好"; + let s1 = "你好!"; let s2 = s1; - println! ("s1 = {}, s2 = {}", s1, s2); + println! ("s1: {s1}, s2: {s2}"); } ``` -请参阅下面的图 4-1,来搞明白在幕后 `String` 到底发生了什么。`String` 类型的值,是由三部分构成,在下图中的左边有给出:一个指向到保存该字符串内容内存的指针、一个长度,和一个该字符串的容量。这样一组数据被保存在栈上。下图的右边,即是内存堆上保存着字符串内容的内存。 + +请看图 4-1,了解 `String` 的到底发生了什么。一个 `String` 由三部分组成(如左图所示):指向存放该字符串内容的内存的一个指针、一个长度与一个容量。这组数据,是存储在栈上的。而图的右边,则是堆上存放着内容的内存。 + ![Rust 中 `String` 类型的本质](images/Ch04_01.svg) -*图 4-1:、保存着绑定到变量 `s1` 的值 `hello` 的一个 `String` 类型值在内存中的表示* +*图 4-1:保存绑定到 `s1` 的值 `"hello"` 的 `String` 在内存中的表示* -> **注**:`String` 类似属于 [灵巧指针,smart pointer](Ch15_Smart_Pointers.md),他是个包含了指针与其他一些元数据的,带有一些方法的特别能力的结构体。 +> **注**:`String` 类似属于 [灵巧指针](../Ch15_Smart_Pointers.md),他是个包含了指针与其他一些元数据,有着一些方法特别能力的结构体。 -其中的长度,即为以字节计数、该 `String` 值内容正使用着的内存数量。而容量则是该 `String` 值从内存分配器处收到的、以字节计算的内存数量。长度与容量之间的区别,会相当重要,但在此情形下尚不重要,到目前未知,是可以忽略容量这个部分的。 -在将 `s1` 赋值给 `s2` 时,这个 `String` 值被拷贝了,表示这里拷贝了栈上的指针、长度和容量。这里并未拷贝指针指向的、内存堆上的数据。也就是说,内存中数据的表示,如下图 4-2 所示: +长度是这个 `String` 的内容当前所使用的内存容量(以字节为单位)。容量则是这个字符串,从内存分配器获得的内存总量(以字节为单位)。长度和容量之间的差异很重要,但在现在这个上下文中并不重要,所以忽略这个容量就可以了。 + +当我们把 `s1` 赋值给 `s2` 时,这个 `String` 数据会被复制,这意味着我们拷贝了栈上的指针、长度和容量。我们不会拷贝该指针所指向的堆上数据。换句话说,内存中的数据表示,会如下图 4-2 所示。 + ![有着变量 `s1` 的指针、长度与容量拷贝的变量 `s2` 在内存中的表示](images/Ch04_02.svg) -*图 4-2:有着变量 `s1` 的指针、长度与容量拷贝的变量 `s2` 在内存中的表示* +*图 4-2:变量 `s2` 在内存中的表示,其有着 `s1` 的指针、长度和容量的副本* -这种表示 *不* 同于下图 4-3,那才是 Rust 对内存堆上的数据进行拷贝时,内存看起来的样子。如果 Rust 像下图 4-3 中那样做,那么当内存堆上的数据较大时, `s2 = s1` 的这个操作,将会在运行时性能开销上代价高昂。 + +如果 Rust 也拷贝了堆数据,那么内存的表示就不会如图 4-3 所示。如果 Rust 这样做了,那么在堆上的数据很大时,操作 `s2 = s1` 在运行时的性能方面,可能会非常昂贵。 ![`s2 = s1` 操作的另一种可能:Rust 拷贝内存堆数据](images/Ch04_03.svg) -*图 4-3:`s2 = s1` 操作的另一种可能:Rust 同时拷贝内存堆数据* +*图 4-3:如果 Rust 也复制了堆数据,则 `s2 = s1` 可能会执行的另一种可能性* -早先曾讲过,在变量超出作用域后,Rust 会自动调用那个 `drop` 函数,而清理掉那个变量的堆内存。但图 4-2 则给出了两个指针都指向同一位置的情况。这就是个问题了:在 `s2` 与 `s1` 都超出作用域时,他们都将尝试去释放那同样的内存。这被称为 *双重释放,double free* 错误,是先前提到过的内存安全错误之一,one of the memory safety bugs。二次释放内存,可导致内存损坏,而内存损坏则会潜在导致安全漏洞。 -为确保内存安全,Rust 在代码行 `s2 = s1` 之后,便不再认为 `s1` 是有效的了。因此,在 `s1` 超出作用域后,Rust 便不需要释放任何内存。下面就来检查一下,在 `s2` 创建出来后,去尝试使用 `s1` 会发生什么;这样做是不会工作的: +前面我们说过,当某个变量超出作用域时,Rust 会自动调用 `drop` 函数,并清理该变量的堆内存。但图 4-2 显示,两个数据指针都指向同一位置。这就有问题了:当 `s2` 和 `s1` 超出作用域时,他们都会尝试释放相同的内存。这就是所谓的 *双重释放,double free* 错误,也是我们之前提到的内存安全漏洞之一。释放两次内存,会导致内存损坏,从而可能导致安全漏洞。 + +为了确保内存安全,在 `let s2 = s1;` 之后,Rust 便将 `s1` 视为不再有效。因此,当 `s1` 离开作用域时,Rust 不需要释放任何东西。请在` s2` 创建出之后,看看尝试使用 `s1` 时会发生什么;他不会工作: + ```rust - let s1 = String::from("hello"); // 这里 s 的类型为:String + let s1 = String::from("hello"); let s2 = s1; - println! ("{}", s1); + println! ("{}, world!", s1); ``` -由于 Rust 阻止了对失效引用变量的使用,因此将收到一个下面这样的错误: + +因为 Rust 阻止咱们使用无效引用,咱们就会得到一个下面这样的报错: + ```console $ cargo run - Compiling string_demo v0.1.0 (/home/peng/rust-lang/projects/string_demo) + Compiling ownership_demo v0.1.0 (/home/peng/rust-lang-zh_CN/projects/ownership_demo) warning: unused variable: `s2` --> src/main.rs:3:9 | @@ -242,21 +258,25 @@ warning: unused variable: `s2` = note: `#[warn(unused_variables)]` on by default error[E0382]: borrow of moved value: `s1` - --> src/main.rs:5:21 + --> src/main.rs:5:29 | -2 | let s1 = String::from("hello"); // 这里 s 的类型为:String +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); - | ^^ value borrowed here after move +5 | 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) + = 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: `string_demo` (bin "string_demo") generated 1 warning -error: could not compile `string_demo` due to previous error; 1 warning emitted +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 显示的那样: