数据布局

首先,让我们来研究一下敌人的结构。双向链接列表在概念上很简单,但它就是这样欺骗和操纵你的。这是我们反复研究过的同一种链接列表,但链接是双向的。双倍链接,双倍邪恶。

相比于单向(删掉了 Some/None 这类东西以保持简洁):

... -> (A, ptr) -> (B, ptr) -> ...

我们需要这个:

... <-> (ptr, A, ptr) <-> (ptr, B, ptr) <-> ...

这使你可以从任一方向遍历列表,或使用cursor(游标)来回查找。

为了换取这种灵活性,每个节点必须存储两倍的指针,并且每个操作都必须修复更多的指针。这是一个足够复杂的问题,更容易犯错,所以我们将做大量的测试。

你可能也注意到了,我故意没有画出列表的两端。这正是我们下面的方案中要实现的对方。我们的实现肯定需要两个指针:一个指向列表的起点,另一个指向列表的终点。。

在我看来,有两种值得注意的方法可以做到这一点:“传统节点”和“虚拟节点”。

传统的方法是对堆栈的简单扩展——只需将头部和尾部指针存储在堆栈上:

[ptr, ptr] <-> (ptr, A, ptr) <-> (ptr, B, ptr)
  ^                                        ^
  +----------------------------------------+

这很好,但它有一个缺点:极端情况。现在我们的列表有两个边缘,这意味着极端情况的数量增加了一倍。很容易忘记一个并有一个严重的错误。

虚拟节点方法试图通过在我们的列表中添加一个额外的节点来消除这些极端情况,该节点不包含任何数据,但将两端链接成一个环:

[ptr] -> (ptr, ?DUMMY?, ptr) <-> (ptr, A, ptr) <-> (ptr, B, ptr)
           ^                                                 ^
           +-------------------------------------------------+ 

通过执行此操作,每个节点始终具有指向列表中上一个和下一个节点的实际指针。即使你从列表中删除了最后一个元素,你最终也只是拼接了虚拟节点以指向它自己:

[ptr] -> (ptr, ?DUMMY?, ptr) 
           ^             ^
           +-------------+

一定程度上这非常令人满意和优雅。不幸的是,它有几个实际问题:

问题 1:额外的间接和分配,尤其是对于必须包含虚拟节点的空列表。可能的解决方案包括:

  • 在插入某些内容之前不要分配虚拟节点:简单而有效,但它会添加一些我们试图通过使用虚拟指针来避免的极端情况!
  • 使用静态的 "copy-on-write" 单例虚拟节点,并采用一些非常巧妙的方案,让 "copy-on-write" 检查捎带上正常检查:看,我真的很想,我真的很喜欢这种东西,但我们不能在这本书中走那条路。如果你想看到那种变态的东西,请阅读 ThinVec 的源代码
  • 将虚拟节点存储在栈上 - 这在没有 C++ 风格的移动构造函数的语言中并不实用。我敢肯定,我们可以在这里用pinning做一些奇怪的事情,但我们不会这样做。

问题 2:虚拟节点中存储了什么?当然,如果它是一个整数,那很好,但如果我们存储的是一个满是 Box 的列表呢?我们可能无法初始化这个值!可能的解决方案包括:

  • 让每个节点存储Option<T>:简单有效,但也臃肿烦人。
  • 使每个节点都存储 MaybeUninit。可怕又烦人。
  • 虚拟节点不包含数据字段。这也很诱人,但非常危险和烦人。如果你想看到那种的东西,请阅读 BTreeMap 的来源

对于像 Rust 这样的语言来说,这些虚拟节点方案的问题确实超过了便利性,所以我们将坚持传统的布局。我们将使用与上一章中对不安全队列相同的基本设计:

#![allow(unused)]
fn main() {
pub struct LinkedList<T> {
    front: Link<T>,
    back: Link<T>,
    len: usize,
}

type Link<T> = *mut Node<T>;

struct Node<T> {
    front: Link<T>,
    back: Link<T>,
    elem: T, 
}
}

这还不是一个真正的生产质量的布局。不过还不错。我们可以使用一些魔法技巧来告诉 Rust 我们可以做得更好一些。要做到这一点,我们需要 ... 更加深入。