1.1 数据布局
6 分钟阅读
原文链接: https://rust-unofficial.github.io/too-many-lists/first-layout.html
好,链表是什么?基本上就是堆上(内核党先别吵!)一串按顺序互相指向的数据。链表是过程式程序员不该用十英尺杆子碰的东西,却是函数式程序员什么都用的结构。公平起见,我们向函数式程序员要链表的定义。他们大概会给你类似下面的定义:
| |
大致读作:「List 要么是 Empty,要么是 Element 后面跟着一个 List」。这是用和类型(sum type)表达的递归定义,fancy 名字指的是「可以有不同取值、且取值类型可能不同的类型」。Rust 把和类型叫 enum!若你来自类 C 语言,这就是你熟悉并喜爱的 enum,只是开到了满功率。把函数式定义抄进 Rust 吧!
暂时不用泛型,保持简单。只支持存有符号 32 位整数:
| |
呼,累死了。先编译一下:
| |
好吧。不知你怎样,我确实觉得被函数式编程社区背叛了。
若我们真去看错误信息(熬过背叛感之后),会发现 rustc 其实在告诉我们怎么解决:
insert indirection (e.g., a
Box,Rc, or&) at some point to makefirst::Listrepresentable
好,box。那是啥?谷歌 rust box……
看看……
pub struct Box<T>(_);A pointer type for heap allocation. See the module-level documentation for more.
点链接
Box<T>,口语里叫「box」,提供 Rust 里最简单的堆分配形式。Box 拥有这块分配,离开作用域时 drop 其内容。Examples
Creating a box:
let x = Box::new(5);Creating a recursive data structure:
| |
| |
This will print
Cons(1, Box(Cons(2, Box(Nil)))).Recursive structures must be boxed, because if the definition of Cons looked like this:
Cons(T, List<T>),It wouldn’t work. This is because the size of a List depends on how many elements are in the list, and so we don’t know how much memory to allocate for a Cons. By introducing a Box, which has a defined size, we know how big Cons needs to be.
哇。这也许是我见过最贴切、最有用的文档。字面意义上文档第一件事就是我们正想写的、为何不行、怎么修。
文档真棒。
好,照做:
| |
| |
嘿,编过了!
……但这其实是 List 的非常愚蠢的定义,原因有好几个。
考虑有两个元素的链表:
| |
有两个关键问题:
- 我们分配了一个节点,它只说「我其实不是 Node」
- 其中一个节点根本不是堆分配的。
表面上看,这两点似乎互相抵消。我们多堆分配了一个节点,但有一个节点不必堆分配。然而考虑下面这种可能的布局:
| |
这种布局里我们无条件堆分配所有节点。关键区别是第一种布局里没有垃圾。这垃圾是什么?要理解它,得看 enum 在内存里怎么布局。
一般来说,若有这样的 enum:
| |
Foo 需要存一个整数,表示它代表 enum 的哪个变体(D1、D2、…… Dn)。这就是 enum 的标签。还需要足够空间存 T1、T2、…… Tn 中最大的那个(再加一些空间满足对齐)。
这里的大结论是:尽管 Empty 只有一比特信息,它必然占用指针加一个元素的空间,因为它随时可能变成 Elem。因此第一种布局会堆分配一个充满垃圾的额外元素,比第二种布局多占一点空间。
有一个节点完全不分配,也许令人惊讶,也比总是分配更糟。因为这给我们不统一的节点布局。对压入弹出影响不大,但对拆分合并链表有影响。
考虑两种布局下拆分链表:
| |
| |
布局 2 的拆分只需把 B 的指针拷到栈上并把旧值置空。布局 1 最终也做同样的事,但还要把 C 从堆拷到栈。合并是反向过程。
链表少数好处之一是可以在节点里构造元素,然后在链表间自由挪动而不移动元素本身。你只摆弄指针,东西就「动了」。布局 1 毁掉了这个性质。
好,我相当确信布局 1 很糟。怎么重写 List?可以:
| |
希望你觉得这更糟。最突出的是这让逻辑复杂很多,因为出现了完全无效的状态:ElemThenNotEmpty(0, Box(Empty))。它仍然有元素分配不统一的问题。
但它有一个有趣性质:完全避免为 Empty 分配,堆分配总数减 1。不幸的是,这样反而浪费更多空间!因为之前的布局利用了空指针优化。
我们之前看到每个 enum 都要存标签,说明其比特代表哪个变体。但若有一种特殊 enum:
| |
空指针优化就会生效,省掉标签所需的空间。若变体是 A,整个 enum 全设为 0。否则是 B。这可行是因为 B 不可能全是 0,因为它包含非零指针。巧妙!
能想到其他能做这种优化的 enum 和类型吗?其实很多!所以 Rust 不规定 enum 布局。Rust 还会为我们做几种更复杂的 enum 布局优化,空指针优化肯定最重要!
这意味着 &、&mut、Box、Rc、Arc、Vec 以及 Rust 里其他几种重要类型放在 Option 里时没有额外开销!
(这些我们后面会陆续讲到。)
那怎么避免额外垃圾、统一分配,又得到甜蜜的空指针优化?需要更好地区分「有元素」和「再分配一条链表」。为此得稍微 C 一点:结构体!
enum 让我们声明能包含若干取值之一的类型,struct 让我们声明同时包含多个*值的类型。把 List 拆成两种类型:List 和 Node。
和之前一样,List 要么是 Empty,要么是一个元素后面跟着另一条 List。
把「有元素后面跟着另一条 List」用完全独立的类型表示,就能把 Box 放到更优的位置:
| |
检查优先级:
- 链表尾部从不分配额外垃圾:✓
enum是美味的空指针优化形式:✓- 所有元素统一分配:✓
好!我们其实正好构造了用来证明第一种布局(官方 Rust 文档建议的那种)有问题的布局。
| |
:(
Rust 又对我们发火了。我们把 List 标成 public(因为希望别人能用),但没把 Node 标 public。问题是 enum 的内部完全 public,我们不能公开谈论私有类型。可以把整个 Node 都 public,但 Rust 里通常倾向把实现细节藏起来。把 List 改成 struct,就能隐藏实现细节:
| |
因为 List 是只有一个字段的 struct,它的大小和该字段相同。零成本抽象,耶!
| |
好,编过了!Rust 挺生气,因为在它看来我们写的东西完全没用:从没用过 head,用我们库的人也不能,因为它是私有的。传递下去,Link 和 Node 也没用。解决它!给 List 实现点代码!