Rust语言高级技巧 - RefCell 是另外一个提供了内部可变性的类型,Cell 类型没办法制造出直接指向内部数据的指针,为什么RefCell可以呢?

Cell

按照前面的理论,如果我们有共享引用指向一个对象,那么这个对象就不会被更改了。 因为在共享引用存在的期间,不能有可变引用同时指向它,因此它一定是不可变的。其实在 Rust 中,这种想法是不准确的。下面给出一个示例:

js 复制代码
use std::rc::Rc;
fn main(){
  let r1 =Rc::new(1);
  println!("reference count{}",Rc::strong_count(&r1));
  let r2 =r1.clone();
  println!("reference count{}",Rc::strong_count(&r2));
}

编译,执行,结果为:

js 复制代码
reference count 1
reference count 2

Rc 是 Rust 里面的引用计数智能指针,在后文中我们还会继续讲解。多个Rc 指针可以 同时指向同一个对象,而且有一个共享的引用计数值在记录总共有多少个Rc 指针指向这个对 象。

注意Rc 指针提供的是共享引用,按道理它没有修改共享数据的能力。但是我们用共享 引用调用clone 方法,引用计数值发生了变化。这就是我们要说的"内部可变性"。如果没 有内部可变性,标准库中的Rc 类型是无法正确实现出来的。具备内部可变性的类型,最典 型的就是Cell。

现在用一个更浅显的例子来演示一下Cell 的能力:

js 复制代码
use std::cell::Cell;
fn main(){
  let data :Cell<i32>=Cell::new(100);
  let p=&data;
  vdata.set(10);
  println!("{}",p.get());
  p.set(20);
  println!("{:?}",data);
}

这次编译通过,执行,结果是符合我们的预期的:

js 复制代码
10
Cell {value:20 }

请注意这个例子最重要的特点。需要注意的是,这里的"可变性"问题跟我们前面见到 的情况不一样了。data 这个变量绑定没有用mut 修 饰 ,p 这个指针也没有用&mut 修饰, 然而不可变引用竟然可以调用set 函数,改变了变量的值,而且还没有出现编译错误。

这就是所谓的内部可变性------这种类型可以通过共享指针修改它内部的值。虽然粗略一 看 ,Cell类型似乎违反了Rust 的"唯一修改权"原则。我们可以存在多个指向Cell类型 的不可变引用,同时我们还能利用不可变引用改变Cell内部的值。但实际上,这个类型是 完全符合"内存安全"的。我们再想想,为什么Rust 要尽力避免alias 和 mutation同时存在?


因为假如我们同时有可变指针和不可变指针指向同一块内存,有可能出现通过一个可变指针 修改内存的过程中,数据结构处于被破坏状态的情况下,被其他的指针观测到。 Cell 类 型 是不会出现这样的情况的。因为Cell 类型把数据包裹在内部,用户无法获得指向内部状态 的指针,这意味着每次方法调用都是执行的一次完整的数据移动操作。每次方法调用之后, Cell 类型的内部都处于一个正确的状态,我们不可能观察到数据被破坏掉的状态。

多个共享指针指向Cell 类型的状态就类似图15-1所示的这样, Cell 就是一个"壳",它把数据严严实实地包裹在里面, 所有的指针只能指向Cell, 不能 直接指向数据。修改数据只能通过 Cell 来完成,用户无法创造一个直 接指向数据的指针。

我们来仔细观察一下Cell 类 型 提供的公开的API,就能理解Cell 类型设计的意义了。下面是Cell 类 型提供的几个主要的成员方法:

js 复制代码
impl<T>Cell<T>{
pub fn get_mut(&mut self)->&mut T{ }
pub fn set(&self,val:T){ }
pub fn swap(&self,other:&Self){ }
pub fn replace(&self,val:T)->T{ }
pub fn into_inner(self)->T{ }
impl<T:Copy>Cell<T>{
  pub fn get(&self)->T{ }
}
  • get_mut 方法可以从&mut Cell类型制造出一个&mut T 型指针。因为&mut 型指针具有"独占性",所以这个函数保证了调用前,有且仅有一个"可写"指针指 向 Cell, 调用后有且仅有一个"可写"指针指向内部数据。它不存在制造多个引用 指向内部数据的可能性。

