用太多链表写法学习Rust

有任何问题或想一次性查看所有最终代码吗? 所有代码都在Github上!

说明: 本书当前版本是针对Rust 2018编写的,第一次发布是rustc 1.31 (2018-12-08) 如果你的rust工具链足够新,cargo new创造的Cargo.toml文件应该包含一行 edition = "2018"(如果你在很久以后读到这篇文章,可能看到更大的数字!)。你可以 用旧版工具链,但是会解锁一个隐藏的困难模式,你会得到书里没有提到的额外的编译器 报错。哇哦,有意思!

我经常被问到如何在Rust中实现链表。老实说,答案取决于你的需求是什么,要当场回答这个问 题显然不是超级容易。因此,我决定写这本书来一劳永逸地全面回答这个问题。

在这个系列中,我将通过让你实现6个链表来教你基本和高级的Rust编程。在这样做的过程中,你 应该学会:

  • 下列指针类型: &, &mut, Box, Rc, Arc, *const, *mut
  • 所有权、借用、继承可变性、内部可变性、Copy复制性
  • 所有的关键词: struct, enum, fn, pub, impl, use, ...
  • 模式匹配、泛型、析构器
  • 测试
  • 基本不安全Rust

是的,链表真是如此的可怕,以至于你在制作它们时要处理所有这些概念。

一切都在侧边栏中(在移动端可能会被折叠),但为了快速参考,这里是我们要做的:

  1. 一个不好的单链栈
  2. 一个可以的单链栈
  3. 一个持久的单链栈
  4. 一个不好的但安全的双端队列
  5. 一个不安全的单链队列
  6. TODO:一个好的不安全的双端队列
  7. Bonus: 一大堆愚蠢的链表

为了使我们的得到的结果一致,我将写出所有输入终端的命令。我还将使用Rust的标准 包管理器Cargo来开发这个项目。Cargo并不是写Rust程序所必需的,但它比直接使用 rustc要好得多。如果你只是想玩玩,你也可以通过play.rust-lang.org 在浏览器中运行一些简单的程序。

让我们开始做我们的项目吧:

> cargo new --lib lists
> cd lists

我们会把每个列表放在一个单独的文件中,这样我们就不会失去任何工作。

需要注意的是,真实的Rust学习经历包括写代码,让编译器对你咆哮,并试图弄清楚 这些咆哮到底是什么意思。我将仔细确保这种情况尽可能频繁地发生。学会阅读和理解 Rust普遍优秀的编译器错误和文档,这对于成为一个高效的Rust程序员来说非常重要。

虽然实际上这是一个谎言。在写这本书的过程中,我遇到的编译器错误远远多于我展示 的。特别是,在后面的章节中,我不会展示很多随机的"打错字(复制粘贴)"的错误,就 是你会在每一种语言中都能遇到的错误。这是一次让编译器向我们尖叫的导游之旅

我们的进度会很慢,而且老实说,整个过程中我不会很认真。我认为编程应该是有趣的, 哇哈哈! 如果你是那种想要最大化信息密集、严肃、正式内容的人,这本书不适合你。我 所做的一切都不适合你。你错了。

义务公益广告

我们会完全100%清楚一件事:我就是讨厌链表。恨之入骨。链表是可怕的数据结构。 当然,链表有几个很好的使用案例。

  • 你想做很多大列表的拆分或合并。很多
  • 你正在做一些很棒的无锁并发的事情。
  • 你在写一个内核/嵌入式的东西,想使用一个侵入式列表。
  • 你正在使用纯函数式语言,有限的语义和不能修改东西使得链表比较好用。
  • ......还有更多!

但所有这些情况对于任何编写Rust程序的人来说都是超级罕见的。99%的时间你应该只 使用Vec(数组栈),另外1%的时间你应该使用VecDeque(数组双端队列)。由于较少的 内存分配频率、较低的内存开销、真正的随机访问和缓存定位,这些对于大多数工作负载 来说都是公然优越的数据结构。

链表和Trie一样是小众模糊的数据结构。很少有人会反驳我说Trie是一种小众结 构,你们平均水平程序员在整个生产生涯中很可能永远也不用学会--然而链表却有一些奇 怪的名人地位。我们教每个本科生如何写一个链表。这是唯一的小众集合 I couldn't kill from std::collections。它是 the list in C++!

作为一个社区,我们都应该对链表作为"标准"数据结构说不。它是一个很好的数据结构, 有几个很好的用例,但这些用例是特殊的,而不是常见的。

有几个人显然读了这个PSA的第一段,然后就不读了。比如,他们会试图通过列举我的伟大用 例列表中的一件事来反驳我的论点。就在第一段之后的那件事!

为了让我可以直接链接到详细的论点,下面是我看到的几种反驳的尝试,以及我对它们的回应。 如果你只是想学习一些Rust,欢迎跳到第一章!

性能并不总是重要的

是的! 也许你的应用程序是I/O瓶颈的,或者相关代码是在一些不重要的冷门的情况下。但这 甚至不是一个使用链表的论点。这是一个使用任何东西的论点。为什么要使用链表呢?使用 链接的哈希图吧!

如果性能不重要,那么应用数组的自然默认值肯定是可以的。

如果你有一个指针,那么他们会有O(1)的分割-追加-插入-删除

没错! 不过正如Bjarne Stroustrup所指出的,如果获取指针所花费的时间与复制 一个数组中所有元素所花费的时间完全相差无几的话,这实际上并不重要(这确实相当快)。

