Rc<RefCell<T>>是什么梗

515 阅读13分钟

Rc<RefCell<T>>是什么梗

用各种meme调侃rust是大家喜闻乐见的娱乐活动。

image.png

在编程学习初期,我们很有可能会接触到一些“简单的”数据结构,比如链表、各种树。

当然这里的简单不加引号也没问题,毕竟这些数据结构在编程语言中的定义都可以很简单。

递归的数据结构如何定义

无论是链表还是树的节点,它们在逻辑上都可以是递归定义的。例如在C#等编程语言中可以这样定义一个保存整数的二叉树节点类型:

class BinaryTreeNode 
{
    public int Value {set; get;}
    public BinaryTreeNode Parent {set; get;}
    public BinaryTreeNode Left {set; get;}
    public BinaryTreeNode Right {set; get;}
}

Java、Swift等面向对象编程语言的写法应当是大差不差的,这种定义方式对于新手来说也是很自然的。

但是,在C++中这样是不行的:

class BinaryTreeNode {
public: 
    int Value;
    BinaryTreeNode Parent;
    BinaryTreeNode Left;
    BinaryTreeNode Right;
};

其实在C里换成struct也是不行的(incomplete type),你至少得改成指针。甚至于,你在C#中换成struct也是不能这样定义的(因为C#的class和struct是有本质不同的——其实Swift里也有类似的区别)。

递归类型的问题在于,它可以无限地递归下去,这样一来就无法知道这个类型的实例究竟需要多大的内存才能放下。

所谓现代一点的面向对象编程语言,特别是自动管理内存的编程语言,类的实例实际上本质上就是一个指向某个地方的指针,也就是所谓的引用类型。相互赋值只是复制一下指针的值,实例本身不会自动复制。

递归类型中,虽然类型本身是递归的,但是通过指针来引用同类型的实例就避免了这个问题,因为指针就相当于一个整数,它的长度并不会因为无限递归而不可计算。

rust中的递归类型定义

rust中有指针的概念,但是要和C/C++中的指针对应的只能是原始指针(raw pointer):

struct BinaryTreeNode {
    value: i32,
    parent: *mut BinaryTreeNode,
    left: *mut BinaryTreeNode,
    right: *mut BinaryTreeNode,
}

即使你只做过教科书上的练习题也明白,这些数据结构经常涉及对这些字段的修改。rust中如果要对原始指针进行deref,代码必须在unsafe块里:

let mut root = BinaryTreeNode {
    value: 1,
    parent: std::ptr::null_mut(),
    left: std::ptr::null_mut(),
    right: std::ptr::null_mut(),
};
root.left = &mut BinaryTreeNode {
    value: 1,
    parent: &mut root as *mut BinaryTreeNode,
    left: std::ptr::null_mut(),
    right: std::ptr::null_mut(),
} as *mut BinaryTreeNode;

此处引出另一(两)个rust笑话。rust中的空(原始)指针需要用函数std::ptr::null()来构造。又因为*const T*mut T是两个不同类型,所以还有std::ptr::null_mut()

由于一个节点通过指针引用其它节点实例,所以这里需要将引用转换为指针类型。rust可以在绝大部分情况下保证引用的有效性,所以到指针的转换一般是安全的。

但是这就意味着每当你尝试通过指针访问指针所指时,你就得带上unsafe。为什么不安全?当然这都是C/C++的老生常谈了,试看:

fn escaped() -> BinaryTreeNode {
    let mut n = BinaryTreeNode::new(2);
    let mut root = BinaryTreeNode::new(1);
    root.left = &mut n as *mut BinaryTreeNode;
    root
}

fn main() {
    let root = escaped();
    unsafe  {
        println!("{}", (*root.left).value);
    }
}

函数escaped是典型的问题代码。其中的n是一个本地变量,离开函数之后就无效了,但是root的left却指向了它。上面的代码的运行结果到底是怎么样的就由不得你了(实际上如果你之前写了其他代码,最后输出的也有可能是正确的2,但是这通常是不可预测的)。

