如何在rust 中实现 带有循环引用的数据结构
为了保证安全,Rust 编译器会仔细跟踪整个程序的所有权和引用。这使得编写某些类型的数据结构具有挑战性;特别是具有循环引用的数据结构。
从一个简单的二叉树开始:
rust
struct Tree {
root: Option<Node>,
}
struct Node {
data: i32,
left: Option<Box<Node>>,
right: Option<Box<Node>>,
}
由于 Rust 编译器需要能够在 编译时计算 struct 的大小,因此 left 和 right 通常使用 堆分配的 Box 。这些Box被包裹在 Option 中,因为节点的左右子节点可能为空。
现在假设我们要向每个节点添加 parent 链接 。这对于某些树结构很有用;例如,在binary search tree (BST)中,parent 链接可用于有效地查找节点的后继节点。我们怎样才能做到这一点?
"显而易见"的方法失败了
我们 不能只向 Node 添加 parent: Option<Box<Node>>
字段,因为这意味着节点拥有(own)其父节点(注:Box 是具有所有权的智能指针);这显然是错误的。事实上,我们最初的 Node 定义已经明确表明父级拥有(own)其子级(比如:左子树 left: Option<Box<Node>>
),而不是相反。
所以我们可能想添加一个 引用;父级拥有(own)其子级,但子级可以引用(refert)父级。听起来不错;尝试一下:
rust
struct Node {
data: i32,
left: Option<Box<Node>>,
right: Option<Box<Node>>,
parent: Option<&Node>,
}
Rust 会拒绝编译它,并要求显式的生命周期参数。当将引用存储在结构体字段中时 ,Rust 想知道该引用的生命周期与结构体本身的生命周期有何关系。一般来说,可以这样做:
rust
struct Tree<'a> {
root: Option<Node<'a>>,
}
struct Node<'a> {
data: i32,
left: Option<Box<Node<'a>>>,
right: Option<Box<Node<'a>>>,
parent: Option<&'a Node<'a>>,
}
现在生命周期是明确的:我们告诉编译器 parent
引用的生命周期与 Node
本身相同。这个结构体定义可以编译,但是编写操作它的实际代码 在编译时 很快就会与借用检查器发生争执。考虑将新子节点插入当前节点的代码;要改变 当前节点,对其的 可变引用 必须在范围内。同时,新子节点的parent链接是对该节点的引用。借用检查器不会让我们 对 还在存活期的 可变引用 的对象 创建不可变引用(The borrow checker won't let us create a reference to an object which already has a live mutable reference to it);当对 对象的任何其他引用都处于活动状态时,它也不会让我们改变对象。
作为练习,尝试使用 此 Node 定义 为树编写插入方法;你很快就会遇到这个问题。parent: Option<&'a Node<'a>>
不好用,也不能用!!
该怎么办?
显然,"显而易见"的方式行不通。事实上,从首要原则考虑,它不应该。在这篇文章中,我使用带有·parent·链接的 BST 作为一个简单的案例研究,但还有更明显(也许更有用)的用例。考虑 图(graph)的数据结构;一个节点有指向其他节点的边,两个节点可以很容易地相互指向。谁拥有(own)谁?由于这个问题在一般情况下无法在编译时得到回答,这意味着我们不能只对这些"指向"关系使用普通的 Rust 引用。我们需要更聪明灵活一些。
在一次又一次地解决这个问题后,Rust 程序员已经确定了三种可能的解决方案:
- 通过使用指向
std::cell:RefCell
的引用计数指针 (std::rc::Rc
) 将借用检查推迟到运行时,而不是在编译时检查。 - 集中所有权(例如,所有节点都由 Tree 中的 节点vector 拥有),然后引用成为句柄(上述向量的索引)。
- 使用原始指针和 unsafe 块。
在这篇文章中,我将介绍这些方法中的每一种,应用于实现具有插入、删除、"获取后继get successor"方法和中序遍历的功能相当完整的 BST。这篇文章的完整代码可以在 this repository存储库中找到;这篇文章仅介绍了每种方法的一些有趣的片段。请参阅代码存储库以获取带有注释和广泛测试的完整实现。
使用 Rc
和 RefCell
进行运行时借用检查
这种方法使用 Rust 标准库中的两种数据结构的组合:
-
std::rc::Rc
是一个引用计数指针,提供 堆分配数据的 共享 所有权。Rc
的多个实例可以引用相同的数据;当所有 引用都消失时,堆分配就会被删除[1]。这与 C++ 中的shared_ptr
非常相似。与
Rc
相对应的弱版本std::rc::Weak
;这表示指向 其他Rc
拥有的数据的 弱指针。虽然我们可以通过 Weak 访问数据,但如果 仅剩下弱指针 (即所有强指针都已经销毁),则分配(allocation)将被丢弃(dropped)。在 C++ 中,这将是weak_ptr
。(这说明弱指针 不影响对象的生命周期,存在则引用,不存在则为空。而且对象在销毁时并不考虑是否还存在弱引用,只会考虑是否存在强应用。弱引用不会增加引用计数,而强引用会。)
-
std::cell::RefCell
是一个可变 内存位置,具有动态 检查的借用规则(这相对于编译时静态借用检查)。 RefCell 允许我们获取并传递对堆数据的引用,而无需 编译期的 借用检查器的检查。然而,它仍然是安全的;所有相同的借用规则均由 RefCell 在运行时强制执行。
您可以在here查看实现此方法的完整代码。接下来,我将重点介绍一些有趣的部分。
这就是如何定义 BST 数据结构 [2]:
rust
use std::cell::RefCell;
use std::rc::{Rc, Weak};
pub struct Tree {
count: usize,
root: Option<NodeLink>,
}
type NodeLink = Rc<RefCell<Node>>;
#[derive(Debug)]
struct Node {
data: i32,
left: Option<NodeLink>,
right: Option<NodeLink>, //Rc<RefCell<Node>>
parent: Option<Weak<RefCell<Node>>>,//对父级的引用是Weak 且可变 Refcell
}
Own的"链接"由 Option<Rc<RefCell<Node>>>
表示,体现在Rc
;非Own 链接由 Option<Weak<RefCell<Node>>>
表示,体现在Weak
。让我们看一些有代表性的代码示例:
rust
/// Insert a new item into the tree; returns `true` if the insertion
/// happened, and `false` if the given data was already present in the
/// tree.
pub fn insert(&mut self, data: i32) -> bool {
if let Some(root) = &self.root {
if !self.insert_at(root, data) {
return false;
}
} else {
self.root = Some(Node::new(data));
}
self.count += 1;
true
}
// Insert a new item into the subtree rooted at `atnode`.
fn insert_at(&self, atnode: &NodeLink, data: i32) -> bool {
let mut node = atnode.borrow_mut(); //可变借用
if data == node.data {
false
} else if data < node.data {
match &node.left {
None => {
let new_node = Node::new_with_parent(data, atnode);
node.left = Some(new_node);
true
}
Some(lnode) => self.insert_at(lnode, data),
}
} else {
match &node.right {
None => {
let new_node = Node::new_with_parent(data, atnode);
node.right = Some(new_node);
true
}
Some(rnode) => self.insert_at(rnode, data),
}
}
}
为简单起见,本文中的代码示例将 root上的操作分离为顶级函数/方法,然后调用在节点级别操作的递归方法。在本例中, insert_at
获取 链接并将新数据作为该节点的子节点插入。它保留了 BST 不变量(较小表示左子节点,较大表示右子节点)。这里值得注意的是最开始的 borrow_mut()
调用。它从 atnode
指向的 RefCell
获取 可变引用。但这 不仅仅是一个常规的 Rust 可变引用,如 &mut
;相反,它是一种名为 std::cell::RefMut
的特殊类型。这就是 可变性魔法发生的地方 - 看不到 &mut
,但代码实际上可以改变底层数据 [3]。
重申一下,这段代码仍然是安全的;如果您尝试在 RefCell 上执行另一个 borrow_mut()
,而前一个 RefMut
仍在范围内,您将遇到运行时panic。运行时的安全性得到保证。
另一个有趣的例子是私有 find_node
方法,它从某个节点开始查找并返回具有给定数据的节点:
rust
/// Find the item in the tree; returns `true` iff the item is found.
pub fn find(&self, data: i32) -> bool {
self.root
.as_ref()
.map_or(false, |root| self.find_node(root, data).is_some())
}
fn find_node(&self, fromnode: &NodeLink, data: i32) -> Option<NodeLink> {
let node = fromnode.borrow();
if node.data == data {
Some(fromnode.clone()) //clone 节点
} else if data < node.data {
node.left
.as_ref()
.and_then(|lnode| self.find_node(lnode, data))
} else {
node.right
.as_ref()
.and_then(|rnode| self.find_node(rnode, data))
}
}
开头的 .borrow()
调用是我们如何要求 RefCell
提供对内部数据的 不可变引用(当然,这不能在运行时与任何可变引用共存)。当我们返回找到的节点时,我们 clone Rc ,因为我们需要该节点的单独共享所有者。这让 Rust 保证在返回的 Rc 仍然存在时节点不会被删除。
正如完整代码示例所示,这种方法是可行的。不过,需要大量的练习和耐心才能做到正确,至少对于没有经验的 Rust 程序员来说是这样。由于每个节点都包含在三个间接级别( Option 、 Rc 和 RefCell )中,因此编写代码可能会有些棘手,因为在任何时候您都必须记住您"当前处于"哪个级别的间接关系。
这种方法的另一个缺点是获得对存储在树中的数据的简单引用并不容易。正如您在上面的示例中看到的,顶级 find
方法不返回节点或其内容,而只是返回一个布尔值。这不太好;例如,它使 后继 方法不是最优的。这里的问题是,对于 RefCell
我们不能只返回对数据的常规 &
引用,因为 RefCell
必须保持运行时跟踪所有的借用。我们只能返回 std::cell::Ref
,但这会泄漏实现细节。这不是一个致命的缺陷,只是在使用这些类型编写代码时要记住的一点。
使用vector中的句柄作为节点引用
我们要讨论的第二种方法是使用简单的 Vec 使 Tree own在其中创建的所有节点。然后,所有节点引用都变成"句柄"------该vector的索引。以下是数据结构:
rust
pub struct Tree {
// All the nodes are owned by the `nodes` vector. Throughout the code, a
// NodeHandle value of 0 means "none".
root: NodeHandle,
nodes: Vec<Node>,
count: usize,
}
type NodeHandle = usize;
#[derive(Debug)]
struct Node {
data: i32,
left: NodeHandle,
right: NodeHandle,
parent: NodeHandle,
}
再次强调,完整的代码可以在 on GitHub上找到;在这里我将展示一些重要的部分。这里是插入:
rust
/// Insert a new item into the tree; returns `true` if the insertion
/// happened, and `false` if the given data was already present in the
/// tree.
pub fn insert(&mut self, data: i32) -> bool {
if self.root == 0 {
self.root = self.alloc_node(data, 0);
} else if !self.insert_at(self.root, data) {
return false;
}
self.count += 1;
true
}
// Insert a new item into the subtree rooted at `atnode`.
fn insert_at(&mut self, atnode: NodeHandle, data: i32) -> bool {
if data == self.nodes[atnode].data {
false
} else if data < self.nodes[atnode].data {
if self.nodes[atnode].left == 0 {
self.nodes[atnode].left = self.alloc_node(data, atnode);
true
} else {
self.insert_at(self.nodes[atnode].left, data)
}
} else {
if self.nodes[atnode].right == 0 {
self.nodes[atnode].right = self.alloc_node(data, atnode);
true
} else {
self.insert_at(self.nodes[atnode].right, data)
}
}
}
其中 alloc_node 是:
rust
// Allocates a new node in the tree and returns its handle.
fn alloc_node(&mut self, data: i32, parent: NodeHandle) -> NodeHandle {
self.nodes.push(Node::new_with_parent(data, parent));
self.nodes.len() - 1
}
使用 Option<Rc<RefCell<...>>>
编写代码后,这种句柄方法感觉非常简单。没有间接层;句柄是一个索引;对节点的引用是句柄;句柄 0 表示"无",就是这样[4]。
该版本也可能比链接版本更高效,因为它的 堆分配少得多,并且数据存储在单个 nodes: Vec<Node>
对缓存非常友好。
也就是说,这里也存在一些问题。
首先,我们将部分安全掌握在自己手中。虽然这种方法不会导致内存损坏、双重释放或访问已释放的指针,但它可能会导致运行时恐慌和其他问题,因为我们处理向量的"原始"索引。由于错误,这些事件可能会超出向量的边界,或者指向错误的槽等。例如,没有什么可以阻止我们在存在"实时句柄"的情况下修改 一个槽位的数据。
另一个问题是删除树节点。现在,代码只是通过不使用任何实时句柄指向该节点来"删除"该节点。这使得通过树的方法无法访问该节点,但它不会释放内存。事实上,这个 BST 实现永远不会释放任何东西:
rust
// Replaces `node` with `r` in the tree, by setting `node`'s parent's
// left/right link to `node` with a link to `r`, and setting `r`'s parent
// link to `node`'s parent.
// Note that this code doesn't actually deallocate anything. It just
// makes self.nodes[node] unused (in the sense that nothing points to
// it).
fn replace_node(&mut self, node: NodeHandle, r: NodeHandle) {
let parent = self.nodes[node].parent;
// Set the parent's appropriate link to `r` instead of `node`.
if parent != 0 {
if self.nodes[parent].left == node {
self.nodes[parent].left = r;
} else if self.nodes[parent].right == node {
self.nodes[parent].right = r;
}
} else {
self.root = r;
}
// r's parent is now node's parent.
if r != 0 {
self.nodes[r].parent = parent;
}
}
对于现实世界的应用程序来说,这显然是错误的。至少,可以通过创建未使用索引的"空闲列表"来改进此实现,该列表可以在添加节点时重用。一个更雄心勃勃的方法可能是为节点实现一个成熟的垃圾收集器。如果您愿意接受挑战,请尝试一下;-)
使用原始指针和不安全块
要讨论的第三种也是最后一种方法是使用原始指针和 unsafe 块来实现 BST。如果您有 C/C++ 背景,这种方法感觉非常熟悉。完整的代码在here。
rust
pub struct Tree {
count: usize,
root: *mut Node,
}
#[derive(Debug)]
struct Node {
data: i32,
// Null pointer means "None" here; right.is_null() ==> no right child, etc.
left: *mut Node,
right: *mut Node,
parent: *mut Node,
}
节点链接变为 *mut Node
,它是指向 可变 Node 的原始指针。使用原始指针与编写 C 代码非常相似,但在通过这些指针分配、取消分配和访问数据方面存在一些特殊之处。让我们从分配开始;这是 Node 构造函数:
rust
impl Node {
fn new(data: i32) -> *mut Self {
Box::into_raw(Box::new(Self { //为 原始指针分配新内存的最简单方法是**使用 Box::into_raw`
data,
left: std::ptr::null_mut(),
right: std::ptr::null_mut(),
parent: std::ptr::null_mut(),
}))
}
fn new_with_parent(data: i32, parent: *mut Node) -> *mut Self {
Box::into_raw(Box::new(Self {
data,
left: std::ptr::null_mut(),
right: std::ptr::null_mut(),
parent,
}))
}
}
我发现 为 原始指针分配新内存的最简单方法是 使用 Box::into_raw
,只要我们记住从那时起我们就可以取消分配该内存(稍后会详细介绍),它就可以很好地工作。
让我们看看插入是如何工作的:
rust
/// Insert a new item into the tree; returns `true` if the insertion
/// happened, and `false` if the given data was already present in the
/// tree.
pub fn insert(&mut self, data: i32) -> bool {
if self.root.is_null() {
// 这里创建了一个指针并赋给了root 指针
self.root = Node::new(data);
} else {
if !insert_node(self.root, data) {
return false;
}
}
self.count += 1;
true
}
// Inserts `data` into a new node at the `node` subtree.
fn insert_node(node: *mut Node, data: i32) -> bool {
unsafe {
if (*node).data == data {
false
} else if data < (*node).data {
if (*node).left.is_null() {
(*node).left = Node::new_with_parent(data, node);
true
} else {
insert_node((*node).left, data)
}
} else {
if (*node).right.is_null() {
(*node).right = Node::new_with_parent(data, node);
true
} else {
insert_node((*node).right, data)
}
}
}
}
这里值得注意的一点是 unsafe
块, insert_node
的主体被包装在其中。这是必需的,因为此代码存在 解引用原始指针。在 Rust 中,可以分配指针并传递它们,无需特殊规定,但 解引用指针需要 unsafe 。
让我们看看删除节点是如何工作的;这是 replace_node
,它执行与我们在节点句柄方法中看到的类似命名的方法相同的任务:
rust
// Replaces `node` with `r` in the tree, by setting `node`'s parent's
// left/right link to `node` with a link to `r`, and setting `r`'s parent
// link to the `node`'s parent. `node` cannot be null.
fn replace_node(&mut self, node: *mut Node, r: *mut Node) {
unsafe {
let parent = (*node).parent;
if parent.is_null() {
// Removing the root node.
self.root = r;
if !r.is_null() {
(*r).parent = std::ptr::null_mut();
}
} else {
if !r.is_null() {
(*r).parent = parent;
}
if (*parent).left == node {
(*parent).left = r;
} else if (*parent).right == node {
(*parent).right = r;
}
}
// node is unused now, so we can deallocate it by assigning it to
// an owning Box that will be automatically dropped.
//如何通过原始指针释放堆数据:使用 Box::from_raw 。
// 这使得 Box 取得数据的所有权; Box 有一个析构函数,因此当它超出范围时它实际上会释放它
Box::from_raw(node);
}
}
这演示了我们如何通过原始指针释放堆数据:使用 Box::from_raw 。这使得 Box 取得数据的所有权; Box 有一个析构函数,因此当它超出范围时它实际上会释放它。
这给我们带来了重要的一点:我们现在必须负责释放 Tree 的内存。与之前的方法不同,默认的 Drop
实现在这里不会执行,因为 Tree 中包含的唯一内容是 root: *mut Node
并且 Rust 不知道如何实现"drop"那个。如果我们在没有显式实现 Drop
特征的情况下运行测试,就会出现内存泄漏。下面是一个简单的 Drop 实现来解决这个问题:
rust
impl Drop for Tree {
fn drop(&mut self) {
// Probably not the most efficient way to destroy the whole tree, but
// it's simple and it works :)
while !self.root.is_null() {
self.remove_node(self.root);
}
}
}
作为练习,尝试实现更高效的 Drop !
使用原始指针编写代码感觉相当自然;虽然最终的 LOC 计数相似,但原始指针所需的精神负担比使用 Option<Rc<RefCell<Node>>>
[5]明显减轻。虽然我没有对它进行基准测试,但我的直觉是 指针版本也更高效;至少,它避开了 RefCell 所做的动态借用检查。当然,另一面是安全性的丧失。使用 unsafe 版本,我们可能会遇到所有古老的 C 内存错误。
结论
这篇文章的目的是回顾在 Rust 中实现重要链接数据结构可以采取的不同方法。它涵盖了三个安全级别的方法:
- Rc 和 RefCell 完全安全。
- 内存安全,但在将整数句柄放入 Vec 时更容易出现错误(例如别名借用)。
- 原始指针完全不安全。
恕我直言,这三种方法都有其优点并且了解它们很有用。 Rust 自己的标准库和一些流行的crate的实现 证据表明,第三种方法 - 使用原始指针和 unsafe - 非常流行。
注释:
-
Rc 还有一个名为 Arc 的线程安全变体,它使用原子,因此对于单线程应用程序来说速度稍慢。在这篇文章中,我将只使用 Rc ,但切换到 Arc 会很容易。
-
为简单起见,本文中的数据类型将只是 i32 。在实际的程序中,所有 Node 类型都是通用的。
-
如果您想知道 RefMut 如何实现这一壮举 - 当然是通过使用 unsafe 。
-
公平地说,Rust 对
Option
的支持确实很好,但我在编写基于句柄的方法时错过了它。如果我能够访问let Some(xx) = ...
的等效项,某些代码肯定会更短一些。一位好心的读者指出,Rust 实际上有类型std::num::NonZeroUsize
正是为了这个目的。 -
尽管这可以归因于我对 Rust 的相对缺乏经验以及编写 C 和 C++ 的悠久历史。
相关阅读:
Learn Rust the Dangerous Way-系列文章翻译-总述
Learn Rust the Dangerous Way-系列文章翻译-0
Learn Rust the Dangerous Way-系列文章翻译-1
Learn Rust the Dangerous Way-系列文章翻译-2
Learn Rust the Dangerous Way-系列文章翻译-3
Learn Rust the Dangerous Way-系列文章翻译-4