除非你的工作负载是以拆分和合并成本为主要开销,否则其他每个操作由于缓存效应和代码复杂 度所需要的惩罚会消除任何理论上的收益。

但是是的,如果你剖析发现你的应用要花大量的时间在拆分和合并上,你可能从链表中获益。

我负担不起摊销

你已经进入了一个相当小众的领域--大多数人都能承受摊销。不过,数组在最坏的情况下也是 要摊销的。仅仅因为你使用的是一个数组,并不意味着你有摊销的成本。如果你能预测你要存储 多少元素(甚至有一个上限),你就可以预留所有你需要的空间。根据我的经验,能够预测你需 要多少元素是很常见的。特别是在Rust中,所有的迭代器都会提供一个size_hint,正是针对 这种情况。

那么pushpop将是真正的O(1)操作。而且它们会比链表上的pushpop快得多。你做 一个指针偏移,写入字节,然后递增一个整数。不需要使用任何类型的分配器。

这对低延迟来说如何?

但是,是的,如果你不能预测你的负载,那么可以节省在最坏的情况下的延迟!

链表浪费的空间更少

嗯,这很复杂。一个“标准”的数组大小调整策略是增长或缩小,使最多一半的数组为空。这确实 浪费了很多空间。特别是在Rust中,我们不会自动收缩集合(如果你只是要把它重新填满,那就 太浪费了),所以浪费的空间可能会接近无穷大!

但这是最坏的情况。在最好的情况下,一个数组栈对整个数组只有三个指针的开销。基本上没有 开销。

而链表则是无条件的浪费每个元素的空间。一个单链列表浪费一个指针,而一个双链列表则浪费 两个指针。与数组不同,相对的浪费与元素的大小成正比。如果你有巨大的元素,这接近于0浪费 。如果你有微小的元素(比如说,字节),那么这可能是高达16倍的内存开销(32位上是8倍)!

实际上,这更像是23倍(32位上是11倍),因为会在字节上添加填充,将整个节点的大小对齐到 指针上。

这也是假设你的分配器的最佳情况:分配和取消分配节点是在密集地进行的,你不会因为碎片化 而损失内存。

但是是的,如果你有巨大的元素,不能预测你的负载,并且有一个像样的分配器,就可以节省内 存。

我在<函数式语言>中一直使用链表。

很好! 在函数式语言中使用链表是超级优雅的,因为你可以在没有任何改变的情况下对它们进行 操作,可以递归地描述它们,而且由于惰性的魔力,还可以使用无限列表。

具体来说,链表是不错的,因为它们代表了一个迭代,而不需要任何可变的状态。下一步只是访 问下一个子列表。

然而需要注意的是,Rust可以在数组上进行模式匹配,并且用slices来谈论子数组 ! 实际上,它在某些方面甚至比函数式链表更有表现力,因为你可以谈论最后一个元素,甚至“ 没有第一和最后两个元素的数组”或其他任何你想要的疯狂的东西。

的确,你不能用分片来构建一个列表。你只能把它们拆开。

为了偷懒,我们改用iterators。这些可以是无限的,你可以像函数式列表一样对它们进行 映射、过滤、反转和连接,而且这一切都会同样惰性地完成。这里不奇怪:切片也可以被转换成 迭代器。

但是是的,如果你限于不可变的语义,链表可以非常好。

请注意,我并不是说函数式编程一定很弱或不好。然而它从根本上讲有语义限制的:你基本 上只允许谈论事情是怎样的,而不允许谈论它们应该怎样做。这实际上是一个特点,因为 它使编译器能够进行大量的怪异变换,并有可能找出最好的方法来做事情,而不用你 担心。然而这是以能够担心为代价的。通常有逃生舱口,但在某些限制下,你又只是在写过程 式代码。

即使是在函数式语言中,当你真正需要数据结构时,你也应该努力为任务使用合适的数据结构。 是的,单链表是你控制数据流的主要工具,但它们对于实际存储一堆数据和查询数据来说是一种 非常糟糕的方式。

链表是构建并发数据结构的好方法!

是的! 虽然编写一个并发数据结构真的是一个完全不同的野兽,而且不是一件值得轻视的事情。 当然也不是很多人都会考虑去做的事情。一旦写好了一个,你也不是真的选择使用链表。你是选 择使用MPSC队列什么的。在这种情况下,实现策略是相当遥远的!

但没错,链表是无锁并发的黑暗世界中的不折不扣的英雄。

咕哝咕哝的内核嵌入了一些干扰性的东西。

这很小众。你说的是一种情况,你甚至没有使用你的语言的运行时。这难道不是一个危险的 信号,你在做一些奇怪的事情吗?

这也是非常不安全的。

但是肯定的。要在栈上构建你那令人敬畏的零分配列表。

迭代器不会因为无关的插入/删除而失效。

你这舞跳得可真微妙。特别是当你没有垃圾回收器的时候。我可能会说,你的控制流和所有 权模式可能有点太纠结了,这取决于细节。

但是是的,你可以用光标做一些非常酷的疯狂的事情。

它们很简单,而且很适合教学

嗯,是的。你正在读一本专门针对这个前提的书。好吧,单链表是非常简单的。双链表可能会 变得有点粗糙,我们会看到。

深呼吸

好吧,那就不说了。让我们写一个亿万个链表。

现在开始第一章!

On to the first chapter!