不能更安全一点?

你说你都在写rust了,不能写点安全的代码吗?用引用试试?

事情马上就复杂了起来。首先引用没有空值,你得先用Option包装一下。并且我们没法用&mut BinaryTreeNode作为节点字段的类型,因为很简单,rust不允许对同一个对象(这里的对象不是面向对象的对象,它和“东西”是同义词)同时有多个可变引用,那么你就没法让两个节点以指向同一个节点的可变引用为parent。尽管这对于我们的问题来说是不可接受的,我们还是忽略这一点继续尝试用不可变引用试一下:

struct BinaryTreeNode<'t> {
    value: i32,
    parent: Option<&'t BinaryTreeNode<'t>>,
    left: Option<&'t BinaryTreeNode<'t>>,
    right: Option<&'t BinaryTreeNode<'t>>,
}

喜欢写rust的朋友一定知道,一旦你决定在参数、返回值、字段里放进多个引用类型的东西,那么你就要开始和生命周期作斗争。

然而即便如此我们还是没法正常使用:

let mut root = BinaryTreeNode::new(1);
let mut left = BinaryTreeNode::new(2);
left.parent = Some(&root);
root.left = Some(&left);

还是由于rust的所有权规则,我们无法在把对root的不可变引用给了left之后,再去把left给root。

虽然通过引用访问其指向的值是安全的,但是指针有的问题,引用还是有。显然我们需要动态地构造节点然后把它粘在另一个节点身上,但问题是这样的节点很有可能活不过创建它的函数:

fn set_left(&mut self, value: i32) {
    let n = BinaryTreeNode::new(value);
    self.left = Some(&n);
}

不过这次rust会在编译时就告诉你这个问题。

当然,在这个问题中,似乎父节点直接拥有对子节点的所有权也可以,毕竟树的节点只有一个父节点。但是如果直接保存BinaryTreeNode,我们就会回到原点。好在我们有堆指针可以用:

struct BinaryTreeNode<'p> {
    value: i32,
    parent: Option<&'p BinaryTreeNode<'p>>,
    left: Option<Box<BinaryTreeNode<'p>>>,
    right: Option<Box<BinaryTreeNode<'p>>>,
}

拥有Box的对象实际也拥有Box内部的值,我们还可以通过Box拿到可变引用来调用节点的可变方法。

然而在初始化时依然有类似问题:

let mut root = BinaryTreeNode::new(1);
let mut left = BinaryTreeNode::new(2);
left.parent = Some(&root);
root.left = Some(Box::new(left));

总之我们的问题还是没有解决并且老是遇到一些新的问题。

Rc<T>解决什么问题

Rc是reference counting的缩写,也就是引用计数。很多自动管理对象的编程语言都在一定程度上利用引用计数来跟踪对象的使用情况。简单来说,每当产生对一个对象的引用(比如变量赋值),就给它的引用计数加一,引用某对象的变量离开作用域就给它减一。如果一个对象的引用计数为0,那么就可以视作没人用它,可以回收它所使用的内存。

不过在rust中,Rc更像是一种共享所有权并动态确定生命周期的方法。

RcBox一样是指向堆中分配的对象的指针。但是和Box不一样的是,拥有Rc的对象并不独享所有权:

let root = Rc::new(BinaryTreeNode::new(1));
let mut left = BinaryTreeNode::new(2);
let mut right = BinaryTreeNode::new(3);
left.parent = Some(root.clone());
right.parent = Some(root.clone());

Rc::new用于创建一个指向某个值的Rc指针。此后每次对这个指针clone的时候都会得到一个指向同一个值的新Rc,同时让引用计数加一。Rc实现了Clone,但是不能“隐式拷贝”(没有实现Copy)。

另一方面,Rc可以绕过静态的生命周期检查。因为毕竟Rc所指值的生命周期取决于运行时什么时候它的引用计数变成0。因此我们可以避免动态创建对象时使用引用造成的问题。

