diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 6699dce4..611d70b4 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -155,6 +155,11 @@ - [手把手带你实现链表 doing](too-many-lists/intro.md) - [我们到底需不需要链表](too-many-lists/do-we-need-it.md) + - [不太优秀的单向链表:栈](too-many-lists/bad-stack/intro.md) + - [数据布局](too-many-lists/bad-stack/layout.md) + - [基本操作](too-many-lists/bad-stack/basic-operations.md) + - [最后实现](too-many-lists/bad-stack/final-code.md) + - [易混淆概念解析](confonding/intro.md) diff --git a/src/advance/concurrency-with-threads/thread.md b/src/advance/concurrency-with-threads/thread.md index a57594fb..c58fed54 100644 --- a/src/advance/concurrency-with-threads/thread.md +++ b/src/advance/concurrency-with-threads/thread.md @@ -164,7 +164,7 @@ fn main() { 大家要记住,线程的启动时间点和结束时间点是不确定的,因此存在一种可能,当主线程执行完, `v` 被释放掉时,新的线程很可能还没有结束甚至还没有被创建成功,此时新线程对 `v` 的引用立刻就不再合法! -好在报错里进行了提示:`to force the closure to take ownership of v (and any other referenced variables), use the move keyword`,让我们使用 `move` 关键字拿走 `v` 的所有权即可: +好在报错里进行了提示:```to force the closure to take ownership of v (and any other referenced variables), use the `move` keyword```,让我们使用 `move` 关键字拿走 `v` 的所有权即可: ```rust use std::thread; diff --git a/src/basic/converse.md b/src/basic/converse.md index 4ebde9c5..cc56abc5 100644 --- a/src/basic/converse.md +++ b/src/basic/converse.md @@ -58,17 +58,7 @@ assert_eq!(values[1], 3); #### 强制类型转换的边角知识 -1. 数组切片原生指针之间的转换,不会改变数组占用的内存字节数,尽管数组元素的类型发生了改变: - -```rust -fn main() { - let a: *const [u16] = &[1, 2, 3, 4, 5]; - let b = a as *const [u8]; - assert_eq!(std::mem::size_of_val(&a), std::mem::size_of_val(&b)) -} -``` - -2. 转换不具有传递性 +1. 转换不具有传递性 就算 `e as U1 as U2` 是合法的,也不能说明 `e as U2` 是合法的(`e` 不能直接转换成 `U2`)。 ## TryInto 转换 diff --git a/src/basic/method.md b/src/basic/method.md index ba871de5..0ade558c 100644 --- a/src/basic/method.md +++ b/src/basic/method.md @@ -72,6 +72,7 @@ fn main() { `impl Rectangle {}` 表示为 `Rectangle` 实现方法(`impl` 是实现 _implementation_ 的缩写),这样的写法表明 `impl` 语句块中的一切都是跟 `Rectangle` 相关联的。 +#### self、&self 和 &mut self 接下里的内容非常重要,请大家仔细看。在 `area` 的签名中,我们使用 `&self` 替代 `rectangle: &Rectangle`,`&self` 其实是 `self: &Self` 的简写(注意大小写)。在一个 `impl` 块内,`Self` 指代被实现方法的结构体类型,`self` 指代此类型的实例,换句话说,`self` 指代的是 `Rectangle` 结构体实例,这样的写法会让我们的代码简洁很多,而且非常便于理解:我们为哪个结构体实现方法,那么 `self` 就是指代哪个结构体的实例。 需要注意的是,`self` 依然有所有权的概念: diff --git a/src/cargo/guide/cargo-toml-lock.md b/src/cargo/guide/cargo-toml-lock.md index 8de8d711..e86bca9a 100644 --- a/src/cargo/guide/cargo-toml-lock.md +++ b/src/cargo/guide/cargo-toml-lock.md @@ -14,7 +14,7 @@ 关于是否上传,有如下经验准则: - 从实践角度出发,如果你构建的是三方库类型的服务,请把 `Cargo.lock` 加入到 `.gitignore` 中。 -- 若构建的是一个面向用户终端的产品,例如可以像命令行工具、应用程一样执行,那就把 `Cargo.lock` 上传到源代码目录中。 +- 若构建的是一个面向用户终端的产品,例如可以像命令行工具、应用程序一样执行,那就把 `Cargo.lock` 上传到源代码目录中。 例如 [`axum`](https://github.com/tokio-rs/axum) 是 web 开发框架,它属于三方库类型的服务,因此源码目录中不应该出现 `Cargo.lock` 的身影,它的归宿是 `.gitignore`。而 [`ripgrep`](https://github.com/BurntSushi/ripgrep) 则恰恰相反,因为它是一个面向终端的产品,可以直接运行提供服务。 diff --git a/src/test/ci.md b/src/test/ci.md index 2dc31ddc..82871132 100644 --- a/src/test/ci.md +++ b/src/test/ci.md @@ -135,7 +135,7 @@ jobs: ```yml on: - schedule: -cron:'00 ***' + schedule: -cron:'0 0 * * *' ``` 3. 外部事件触发,例如你可以通过 `REST API` 向 Github 发送请求去触发,具体请查阅[官方文档](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#repository_dispatch) diff --git a/src/tokio/shared-state.md b/src/tokio/shared-state.md index 3c96085f..b6e40f85 100644 --- a/src/tokio/shared-state.md +++ b/src/tokio/shared-state.md @@ -27,7 +27,7 @@ bytes = "1" ## 初始化 HashMap -由于 `HashMap` 会在多个任务甚至多个线程间共享,再结合之前的选择,最终我们决定使用 `>>` 的方式对其进行包裹。 +由于 `HashMap` 会在多个任务甚至多个线程间共享,再结合之前的选择,最终我们决定使用 `Arc>` 的方式对其进行包裹。 但是,大家先来畅想一下使用它进行包裹后的类型长什么样? 大概,可能,长这样:`Arc>>`,天哪噜,一不小心,你就遇到了 Rust 的阴暗面:类型大串烧。可以想象,如果要在代码中到处使用这样的类型,可读性会极速下降,因此我们需要一个[类型别名](https://course.rs/advance/custom-type.html#类型别名type-alias)( type alias )来简化下: diff --git a/src/too-many-lists/bad-stack/basic-operations.md b/src/too-many-lists/bad-stack/basic-operations.md new file mode 100644 index 00000000..104e7607 --- /dev/null +++ b/src/too-many-lists/bad-stack/basic-operations.md @@ -0,0 +1,264 @@ +# 定义基本操作 +这个章节我们一起来为新创建的 `List` 定义一些基本操作,首先从创建链表开始。 + +## New +为了将实际的代码跟类型关联在一起,我们需要使用 `impl` 语句块: +```rust +impl List { + // TODO +} +``` + +下一步就是创建一个关联函数,用于构建 `List` 的新实例,该函数的作用类似于其他语言的构造函数。 +```rust +impl List { + pub fn new() -> Self { + List { head: Link::Empty } + } +} +``` + +> 学习链接: [impl、关联函数](https://course.rs/basic/method.html#关联函数)、[Self](https://course.rs/basic/trait/trait-object.html?highlight=Self#self-与-self) + + +## Push +在开始实现之前,你需要先了解 [self、&self、&mut sef](https://course.rs/basic/method.html#selfself-和-mut-self) 这几个概念。 + +在创建链表后,下一步就是往链表中插入新的元素,由于 `push` 会改变链表,因此我们使用 `&mut self` 的方法签名: +```rust +impl List { + pub fn push(&mut self, elem: i32) { + // TODO + } +} +``` + +根据之前的数据定义,首先需要创建一个 `Node` 来存放该元素: +```rust +pub fn push(&mut self, elem: i32) { + let new_node = Node { + elem: elem, + next: ????? + }; +} +``` + +下一步需要让该节点指向之前的旧 `List`: +```rust +pub fn push(&mut self, elem: i32) { + let new_node = Node { + elem: elem, + next: self.head, + }; +} +``` + +```shell +error[E0507]: cannot move out of `self.head` which is behind a mutable reference + --> src/first.rs:23:19 + | +23 | next: self.head, + | ^^^^^^^^^ move occurs because `self.head` has type `Link`, which does not implement the `Copy` trait +``` + + +但是,如上所示,这段代码会报错,因为试图将借用的值 `self` 中的 `head` 字段的所有权转移给 `next` ,在 Rust 中这是不被允许的。那如果我们试图将值再放回去呢? +```rust +pub fn push(&mut self, elem: i32) { + let new_node = Box::new(Node { + elem: elem, + next: self.head, + }); + + self.head = Link::More(new_node); +} +``` + +其实在写之前,应该就预料到结果了,显然这也是不行的,虽然从我们的角度来看还挺正常的,但是 Rust 并不会接受(有多种原因,其中主要的是[Exception safety](https://doc.rust-lang.org/nightly/nomicon/exception-safety.html))。 + +我们需要一个办法,让 Rust 不再阻挠我们,其中一个可行的办法是使用 `clone`: +```rust +pub struct List { + head: Link, +} + +#[derive(Clone)] +enum Link { + Empty, + More(Box), +} + +#[derive(Clone)] +struct Node { + elem: i32, + next: Link, +} + +impl List { + pub fn new() -> Self { + List { head: Link::Empty } + } + + pub fn push(&mut self, elem: i32) { + let new_node = Node { + elem: elem, + next: self.head.clone(), + }; + } +} +``` + +`clone` 用起来简单难,且可解万愁,但是。。。既然是链表,性能那自然是很重要的,特别是要封装成库给其他代码使用时,那性能更是重中之重。 + +没办法了,我们只能向大名鼎鼎的 Rust 黑客 Indiana Jones求助了: + + +经过一番诚心祈愿,Indy 建议我们使用 `mem::replace` 秘技。这个非常有用的函数允许我们从一个借用中偷出一个值的同时再放入一个新值。 +```rust +pub fn push(&mut self, elem: i32) { + let new_node = Box::new(Node { + elem: elem, + next: std::mem::replace(&mut self.head, Link::Empty), + }); + + self.head = Link::More(new_node); +} +``` + +这里,我们从借用 `self` 中偷出了它的值 `head` 并赋予给 `next` 字段,同时将一个新值 `Link::Empty` 放入到 `head` 中,成功完成偷梁换柱。不得不说,这个做法非常刺激,但是很不幸的是,目前为止,最好的办法可能也只能是它了。 + +但是不管怎样,我们成功的完成了 `push` 方法,下面再来看看 `pop`。 + +## Pop +`push` 是插入元素,那 `pop` 自然就是推出一个元素,因此也需要使用 `&mut self`,除此之外,推出的元素需要被返回,这样调用者就可以获取该元素: +```rust +pub fn pop(&mut self) -> Option { + // TODO +} +``` + +我们还需要一个办法来根据 `Link` 是否有值进行不同的处理,这个可以使用 `match` 来进行模式匹配: +```rust +pub fn pop(&mut self) -> Option { + match self.head { + Link::Empty => { + // TODO + } + Link::More(node) => { + // TODO + } + }; +} +``` + +目前的代码显然会报错,因为函数的返回值是 `Option` 枚举,而目前的返回值是 [`()`](https://course.rs/basic/base-type/function.html#无返回值)。当然,我们可以返回一个`Option` 的枚举成员 `None`,但是一个更好的做法是使用 `unimplemented!()`,该宏可以明确地说明目前的代码还没有实现,一旦代码执行到 `unimplemented!()` 的位置,就会发生一个 `panic`。 + +```rust +pub fn pop(&mut self) -> Option { + match self.head { + Link::Empty => { + // TODO + } + Link::More(node) => { + // TODO + } + }; + unimplemented!() +} +``` +`panics` 是一种[发散函数](https://course.rs/basic/base-type/function.html?search=#永不返回的函数),该函数永不返回任何值,因此可以用于需要返回任何类型的地方。这句话很不好理解,但是从上面的代码中可以看出 `unimplemented!()` 是永不返回的函数,但是它却可以用于一个返回 `Option` 的函数中来替代返回值。 + +以上代码果不其然又报错了: +```shell +> cargo build + +error[E0507]: cannot move out of borrowed content + --> src/first.rs:28:15 + | +28 | match self.head { + | ^^^^^^^^^ + | | + | cannot move out of borrowed content + | help: consider borrowing here: `&self.head` +... +32 | Link::More(node) => { + | ---- data moved here + | +note: move occurs because `node` has type `std::boxed::Box`, which does not implement the `Copy` trait +``` + +好在编译器偷偷提示了我们使用借用来替代所有权转移: `&self.head`。修改后,如下: +```rust +pub fn pop(&mut self) -> Option { + match &self.head { + Link::Empty => { + // TODO + } + Link::More(node) => { + // TODO + } + }; + unimplemented!() +} +``` + +是时候填写相应的逻辑了: +```rust +pub fn pop(&mut self) -> Option { + let result; + match &self.head { + Link::Empty => { + result = None; + } + Link::More(node) => { + result = Some(node.elem); + self.head = node.next; + } + }; + result +} +``` + +当链表为 `Empty` 时,返回一个 `None`,表示我们没有 `pop` 到任何元素;若不为空,则返回第一个元素,并将 `head` 指向下一个节点 `node.next`。但是这段代码又报错了: +```shell +error[E0507]: cannot move out of `node.next` which is behind a shared reference + --> src/first.rs:37:29 + | +37 | self.head = node.next; + | ^^^^^^^^^ move occurs because `node.next` has type `Link`, which does not implement the `Copy` trait +``` + + +原因是试图转移 `node` 的所有权,但只有它的引用。回头仔细看看代码,会发现这里的关键是我们希望移除一些东西,这意味着需要通过值的方式获取链表的 head。看来只能故技重施了: +```rust +pub fn pop(&mut self) -> Option { + let result; + match std::mem::replace(&mut self.head, Link::Empty) { + Link::Empty => { + result = None; + } + Link::More(node) => { + result = Some(node.elem); + self.head = node.next; + } + }; + result +} +``` + +我们将 `self.head` 的值偷出来,然后再将 `Link::Empty` 填回到 `self.head` 中。此时用于 `match` 匹配的就是一个拥有所有权的值类型,而不是之前的引用类型。 + +事实上,上面的代码有些啰嗦,我们可以直接在 `match` 的两个分支中通过表达式进行返回: +```rust +pub fn pop(&mut self) -> Option { + match std::mem::replace(&mut self.head, Link::Empty) { + Link::Empty => None, + Link::More(node) => { + self.head = node.next; + Some(node.elem) + } + } +} +``` + +这样修改后,代码就更加简洁,可读性也更好了,至此链表的基本操作已经完成,下面让我们写一个测试代码来测试下它的功能和正确性。 \ No newline at end of file diff --git a/src/too-many-lists/bad-stack/final-code.md b/src/too-many-lists/bad-stack/final-code.md new file mode 100644 index 00000000..95a1b7ea --- /dev/null +++ b/src/too-many-lists/bad-stack/final-code.md @@ -0,0 +1,270 @@ +# 一些收尾工作以及最终代码 +在之前的章节中,我们完成了 Bad 单链表栈的数据定义和基本操作,下面一起来写一些测试代码。 + + +## 单元测试 +> 关于如何编写测试,请参见[自动化测试章节](https://course.rs/test/write-tests.html) + +首先,单元测试代码要放在待测试的目标代码旁边,也就是同一个文件中: +```rust +// in first.rs +#[cfg(test)] +mod test { + #[test] + fn basics() { + let mut list = List::new(); + + // Check empty list behaves right + assert_eq!(list.pop(), None); + + // Populate list + list.push(1); + list.push(2); + list.push(3); + + // Check normal removal + assert_eq!(list.pop(), Some(3)); + assert_eq!(list.pop(), Some(2)); + + // Push some more just to make sure nothing's corrupted + list.push(4); + list.push(5); + + // Check normal removal + assert_eq!(list.pop(), Some(5)); + assert_eq!(list.pop(), Some(4)); + + // Check exhaustion + assert_eq!(list.pop(), Some(1)); + assert_eq!(list.pop(), None); + } +} +``` + +在 `src/first.rs` 中添加以上测试模块,然后使用 `cart test` 运行相关的测试用例: +```shell +> cargo test + +error[E0433]: failed to resolve: use of undeclared type or module `List` + --> src/first.rs:43:24 + | +43 | let mut list = List::new(); + | ^^^^ use of undeclared type or module `List` + +``` + +Ooops! 报错了,从错误内容来看,是因为我们在一个不同的模块 `test` 中,引入了 `first` 模块中的代码,由于前者是后者的子模块,因此可以使用以下方式引入 `first` 模块中的 `List` 定义: +```rust +#[cfg(test)] +mod test { + use super::List; + // 其它代码保持不变 +} +``` + +大家可以再次尝试使用 `carto test` 运行测试用例,具体的结果就不再展开,关于结果的解读,请参看文章开头的链接。 + +## Drop +现在还有一个问题,我们是否需要手动来清理释放我们的链表?答案是 No,因为 Rust 为我们提供了 `Drop` 特征,若变量实现了该特征,则在它离开作用域时将自动调用解构函数以实现资源清理释放工作,最妙的是,这一切都发生在编译期,因此没有多余的性能开销。 + +> 关于 Drop 特征的详细介绍,请参见[智能指针 - Drop](https://course.rs/advance/smart-pointer/drop.html) + +事实上,我们无需手动为自定义类型实现 `Drop` 特征,原因是 Rust 自动为几乎所有类型都实现了 `Drop`,例如我们自定义的结构体,只要结构体的所有字段都实现了 `Drop`,那结构体也会自动实现 `Drop` ! + +但是,有的时候这种自动实现可能不够优秀,例如考虑以下链表: +```shell +list -> A -> B -> C +``` + +当 `List` 被自动 `drop` 后,接着会去尝试 `Drop` A,然后是 `B`,最后是 `C`。这个时候,其中一部分读者可能会紧张起来,因此这其实是一段递归代码,可能会直接撑爆我们的 stack 栈。 + +例如以下的测试代码会试图创建一个很长的链表,然后会导致栈溢出错误: +```rust +```rust, ignore +#[test] +fn long_list() { + let mut list = List::new(); + for i in 0..100000 { + list.push(i); + } + drop(list); +} +``` + + +```shell +thread 'first::test::long_list' has overflowed its stack +``` + +可能另一部分同学会想 "这显然是[尾递归](https://zh.wikipedia.org/wiki/尾调用),一个靠谱的编程语言是不会让尾递归撑爆我们的 stack"。然后,这个想法并不正确,下面让我们尝试模拟编译器来看看 `Drop` 会如何实现: +```rust +impl Drop for List { + fn drop(&mut self) { + // NOTE: 在 Rust 代码中,我们不能显式的调用 `drop` 方法,只能调用 std::mem::drop 函数 + // 这里只是在模拟编译器! + self.head.drop(); // 尾递归 - good! + } +} + +impl Drop for Link { + fn drop(&mut self) { + match *self { + Link::Empty => {} // Done! + Link::More(ref mut boxed_node) => { + boxed_node.drop(); // 尾递归 - good! + } + } + } +} + +impl Drop for Box { + fn drop(&mut self) { + self.ptr.drop(); // 糟糕,这里不是尾递归! + deallocate(self.ptr); // 不是尾递归的原因是在 `drop` 后,还有额外的操作 + } +} + +impl Drop for Node { + fn drop(&mut self) { + self.next.drop(); + } +} +``` + +从上面的代码和注释可以看出为 `Box` 实现的 `drop` 方法中,在 `self.ptr.drop` 后调用的 `deallocate` 会导致非尾递归的情况发生。 + +因此我们需要手动为 `List` 实现 `Drop` 特征: +```rust +impl Drop for List { + fn drop(&mut self) { + let mut cur_link = mem::replace(&mut self.head, Link::Empty); + while let Link::More(mut boxed_node) = cur_link { + cur_link = mem::replace(&mut boxed_node.next, Link::Empty); + // boxed_node 在这里超出作用域并被 drop, + // 由于它的 `next` 字段拥有的 `Node` 被设置为 Link::Empty, + // 因此这里并不会有无边界的递归发生 + } + } +} +``` + +测试下上面的实现以及之前的长链表例子: +```shell +> cargo test + + Running target/debug/lists-5c71138492ad4b4a + +running 2 tests +test first::test::basics ... ok +test first::test::long_list ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured +``` + +完美! + + + +#### 提前优化的好处! + +事实上,我们在这里做了提前优化,否则可以使用 `while let Some(_) = self.pop() { }`, 这种实现显然更加简单. 那么问题来了:它们的区别是什么,有哪些性能上的好处?特别是在链表不仅仅支持 `i32` 时。 + +
+ 点击这里展开答案 + +`self.pop()` 的会返回 `Option`, 而我们之前的实现仅仅对智能指针 `Box` 进行操作。前者会对值进行拷贝,而后者仅仅使用的是指针类型。 + +当链表中包含的值是其他较大的类型时,那这个拷贝的开销将变得非常高昂。 +
+ +## 最终代码 +```rust +use std::mem; + +pub struct List { + head: Link, +} + +enum Link { + Empty, + More(Box), +} + +struct Node { + elem: i32, + next: Link, +} + +impl List { + pub fn new() -> Self { + List { head: Link::Empty } + } + + pub fn push(&mut self, elem: i32) { + let new_node = Box::new(Node { + elem: elem, + next: mem::replace(&mut self.head, Link::Empty), + }); + + self.head = Link::More(new_node); + } + + pub fn pop(&mut self) -> Option { + match mem::replace(&mut self.head, Link::Empty) { + Link::Empty => None, + Link::More(node) => { + self.head = node.next; + Some(node.elem) + } + } + } +} + +impl Drop for List { + fn drop(&mut self) { + let mut cur_link = mem::replace(&mut self.head, Link::Empty); + + while let Link::More(mut boxed_node) = cur_link { + cur_link = mem::replace(&mut boxed_node.next, Link::Empty); + } + } +} + +#[cfg(test)] +mod test { + use super::List; + + #[test] + fn basics() { + let mut list = List::new(); + + // Check empty list behaves right + assert_eq!(list.pop(), None); + + // Populate list + list.push(1); + list.push(2); + list.push(3); + + // Check normal removal + assert_eq!(list.pop(), Some(3)); + assert_eq!(list.pop(), Some(2)); + + // Push some more just to make sure nothing's corrupted + list.push(4); + list.push(5); + + // Check normal removal + assert_eq!(list.pop(), Some(5)); + assert_eq!(list.pop(), Some(4)); + + // Check exhaustion + assert_eq!(list.pop(), Some(1)); + assert_eq!(list.pop(), None); + } +} +``` + +从代码行数也可以看出,我们实现的肯定不是一个精致的链表:总共只有 80 行代码,其中一半还是测试! + +但是万事开头难,既然开了一个好头,那接下来我们一鼓作气,继续看看更精致的链表长什么样。 \ No newline at end of file diff --git a/src/too-many-lists/bad-stack/intro.md b/src/too-many-lists/bad-stack/intro.md new file mode 100644 index 00000000..41fe983e --- /dev/null +++ b/src/too-many-lists/bad-stack/intro.md @@ -0,0 +1,9 @@ +# 糟糕的单向链表栈 +本章,让我们用一个不咋样的单向链表来实现一个栈数据结构,因为不咋样,实现起来倒是很简单。 + +首先,创建一个文件 `src/first.rs` 用于存放本章节的链表代码,虽然糟糕,也不能用完就扔,大家说是不 :P 然后在 `lib.rs` 中添加这一行代码: + +```rust +// in lib.rs +pub mod first; +``` diff --git a/src/too-many-lists/bad-stack/layout.md b/src/too-many-lists/bad-stack/layout.md new file mode 100644 index 00000000..3d92bd02 --- /dev/null +++ b/src/too-many-lists/bad-stack/layout.md @@ -0,0 +1,203 @@ +# 基本数据布局( Layout ) +发现一件尴尬的事情,之前介绍了这么多,但是竟然没有介绍链表是什么...亡羊补牢未为晚也,链表就是一系列存储在堆上的连续数据,大家是否不是发现这个定义跟动态数据 `Vector` 非常相似,那么区别在于什么呢? + +区别就在于链表中的每一个元素都指向下一个元素,最终形成 - 顾名思义的链表: `A1 -> A2 -> A3 -> Null`。 而数组中的元素只是连续排列,并不存在前一个元素指向后一个元素的情况,而是每个元素通过下标索引来访问。 + +既然函数式语言的程序员最常使用链表,那么我们来看看他们给出的定义长什么样: + +```rust +List a = Empty | Elem a (List a) +``` + +mu...看上去非常像一个数学定义,我们可以这样阅读它, 列表 a 要么是空,要么是一个元素后面再跟着一个列表。非常递归也不咋好懂的定义,果然,这很函数式语言。 + +下面我们再来使用 Rust 的方式对链表进行下定义,为了简单性,这先不使用泛型: +```rust +// in first.rs + +pub enum List { + Empty, + Elem(i32, List), +} +``` + +喔,看上去人模狗样,来,运行下看看: +```shell +$ cargo run +error[E0072]: recursive type `List` has infinite size + --> src/first.rs:1:1 + | +1 | pub enum List { + | ^^^^^^^^^^^^^ recursive type has infinite size +2 | Empty, +3 | Elem(i32, List), + | ---- recursive without indirection +help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable + | +3 | Elem(i32, Box), +``` + +帅不过 3 秒的玩意儿~~ 好在这个问题,我们在[之前的章节](https://course.rs/advance/smart-pointer/box.html#将动态大小类型变为-sized-固定大小类型)中就讲过了,简而言之,当前的类型是[不定长](https://course.rs/advance/into-types/sized.html)的,对于 Rust 编译器而言,所有栈上的类型都必须在编译期有固定的长度,一个简单的解决方案就是使用 `Box` 将值封装到堆上,然后使用栈上的定长指针来指向堆上不定长的值。 + +实际上,如果大家有仔细看编译错误的话,它还给出了我们提示: `Elem(i32, Box)`,和我们之前的结论一致,下面来试试: +```rust +pub enum List { + Empty, + Elem(i32, Box), +} +``` + +```shell +> cargo build + + Finished dev [unoptimized + debuginfo] target(s) in 0.22s +``` + +万能的编译器再一次拯救了我们,这次的代码成功完成了编译。但是这依然是不咋滴的 `List` 定义,有几个原因。 + +首先,考虑一个拥有两个元素的 `List`: +```shell +[] = Stack +() = Heap + +[Elem A, ptr] -> (Elem B, ptr) -> (Empty, *junk*) +``` + +这里有两个问题: + +- 最后一个节点分配在了堆上,但是它看上去根本不像一个 `Node` +- 第一个 `Node` 是存储在栈上的,结果一家子不能整整齐齐的待在堆上了 + +这两点看上去好像有点矛盾:你希望所有节点在堆上,但是又觉得最后一个节点不应该在堆上。那再来考虑另一种布局( Layout )方式: +```shell +[ptr] -> (Elem A, ptr) -> (Elem B, *null*) +``` + +在这种布局下,我们无条件的在堆上创建所有节点,最大的区别就是这里不再有 `junk`。那么什么是 `junk`?为了理解这个概念,先来看看枚举类型的内存布局( Layout )长什么样: +```rust +enum Foo { + D1(u8), + D2(u16), + D3(u32), + D4(u64) +} +``` + +大家觉得 `Foo::D1(99)` 占用多少内存空间?是 `u8` 对应的 1 个字节吗?答案是 8 个字节( 为了好理解,这里不考虑 enum tag 所占用的额外空间 ),因为枚举有一个特点,枚举成员占用的内存空间大小跟最大的成员对齐,在这个例子中,所有的成员都会跟 `u64` 进行对齐。 + +在理解了这点后,再回到我们的 `List` 定义。这里最大的问题就是尽管 `List::Empty` 是空的节点,但是它依然得消耗和其它节点一样的内存空间。 + +与其让一个节点不进行内存分配,不如让它一直进行内存分配,无论是否有内容,因为后者会保证节点内存布局的一致性。这种一致性对于 `push` 和 `pop` 节点来说可能没有什么影响,但是对于链表的分割和合并而言,就有意义了。 + +下面,我们对之前两种不同布局的 List 进行下分割: +```shell +layout 1: + +[Elem A, ptr] -> (Elem B, ptr) -> (Elem C, ptr) -> (Empty *junk*) + +split off C: + +[Elem A, ptr] -> (Elem B, ptr) -> (Empty *junk*) +[Elem C, ptr] -> (Empty *junk*) +``` + +```shell +layout 2: + +[ptr] -> (Elem A, ptr) -> (Elem B, ptr) -> (Elem C, *null*) + +split off C: + +[ptr] -> (Elem A, ptr) -> (Elem B, *null*) +[ptr] -> (Elem C, *null*) +``` + +可以看出,在布局 1 中,需要将 `C` 节点从堆上拷贝到栈中,而布局 2 则无需此过程。而且从分割后的布局清晰度而言,2 也要优于 1。 + +现在,我们应该都相信布局 1 更糟糕了,而且不幸的是,我们之前的实现就是布局 1, 那么该如何实现新的布局呢 ?也许,我们可以实现类似如下的 `List` : +```rust +pub enum List { + Empty, + ElemThenEmpty(i32), + ElemThenNotEmpty(i32, Box), +} +``` + +但是,你们有没有觉得更糟糕了...有些不忍直视的感觉。这让我们的代码复杂度大幅提升,例如你现在得实现一个完全不合法的状态: `ElemThenNotEmpty(0, Box(Empty))`,而且这种实现依然有之前的不一致性的问题。 + +之前我们提到过枚举成员的内存空间占用和 enum tag 问题,实际上我们可以创建一个特例: +```rust +enum Foo { + A, + B(ContainsANonNullPtr), +} +``` + +在这里 `null` 指针的优化就开始介入了,它会消除枚举成员 `A` 占用的额外空间,原因在于编译器可以直接将 `A` 优化成 `0`,而 `B` 则不行,因为它包含了非 `null` 指针。这样一来,编译器就无需给 `A` 打 tag 进行识别了,而是直接通过 `0` 就能识别出这是 `A` 成员,非 `0` 的自然就是 `B` 成员。 + +事实上,编译器还会对枚举做一些其他优化,但是 `null` 指针优化是其中最重要的一条。 + +所以我们应该怎么避免多余的 `junk`,保持内存分配的一致性,还能保持 `null` 指针优化呢?枚举可以让我们声明一个类型用于表达多个不同的值,而结构体可以声明一个类型同时包含多个值,只要将这两个类型结合在一起,就能实现之前的目标: 枚举类型用于表示 `List`,结构体类型用于表示 `Node`. + +```rust +struct Node { + elem: i32, + next: List, +} + +pub enum List { + Empty, + More(Box), +} +``` + +让我们看看新的定义是否符合之前的目标: + +- `List` 的尾部不会再分配多余的 junk 值,通过! +- `List` 枚举的形式可以享受 `null` 指针优化,完美! +- 所有的元素都拥有统一的内存分配,Good! + +很好,我们准确构建了之前想要的内存布局,并且证明了最初的内存布局问题多多,编译下试试: +```shell +error[E0446]: private type `Node` in public interface + --> src/first.rs:8:10 + | +1 | struct Node { + | ----------- `Node` declared as private +... +8 | More(Box), + | ^^^^^^^^^ can't leak private type +``` + +在英文书中,这里是一个 warning ,但是在笔者使用的最新版中(Rust 1.59),该 warning 已经变成一个错误。主要原因在于 `pub enum` 会要求它的所有成员必须是 `pub`,但是由于 `Node` 没有声明为 `pub`,因此产生了冲突。 + +这里最简单的解决方法就是将 `Node` 结构体和它的所有字段都标记为 `pub` : +```rust +pub struct Node { + pub elem: i32, + pub next: List, +} +``` + +但是从编程的角度而言,我们还是希望让实现细节只保留在内部,而不是对外公开,因此以下代码相对会更加适合: +```rust +pub struct List { + head: Link, +} + +enum Link { + Empty, + More(Box), +} + +struct Node { + elem: i32, + next: Link, +} +``` + +从代码层面看,貌似多了一层封装,但是实际上 `List` 只有一个字段,因此结构体的大小跟字段大小是相等的,没错,传说中的零抽象数据结构! + +至此,一个令人满意的数据布局就已经设计完成,下面一起来看看该如何使用这些数据。 + + diff --git a/内容变更记录.md b/内容变更记录.md index 79a191b8..46e018c8 100644 --- a/内容变更记录.md +++ b/内容变更记录.md @@ -1,9 +1,22 @@ # ChangeLog 记录一些值得注意的变更。 +## 2022-03-11 + +- 新增章节: [不太优秀的单向链表 - 收尾工作及最终代码](https://course.rs/too-many-lists/bad-stack/final-code.html) + +## 2022-03-10 + +- 新增章节:[不太优秀的单向链表 - 数据布局](https://course.rs/too-many-lists/bad-stack/layout.html) +- 新增章节: [不太优秀的单向链表 - 基本操作](https://course.rs/too-many-lists/bad-stack/basic-operations.html) + + ## 2022-03-09 - 在 [Deref 章节](https://course.rs/advance/smart-pointer/deref.html)中新增开篇引导示例,帮助读者更好的理解当前章节 +- 为部分章节增加[课后练习题链接](https://github.com/sunface/rust-by-practice) +- 移除类型转换章节中一段错误的内容 + ## 2022-03-08