Rust实现链表(A Bad Singly-Linked Stack)
学完Rust语法基础之后,通过实现链表数据结构可以巩固知识点,提高Rust实战能力。“Learn Rust With Entirely Too Many Linked Lists” 是经典的使用Rust实现链表的教程,内容浅入深出,从数据结构到方法实现通过不断试错、思考、纠正,最后得到想要的链表。与很多文章不同的是,Too Many Linked List
不仅会教读者怎么实现链表,更重要的是一步一步试错和纠正的过程,让读者能深刻的理解为什么这么做。
本节学习第一个链表:A Bad Singly-Linked Stack
,主要功能包括:数据结构,Push,Pop,Drop。存储数据暂时以i32
类型为例。
一、涉及知识点
- 枚举、结构体
- 包和模块
- Box智能指针
- Null pointer optimizaiton
- 实现结构体方法
- 所有权: self、&mut self、&self
- 模式匹配
- 流程控制
- 代码测试
- 尾递归
二、数据结构设计
2.1 使用枚举结构
一般而言,链表是分配在堆上通过指针索引的一种数据存储方式,一个链表元素要么为空要么为一个节点(由实际值和指向下个节点的指针构成)。
1 | List a = Empty | Elem a (List a) |
在有null
值的程序语言中(c、c#、java等),直接用结构体或者类就能得到一个链表数据结构:
1 | // C语言 定义链表结构体 |
1 | // c# java 定义链表结构体 |
但是,由于Rust
语言没有null
值,因此若想用一种类型既能表示null
又能表示节点实体最好的办法就是使用枚举结构。很自然地,可以构造出以下数据结构:
1 | // in first.rs |
2.2 问题:垃圾存储、移动元素效率低
以上数据结构虽然能基本表示链表结构,但是细细琢磨可以发现在实际使用时很不合理。让我们用以下标志表示拥有两个节点的链表:
1 | [] = Stack |
两个问题:
- 最后表示空的节点,实际上并不应该为一个节点;
- 其中一个节点不是堆分配。
问题一会产生什么后果呢?答案是会产生垃圾存储,而这一点是由枚举结构的特性所决定:枚举节点的内存大小取决于枚举中占用内存最大的节点。因此,即使为Empty
,也需要申请足够的内存大小,以保证能随时转换为Elem
。以上尾节点(Empty, *junk*)
实质上内存占用与Elem
一致。
问题二又有什么问题呢?让我们先来看看以下两种结构的区别:
1 | 结构 1: |
1 | 结构 2: |
当我们需要分离出节点 C时,结构二只需要将节点B中的指针(ptr)拷贝到栈中,并将原始值置为空即可。而结构一则需要将节点C从堆拷贝至栈中。我们知道,链表的优点之一就是可以通过操纵指针高效地实现数据的再组织,而不用挪动数据本身,结构一反而丢失了这一特性。
2.3 改进结构
为了解决以上两个问题,尝试引入结构体,使用结构体和枚举来表示链表结构:
1 | struct Node { |
1 | [List::More] -> Node(elem A, next ptr) -> Node(elem B, *null*) |
从构建的数据可以看出,该结构不仅解决了尾节点存储垃圾的问题,也解决了节点一会儿堆分配一会儿栈分配的问题。
有好奇宝宝肯定会问,上面不是说Rust
语言没有null
吗?为什么这里结构体里面存储了null
呢?这就涉及到Null Pointer Optimizaiton
的知识点了。
枚举空指针优化:
通常的,枚举变量会包含一个用于表明子类型的
tag
和一块足够存储数据的内存空间。但是,当出现以下结构的枚举时,空指针优化就会发挥作用,若子类型为A,内存中就存为0000 0000
,其他情况则为子类型B(因为它包含一个非零指针),不再额外使用tag
表示子类型。
1
2
3
4 enum Foo {
A,
B(SomeNonNullPointer),
}关于NPO的更多信息可以参考这里std::option - Rust (rust-lang.org)。
2.4 public or private
当我们尝试编译2.3的链表数据结构时,Rust
编译器给出了如下提示:
1 | > cargo build |
因为List
需要暴露出来以供使用所以必须声明为pub
,而Node
为了隐藏实现默认为private
。那么问题来了,当枚举声明为pub
时,其内部子类型必须都是可公开使用的。因此,为了隐藏实现不得不将List
定义为结构体类型:
1 | pub struct List{ |
至此,完成了数据结构设计,代码虽然只有十几行,但是不断优化的过程包含了很多对数据结构和内存分配的思考。
三、链表方法:push、pop
现在,让我们为这个链表添加一些常用方法,首先是最基础的构造方法:
1 | impl List { |
由于在增删链表节点时,涉及到对链表结构修改,所以这里需要提前对
Rust
的所有权系统有基本的了解,Rust
中所有权的主要形式有三种:
self
- Value&mut self
- mutable reference&self
- shared reference这里详细说一下
&mut self
,特点是你可以对你拥有的可变引用的值做任何你想做的事情,只要你在做完后让它处于有效状态就可以了,但唯一不能做的是**move the value out with no replacement
**。
3.1 Push方法
push方法需要向链表中添加节点,由于是模拟栈结构,所以采用头插法:首先新建一个节点,其next指向原链表头节点,然后将新节点赋值为头节点。很自然地得到以下代码:
1 | impl List{ |
编译器意料之中地报了错:
1 | > cargo build |
next:self.head
尝试将self.head
的所有权转移至next
,但是编译器很显然不希望我们这么做。因为这会导致当我们完成可变借用归还所有权后,被借用的数据只有部分被初始化了(self.head
中的内容没了)。正如我们之前所述,当用于可变借用的所有权时,唯一禁止做的事情是:**move the value out with no replacement!
**
因此,我们需要做的就是 move the value out and give it a replacement value
. 在Rust
标准库中,正好提供了一个这样的方法:std::mem::replace
。该函数允许使用者用另一个值替换想要”偷借”出去的值。改正后的代码变为:
1 | pub fn push(&mut self, elem: i32) { |
注意,记得在代码顶部引入
use std::mem;
这里附上
replace
源码,可以看出,函数通过unsafe
实现,并返回一个 拥有所有权的变量:
1
2
3
4
5
6
7
8
9
10 pub const fn replace<T>(dest: &mut T, src: T) -> T {
// SAFETY: We read from `dest` but directly write `src` into it afterwards,
// such that the old value is not duplicated. Nothing is dropped and
// nothing here can panic.
unsafe {
let result = ptr::read(dest);
ptr::write(dest, src);
result
}
}
3.2 Pop 方法
pop 方法会弹出链表中的第一个节点并返回节点值。代码逻辑为:判断链表是否为空,若为空返回Option::None
;若不为空则替换头节点为其next
,并返回原头节点值Some(elem)
。
代码实现如下:
1 | pub fn pop(&mut self)->Option<i32>{ |
尝试编译,再一次被编译器教育:
1 | > cargo build |
当我们尝试将node.next
转移给头节点时,发生了上一节同样的错误。这里不妨回过头仔细想想当pop不为空的链表时,我们本质上是在做哪些操作呢:
- 移除链表头节点
- 移除头节点数据
elem
- 替换头节点为其
next
- 返回
Some(elem)
问题就出现在移除链表头节点
上,当我们使用match &self.head
时,匹配到的node
是头节点的可变引用,因此无法直接移除。那么,不妨仍然考虑使用replace
方法,先将原链表头节点置为空,然后返回得到一个拥有所有权的链表头节点node
,这样就可以将其next
值赋给self.head
作为新的头节点了。
1 | pub fn pop(&mut self) -> Option<i32> { |
至此,完成了push、pop
两个常规方法。
四、测试模块
测试代码往往紧随功能代码之后,并通过新的命名空间作为区分,以避免与功能代码冲突。
1 | //in first.rs |
五、实现Drop方法
同C++
一样,Rust
使用析构函数自动清除不再使用的数据。当一个类型实现了名为 Drop
的trait
时,就表示其具有析构函数。
1 | pub trait Drop { |
当定义的复合类型中的子类型都已实现了Drop
时,我们就不用再为复合类型实现Drop
了。因此,想要清除链表结构只需调用头节点的drop
方法即可。
然而,自动清除的结果并没有预料般那么友好。让我们用下面的例子进行说明:
1 | list -> A -> B -> C |
当清除list
时,会先自动清除A
,而在清除A
时,会先清除B
……,整个过程是一次递归调用。然而,递归调用可能会带来栈溢出的问题,是否会在清除链表是发生呢。从调用逻辑上看,很多人可能会草率地以为“清除链表的递归属于尾递归,不会造成栈溢出”。为了搞清楚到底结果是怎样的,我们不妨手动地为所有结构实现Drop
方法:
1 | impl Drop for List { |
从上面的代码和注释可以看出为 Box<Node>
实现的 drop
方法中,在 self.ptr.drop
后调用的 deallocate
会导致非尾递归的情况发生。
因此我们需要手动为 List
实现 Drop
特征:
1 | impl Drop for List{ |
六、最终代码
1 | use std::mem; |
好吧,通过上面的学习我们得到了一个不太优秀的单向链表。keep going~