但是要注意的是,无论是Box还是Rc,在创建时都需要剥夺一个值的所有权才能把它放到堆中。

Rc<T>又带来了什么问题

Rc(常常)只能拿到不可变引用

通过Rc访问值时,无法对其进行(所指的值)修改(Rc没有实现DerefMut)。这意味着我们无法在根节点创建后再设置其子节点:

let root = Rc::new(BinaryTreeNode::new(1));
let mut left = BinaryTreeNode::new(2);
left.parent = Some(root.clone());
root.left = Some(Box::new(left));

就算这样创建了一个两层二叉树,我们却无法创建第三层上的节点。如果用Box持有子节点的所有权,那么子节点的子节点就无法通过Rc来引用第二层的节点。

let mut root = BinaryTreeNode::new(1);
let mut left = BinaryTreeNode::new(2);
left.parent = Some(Rc::new(root));
left.left = Some(Box::new(BinaryTreeNode::new(3)));
// left要交给Rc才能作为left.left的parent
// 但是如果提前把left包装到Rc里我们又没办法修改left.left
left.left.unwrap().parent = Some(Rc::new(left));

但是如果全部节点都通过Rc来引用,那么我们就得到一个完全没用的树——我们什么都没法改。

实际上Rc提供了一个方法可以拿到对所指对象的可变引用(Rc::get_mut)。它的条件很苛刻但可以理解,那就是当且仅当只有一个Rc指向它的时候才可以。对于一棵二叉树的根节点来说,我们很有可能会让两个节点指向它。

RefCell引入局部可变性

使用Rc可以共享一个东西,但是我们没法通过一个拿不到可变引用的Rc去修改它所指的东西内部的状态。

但是RefCell可以帮你。

rust的let绑定是不可变的。意味着你没法让它绑定到另一个值,也没法通过它来修改这个值内部的状态。RefCell则可以让你在不可变绑定身上也能修改RefCell所指对象的内部状态:

struct S {
    n: i32
}

impl S {
    fn add_one(&mut self) {
        self.n += 1;
    }
}

let c = RefCell::new(S{n: 1});
c.borrow_mut().add_one();

注意看,这里的c并不是可变绑定。但是我们依然可以通过borrow_mut拿到一个RefMut(一定程度上就相当于&mut),进而通过它对DerefMut的实现,来调用S的可变方法。

RefCell内部的东西是可变的,但是它对外部依然可以保持不变性。

RefCell在运行时检查借用情况

但是必须要注意,RefCell只是帮你绕过静态(编译时)的借用检查,它并不会帮你违反rust的借用规则:

let c = RefCell::new(13);
let r = c.borrow_mut();
let rp = c.borrow_mut();

这段代码可以完成编译,但是在运行时会panic(违背了只能存在一个可变引用的规则)。可以使用带try字眼的方法避免非法借用。

Rc<RefCell<T>>

将两者结合起来,我们就把T包装成了一个有特殊性质的指针:

  • 它可以被多方共享
  • 它内部的状态可以改变

更令人欣慰的是,RefCell内部发生改变时,由于Rc的共享,所有通过Rc引用它的东西都可以同步感知到这种内部变化。而类型Rc<RefCell<T>>本身是不可变的,它无法指向另一个东西。

现在我们修改BinaryTreeNod的定义:

struct BinaryTreeNode {
    value: i32,
    parent: Option<Rc<RefCell<BinaryTreeNode>>>,
    left: Option<Rc<RefCell<BinaryTreeNode>>>,
    right: Option<Rc<RefCell<BinaryTreeNode>>>,
}

太棒了,我们的字段类型已经有3个尖括号了。然后我们就可以:

let root = Rc::new(RefCell::new(BinaryTreeNode::new(1)));
let left = Rc::new(RefCell::new(BinaryTreeNode::new(2)));
left.borrow_mut().parent = Some(root.clone());
root.borrow_mut().left = Some(left.clone());

