从简单的链表定义初探Rust内存布局和内部细节

165 阅读3分钟

熟悉函数式编程语言的朋友可能很喜欢如下定义:

List e = Empty | Elem e (List e)

这是一种典型的递归表达,非常 简洁,我们把他迁移到 Rust 语言实现:

pub enum List {
    Empty,
    Elem(i32, List)
}

同样 简洁 的表达,但是在 Rust 中无法通过编译,因为 Rust 默认是 栈内存分配 ,而上述定义由于Elem递归定义,导致无法在编译时确定大小,从而无法通过编译。 我们可以通过简单的修改解决:

pub enum List {
    Empty,
    Elem(i32, Box<List>)
}

上述定义在 Rust 官方教程书中关于Box类型章节 (链接) 的讲解中可以看到。 BoxRust 中是声明对象在 堆内存 分配,这样我们就可以像其他语言(GO, JAVA)那样可以仅持有对象引用指针,而引用指针的大小在编译时是固定大小的,就可以完美解决我们编译问题。 这样我们就可以在 Rust 中定义一个简洁的链表对象。

let a_list = List::Elem(1, Box::new(List::Elem(2, Box::new(List::Empty))))

至此大家可能以为结束了,实际该定义存在一些潜在的问题:

  • 我们上面定义的a_list对象实际头节点会分配在栈中,而其余后续节点则在堆中分配,这导致我们的 List 内存分配不统一 内存不统一会导致一些潜在的消耗,比如我们要对链表进行拆分,就会导致链表头节点从堆内存到栈内存的Copy,同理合并操作会导致一次栈内存到堆内存的Copy,带来潜在开销。但我们通常使用链表的目的就是减少 Copy 操作。显然与我们的目的相悖。 聪明的朋友可能会想到,我们可以用Box对象把a_list定义包裹起来:

    let a_list = Box::new(List::Elem(1, Box::new(List::Elem(2, Box::new(List::Empty)))))
    

    这样的操作显然不合适,我们每次定义链表都需要显式的声明其在堆内存中分配。这个在协作中是很难把握的,我们无法预料使用者的习惯。

  • 另一个问题就是内存浪费。由于 Rust 的枚举设计特殊,我们可以在枚举中定义带有不同属性的对象,为了在编译时确定对象大小,枚举对象的大小会取最大对象的长度,其他对象长度做填充处理,保持对象长度的统一。回想我们定义的List枚举,其中Empty没有字段,Elem包含一个i32类型和一个Box类型。因此,即使我们使用Empty对象,其至少也会有两个字段长度的填充(具体长度不做讨论,我们确定会有填充即可)。这样的话,我们的尾节点必然存在内存浪费。

显然,我们上述链表定义看似优雅,实则问题颇多。这些问题并不是我们能够轻易发现的,可以确定的是,这些问题其实是由于 Rust 语言设计导致的。GOJAVA 等带 GC 的堆内存语言并不会出现该问题。Rust 的实践难度可见一斑,Rust 在给我们提供高性能的同时,也需要我们更加关注底层细节,只有熟悉底层细节,我们在能更好的掌控这门语言,写出更高效的代码。

至于如何改进该定义,在此不做讨论。大家有兴趣可以阅读原文章,原文中还讨论了 Rust 关于枚举类型零开销优化的细节。本文并未提及。

作者水平有限,如有纰漏,敬请谅解。