  • set 方法可以修改内部数据。它是把内部数据整个替换掉,不存在多个引用指向内部 数据的可能性。

  • swap 方法也是修改内部数据。跟set 方法一样,也是把内部数据整体替换掉。与 std::mem::swap 函数的区别在于,它仅要求&引用,不要求&mut 引用。

  • replace方法也是修改内部数据。跟set 方法一样,它也是把内部数据整体替换, 唯一的区别是,换出来的数据作为返回值返回了。

  • into_inner方法相当于把这个"壳"剥掉了。它接受的是Self 类型,即move 语 义,原来的Cell 类型的变量会被move 进入这个方法,会把内部数据整体返回出来。 get 方法接受的是&self 参数,返回的是T 类型,它可以在保留之前Cell 类型不 变的情况下返回一个新的T 类型变量,因此它要求T:Copy 约束。每次调用它的时 候,都相当于把内部数据memcpy 了一份返回出去。

正因为上面这些原因,我们可以看到,Cell 类型虽然违背了"共享不可变,可变不共 享"的规则,但它并不会造成内存安全问题。它把"共享且可变"的行为放在了一种可靠、可控、可信赖的方式下进行。它的API 是经过仔细设计过的,绝对不可能让用户有机会通过 &Cetl 获得&T 或者&mut T。它是对alias+mutation原则的有益补充,而非完全颠覆。 大家可以尝试一下用更复杂的例子(如Cell<Vec>),看能不能构造出内存不安 全的场景。


RefCell

RefCell 是另外一个提供了内部可变性的类型。它提供的方式与Cell 类型有点不一 样。Cell 类型没办法制造出直接指向内部数据的指针,而RefCell可以。我们来看一下它 的 API:

js 复制代码
impl<T:?Sized>RefCell<T>{
 pub fn borrow(&self)->Ref<T>{ }
 pub fn try_borrow(&self)->Result<Ref<T>,BorrowError>{ }
 pub fn borrow_mut(&self)->RefMut<T>{ }
 pub fn try_borrow_mut(&self)->Result<RefMut<T>,BorrowMutError>{ }
 pub fn get_mut(&mut  self)->&mut T{ }
}

get_mut 方法与Cell::get_mut 一样,可以通过&mut self获得&mut T,这个过程是安全的。除此之外,RefCell最主要的两个方法就是borrow 和 borrow_mut,另外两个try_borrow和try_borrow_mut 方式不同。

我们还是用示例来演示一下RefCell use std::cell::RefCell;只是它们俩的镜像版,区别仅在于错误处理的怎样使用:

js 复制代码
fn main(){
  let shared_vec:RefCell<Vec<isize>>=RefCell::new(vec![1,2,3]);
  let   shared1   =&shared_vec;
  let   shared2   =&shared1;
  shared1.borrow_mut()·push(4);
  println!("{:?}",shared_vec.borrow());
  shared2.borrow_mut()·push(5);
  println!("{:?}",shared_vec.borrow());
}

在这个示例中,我们用一个RefCell 包了一个Vec, 并且制造了另外两个共享引用指 向同一个RefCell 。 这时,我们可以通过任何一个共享引用调用borrow_mut 方法,获得 指向内部数据的"可写"指针,通过这个指针调用了push 方法,修改内部数据。同时,我 们也可以通过调用borrow 方法获得指向内部数据的"只读"指针,读取Vec 里面的值。

编译,执行,结果为:

js 复制代码
$./test
[1,2,3,4]
[1,2,3,4,5]

这里有一个小问题需要跟大家解释一下:在函数的签名中,borrow 方法和borrow_ mut 方法返回的并不是&T 和 &mut T,而 是Ref 和RefMut。 它们实际上是一种 "智能指针",完全可以当作&T 和 &mut T的等价物来使用。标准库之所以返回这样的类型, 而不是原生指针类型,是因为它需要这个指针生命周期结束的时候做点事情,需要自定义类 型包装一下,加上自定义析构函数。至于包装起来的类型为什么可以直接当成指针使用,它 的原理可以参考下一章"解引用"。

那么问题来了:如果borrow 和borrow_mut 这两个方法可以制造出指向内部数据的 只读、可读写指针,那么它是怎么保证安全性的呢?像前几章讲的那样,如果同时构造了只 读引用和可读写引用指向同一个Vec, 那不是很容易就构造出悬空指针么?答案是,RefCell 类型放弃了编译阶段的alias+mutation原则,但依然会在执行阶段保证alias+mutation原则。 示例如下:

js 复制代码
use std::cell::RefCell;
fn main(){
 let shared_vec:RefCell<Vec<isize>>=RefCell::new(vec![1,2,3]);
 let shared1 =&shared_vec;
 let shared2 =&shared1;
 let p1 =shared1.borrow();
 let p2 =&p1[0];
 shared2.borrow_mut()·push(4);
 println!("{}",p2);
}

上面这个示例的意图是:我们先调用borrow 方法,并制造一个指向数组第一个元素的 指针,接着再调用borrow_mut方法,修改这个数组。这样,就构造出了同时出现 alias 和 mutation的场景。

编译,通过。执行,问题来了,程序出现了panic:

js 复制代码
$./test
thread     'main'    panicked    at     'already    borrowed:   BorrowMutError',src\libcore\ result.rs:860:4
note:Run      with       RUST_BACKTRACE=1`for      a      backtrace.

出现panic的原因是,RefCell 探测到同时出现了alias 和mutation的情况,它为了 防止更糟糕的内存不安全状态,直接使用了panic 来拒绝程序继续执行。如果我们用try_borrow 方法的话,就会发现返回值是Result::Err, 风 格 。

那么RefCell是怎么探测出问题的呢?原因是,RefCell内部有共享引用一个"借用计数器",调用borrow方法的时候,计数器里面的"共享引 用计数"值就加1。当这个borrow 结束的时候,会将这个值自动减1 (如图15-2所示)。同样,borrow_mut 方法被调用的时候,它就记录这是另外一种更友好的错误处理 一下当前存在"可变引用"。如果"共享引用"和"可变引用"同时出现了,就会报错。

从原理上来说,Rust 默认的"借用规则检查器"的逻辑非常像一个在编译阶段执行的"读 写 锁 "(read-write-locker) 。如果同时存在多个"读"的锁,是没问题的;如果同时存在"读" 和"写"的锁,或者同时存在多个"写"的锁,就会发生错误。RefCell 类型并没有打破这 个规则,只不过,它把这个检查逻辑从编译阶段移到了执行阶段。

相关推荐
Tony Bai1 小时前
Go GUI 开发的“绝境”与“破局”:2025 年现状与展望
开发语言·后端·golang
豆浆whisky1 小时前
Go分布式追踪实战:从理论到OpenTelemetry集成|Go语言进阶(15)
开发语言·分布式·golang
Tony Bai1 小时前
【Go模块构建与依赖管理】08 深入 Go Module Proxy 协议
开发语言·后端·golang
浪裡遊1 小时前
Next.js路由系统
开发语言·前端·javascript·react.js·node.js·js
程序员-小李1 小时前
基于 Python + OpenCV 的人脸识别系统开发实战
开发语言·python·opencv
QX_hao1 小时前
【Go】--文件和目录的操作
开发语言·c++·golang
国服第二切图仔1 小时前
Rust开发实战之密码学基础——哈希计算与对称加密实战
rust·密码学·哈希算法
卡提西亚1 小时前
C++笔记-20-对象特性
开发语言·c++·笔记
2301_796512521 小时前
Rust编程学习 - 内存分配机制,如何动态大小类型和 `Sized` trait
学习·算法·rust