总之Rc帮你绕过生命周期检查,RefCell帮你绕过借用检查,至此rust很多在编译时施加的限制都可以被绕过,不可谓不是大杀器。当然,就算不考虑Rc带来的那点运行时开销,我们也应当合理地使用。毕竟你也不想写一长串方法调用才能拿到包装盒里面的东西吧。

循环引用问题

各种引用计数技术都面临一个问题就是循环引用问题。如果A引用了B,而B又引用了A(或者对象的引用关系构成的图中存在圈),那么它们的引用计数永远不会变成0,因为它们始终相互引用,最终这部分内存就始终不会释放。

我们在前面已经看到,其实只是用Rc的话很难产生循环引用。但是RefCell的引入就产生了这种可能性。为了处理循环引用问题,rust内置的解决办法也是弱引用,也就是不会让引用计数增加的引用(指针)。

Rcdowngrade方法可以从一个Rc创建一个Weak

let r = Rc::new(1);
let s = r.clone();
let w = Rc::downgrade(&r);
assert_eq!(Rc::strong_count(&r), 2);

这里创建了一个Rc,只克隆了一次,然后创建了一个弱引用,所以它的强引用计数是2。

由于Weak不影响其所指对象的(强)引用计数,所以它所指的地方并不一定还存在着原本的那个对象。因此实际上无法直接通过Weak访问所指对象。如果需要通过Weak访问所指对象,需要使用Weak::upgrade尝试从Weak获得一个Rc。此方法返回一个Option<Rc<T>>

结果

于是我们可以把BinaryTreeNode定义成这样:

struct BinaryTreeNode {
    value: i32,
    parent: Option<Weak<RefCell<BinaryTreeNode>>>,
    left: Option<Rc<RefCell<BinaryTreeNode>>>,
    right: Option<Rc<RefCell<BinaryTreeNode>>>,
}

假设我们要写一个拿到任意节点,然后需要让它跟它的兄弟节点互换位置的函数:

fn swap_siblings(node: Rc<RefCell<BinaryTreeNode>>) {
    let parent = node.borrow().parent.as_ref().unwrap().upgrade().unwrap();
    let right = parent.borrow().right.clone();
    parent.borrow_mut().right = Some(node.clone());
    parent.borrow_mut().left = right;
}

典中典,我们需要用很多方法调用去拆开层层包装——这还是在假定Option里的值是存在的的情况下。但是好歹我们现在有足够的自由来操作树了。

DLC:为什么叫RefCell

RefCell就是reference cell。虽然rust的使用体验总的来说是命令式的,但是它到底从一些(主要编程范式是函数式的)语言身上学了不少东西。

.NET的函数式语言F#里就有一个叫reference cell的概念:

let c = ref 1 // ref用以创建一个ref int类型对象
let b = c
c := 2 // :=用于修改reference cell
assert !b = !c // !b获得b内部的值

题外话:F#其实也不是特别“纯”的函数式语言。它作为一门.NET语言实际上也支持其它编程范式,尤其是面向对象编程。因为它还要考虑到和C#之类的语言进行互操作。另一方面它也和rust一样可以有可变的let绑定,只是reference cell在某些情况下是必须的。

F#实际上就相当于OCaml.NET。OCaml的很多特性和语法F#都在.NET上实现了。实际上OCaml也是rust的重要灵感来源。OCaml的O是Objective的O,其实它和F#一样也是“函数式为主,混合其它范式”的多范式编程语言:

let d = ref 0
d := 1
!d

好吧,基本上和F#是一模一样的。但是OCaml本身是不支持可变的let绑定的。另外这种OCaml的引用语法在新版本的F#中实际上会被编译器警告已经弃用了。(F#和OCaml代码块都不会上色,哭了)

F#和OCaml对reference cell的定义实际上都是一个只有一个可变字段的记录类型。