【Rust自学】15.6. RefCell与内部可变性:“摆脱”安全性限制

题外话,这篇文章一共4050字,是截止到目前为止最长的文章,如果你能坚持读完并理解,那真的很强!

喜欢的话别忘了点赞、收藏加关注哦,对接下来的教程有兴趣的可以关注专栏。谢谢喵!(=・ω・=)

15.6.1. 什么是内部可变性

内部可变性(interior mutability)是Rust的设计模式之一,它允许程序员在只持有不可变引用的前提下对数据进行修改。

通常而言,这样的行为会被借用规则 (详见 4.4. 引用与借用)所禁止,但是为了能够改变数据,内部可变性模式在代码的数据结构里使用了unsafe代码来绕过Rust正常的可变性和借用规则。

不安全代码向编译器表明我们正在手动检查规则,而不是依赖编译器为我们检查规则。不安全代码相关的概念将在以后的文章中涉及。

15.6.2. RefCell<T>

Rc<T>不同,RefCell<T>类型代表了其持有数据的唯一所有权

为了了解RefCell<T>Box<T>的区别,我们得回顾一下借用规则(详见 4.4. 引用与借用):

  • 在任何给定时间,你可以拥有(但不能同时拥有)一个可变引用或任意数量的不可变引用。
  • 引用总是保持有效。

PS:给定时间可以理解为给定的作用域内

RefCell<T>Box<T>的区别如下:

类型 检查阶段 规则违背后果
Box<T> 编译阶段检查借用规则 编译时报错
RefCell<T> 运行时检查借用规则 触发 panic

借用规则在不同阶段进行检查有不同的特点:

  • 编译阶段:

    • 尽早暴露问题
    • 没有任何运行时的开销
    • 是大多数场景的最佳选择
    • 是Rust的默认行为
  • 运行时:

    • 问题暴露延后,甚至到生产环境
    • 因借用计数产生些许性能损失
    • 实现某些特定的内存安全场景(比如在不可变环境中修改自身数据)

该在什么时候使用`RefCell

Rust编译器在编译阶段会检查所有的代码,其中大部分代码它都能够分析明白,如果没有问题就通过编译,如果有问题就报错。

Rust编译器是非常保守的,某些代码并不能在编译阶段就能分析明白,针对这类无法在编译阶段完成分析的代码Rust会直接拒绝掉,哪怕这些代码本质上没有任何问题。

Rust这么保守是为了保证程序的安全性。虽然拒绝掉某些本身没有问题的代码会对开发者造成不便,但是至少不会产生任何灾难性的后果。

针对这些编译器无法分析的代码,如果开发者能够保证这段代码满足借用规则,那么就可以使用RefCell<T>

RefCell<T>类似,Rc<T>只适用于单线程场景。

15.6.3. 如何在Box<T>Rc<T>RefCell<T>中进行选择

根据下表列出的三者的特性就可以进行选择:

特性 Box<T> Rc<T> RefCell<T>
同一数据的所有者 一个 多个 一个
可变性、借用检查 可变、不可变借用(编译时检查) 不可变借用(编译时检查) 可变、不可变借用(运行时检查)

额外说一句,由于RefCell<T>在运行时才会被检查,所以即使RefCell<T>本身是不可变的,但我们仍然可以修改里面储存的值

15.6.4. 内部可变形:可变的借用一个不可变的值

这个小标题有一点绕,意思是对一个没有声明为mut的类型使用&mut引用。看个例子就明白了:

rust 复制代码
fn main() {
    let x = 5;
    let y = &mut x;
}

借用规则的一个推论是,当你有一个不可变的值时,你就不能可变地借用它。所以这么写会报错:

复制代码
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
 --> src/main.rs:3:13
  |
3 |     let y = &mut x;
  |             ^^^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` (bin "borrowing") due to 1 previous error

然而在某些特定情况下,我们会需要这样一个值------它对外部保持不可变,但它同时能在方法内部修改自身的值,除了这个值本身的方法,其余的代码都不能修改这个值 ,这叫做内部可变性RefCell<T>就是为了这种情况而存在的。

但是RefCell<T>并没有完全地绕开借用规则,编译阶段的检查虽然能够通过,但是在运行阶段如果违反了借用规则就会造成程序恐慌。

下面看一个例子(lib.rs):

功能:用于跟踪某个值与最大值的接近程度,并在该值达到特定级别时发出警告

rust 复制代码
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

这个例子的逻辑并不重要,看一下它的写法:

  • 程序开头定义了Messenger trait,里面有send方法的签名:接收不可变引用&self和一个字符串切片类型&str的形参msg作为参数。

  • 下面定义了一个结构体叫LimitTracker,它是一个泛型类型,生命周期为'a,泛型参数为T,要求T的生命周期为'a并实现Messenger这个在程序开头定义的trait。LimitTracker里面有三个字段:

    • messenger:类型为&str字符串切片类型,生命周期为'a
    • value:类型为usize
    • max:类型为usize
  • 往下看,通过impl块在LimitTracker上写了关联函数new,其参数是类型为泛型引用&T的形参messenger和类型为usize的形参max,返回值是LimitTracker类型。这个函数用于创建LimitTracker实例,这个实例:

    • messenger字段是形参menssenger的值
    • value字段值为0
    • max字段值为形参max的值
  • LimitTracker还有一个方法叫做set_value,其第一个参数是self的可变引用&mut self,第二个参数是value,类型为usize

    方法内部的代码逻辑很简单。把selfvalue字段值和参数value的值相除(还要转换成f64避免丢失精度)得到一个百分比,存储在percentage_of_max内。根据percentage_of_max的大小使用Messenger trait下的send方法发送不同的警告。

使用测试替代(test double)进行测试

这里有一个问题,如果我们要对这个set_value方法进行测试,就需要这个方法得输出些什么东西以供断言。但是set_value方法实际上并没有返回任何的值,所以说它不会提供任何的结果来进行断言。

我们要测试的是当某一个实现了Messenger trait的值和一个max值来创建LimitTracker实例时,传入不同的value就能够触发Messenger发送不同的消息。

为了解决这个问题,这里要介绍test double ,它的中文叫测试替代 ,是一个通用的变成概念,代表了测试工作中被用作其他类型的替代品。test double中有一个特定的类型,叫模拟对象Mock Object),它会承担记录测试过程中的工作。我们就可以利用这些记录来断言这个测试工作运行是否正确。

Rust里没有类似的概念,在标准库里也没有模拟对象(Mock Object),但是我们可以自定义一个结构体来实现和Mock Object相同的功能。

接着上文的代码来写:

rust 复制代码
#[cfg(test)]
mod tests {
    use super::*;

    struct MockMessenger {
        sent_messages: Vec<String>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: vec![],
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.len(), 1);
    }
}
  • 在测试模块的最开头先声明了MockMessenger结构体,里面有1个字段sent_message,表示发送的消息,其类型是Vec<String>

  • MockMessenger在下文又通过impl块创建了了new函数用于创建MockMessenger的实例,实例的sent_messages字段的值是一个空的Vector

  • 后面又为MockMessenger结构体实现了整个代码一开头的Messenger trait。实现了这个trait之后MockMessenger就可以用来创建LimitTracker(因为LimitTracker要求泛型类型实现Messenger trait)。

    使用send方法时这个消息会存储在MockMessenger下字段sent_message这个Vector里。

  • 最后是it_sends_an_over_75_percent_warning_message这个测试函数,它测试的是超过75%的这部分。

    首先创建了MockMessenger的实例叫mock_messenger,然后创建了一个LimitTracker的实例叫limit_tracker,接着在LimitTracker的实例上(就是limit_tracker)调用。

    最后通过mock_messengersent_message这个Vector里元素的数量来断言。

此时的代码逻辑有问题,但是运行会报错:

复制代码
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
  --> src/lib.rs:58:13
   |
58 |             self.sent_messages.push(String::from(message));
   |             ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
   |
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
   |
2  ~     fn send(&mut self, msg: &str);
3  | }
...
56 |     impl Messenger for MockMessenger {
57 ~         fn send(&mut self, message: &str) {
   |

For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` (lib test) due to 1 previous error

错误在为MockMessenger实现Messenger trait时定义send方法的过程:

rust 复制代码
impl Messenger for MockMessenger {
    fn send(&self, message: &str) {
        self.sent_messages.push(String::from(message));
    }
}

无法修改MockMessenger来跟踪消息,因为send方法的函数签名参数是对self不可变引用。我们也无法使用&mut self来代替,因为这样send的签名将与Messenger trait定义中的签名&self不匹配。

针对这种需要内部可变性的情况,就可以使用RefCell<T>,只需要把MockMessengersent_messages字段用RefCell<T>再包装一下即可:

rust 复制代码
struct MockMessenger {
    sent_messages: RefCell<Vec<String>>,
}

由于RefCell<T>不在预导入模块中,所以在使用它之前得先把它引入当前作用域

rust 复制代码
use std::cell::RefCell;

这样改了之后使用了sent_messages字段的代码都需要使用RefCell<T>再包装一下:

rust 复制代码
impl MockMessenger {
    fn new() -> MockMessenger {
        MockMessenger {
            sent_messages: RefCell::new(vec![]),
        }
    }
}

RefCell到底是怎么用的呢?其实就是用RefCell创建的数据,可以用borrow_mut方法来修改,对实参调用borrow_mut方法即可获得一个可变引用,所以为MockMessenger实现Messenger trait时定义send方法就可以使用borrow_mut

rust 复制代码
impl Messenger for MockMessenger {
    fn send(&self, message: &str) {
        self.sent_messages.borrow_mut().push(String::from(message));
    }
}

这样即使send的参数是不可变引用,在函数体里也可以通过borrow_mut来修改其值。

最后把测试函数的断言部分改一下:

rust 复制代码
fn it_sends_an_over_75_percent_warning_message() {
    let mock_messenger = MockMessenger::new();
    let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

    limit_tracker.set_value(80);

    assert_eq!(mock_messenger.sent_messages..borrow().len(), 1);
}

mock_messenger使用borrow函数即可获取对该变量的不可变引用用于断言。

这时候运行就没有问题了,整体代码如下:

rust 复制代码
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
	    let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

	    limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}

15.6.5. 使用RefCell<T>在运行时记录借用信息

实际上,上文所使用的borrow_mutborrow方法相当于提供给用户的两个安全接口:

  • borrow:返回智能指针Ref<T>,它实现了Deref trait
  • borrow_mut:返回智能指针RefMut<T>,实现了Deref trait

RefCell<T>会记录当前存在多少活跃的Ref<T>RefMut<T>

  • 每次调用borrow:不可变借用计数加1。
    任何一个Ref<T>的值离开作用域被释放:不可变借用计数减1
  • 每次调用borrow_mut:可变借用计数加1
    任何一个RefMut<T>的值离开作用域被释放:可变借用计数减1

与编译时借用规则(详见 4.4. 引用与借用)一样, RefCell<T>允许我们在任何时间点拥有许多不可变借用或一个可变借用。

如果我们尝试违反这些规则, RefCell<T>的实现将在运行时出现恐慌(因为RefCell<T>在运行时才会进行借用规则检查)。出现恐慌 already borrowed: BorrowMutError 就是RefCell<T>在运行时处理违反借用规则的方式。

15.6.6. 将Rc<T>RefCell<T>结合使用的例子

Rc<T>允许某些数据被多个所有者持有,但它只提供对该数据的不可变访问。如果您有一个包含RefCell<T>Rc<T> ,你可以获得一个可以拥有多个所有者并且可变的值。

下面看一个将Rc<T>RefCell<T>结合使用来实现多重数据所有权的可变数据:

rust 复制代码
#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

    let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

    *value.borrow_mut() += 10;

    println!("a after = {a:?}");
    println!("b after = {b:?}");
    println!("c after = {c:?}");
}

还记得上一篇文章的Cons列表示例吗?其中我们使用了Rc<T>允许多个列表共享另一个列表的所有权。因为Rc<T>仅保存不可变值,一旦创建了列表中的任何值,我们就无法更改它们。通过这篇文章的内容,让我们添加RefCell<T>以获得更改列表中的值的能力:

  • 首先在生命枚举类型List时把Cons关联的i32类型用RefCell<>包裹,由于Rust编译器无法确定RefCell<T>大小,得用Rc<>包裹在外,其余保持不变
  • 记得引入RcRefCell到当前作用域
  • 下面通过Rc::new()RefCell::new()来创建实例,a通过Rc::clone()来共享value的值,bc通过Rc::clone()来共享a的值(前提是aRc<>包裹)。
  • 最后通过RefCell<T>上的borrow_mut获得value的可变引用,其类型时&i32,然后通过解引用符号*变为i32来进行加10的操作。

输出:

复制代码
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))

跟预期一样,没有问题。

15.6.7. 其他可以实现内部可变性的类型

  • Cell<T>:通过复制来访问数据
  • Mutex<T>:用于实现跨线程情况下的内部可变性模5
相关推荐
Asthenia0412几秒前
为什么MySQL关联查询要“小表驱动大表”?深入解析与模拟面试复盘
后端
南雨北斗3 分钟前
分布式系统中如何保证数据一致性
后端
Asthenia04127 分钟前
Feign结构与请求链路详解及面试重点解析
后端
左灯右行的爱情10 分钟前
缓存并发更新的挑战
jvm·数据库·redis·后端·缓存
brzhang14 分钟前
告别『上线裸奔』!一文带你配齐生产级 Web 应用的 10 大核心组件
前端·后端·架构
shepherd11115 分钟前
Kafka生产环境实战经验深度总结,让你少走弯路
后端·面试·kafka
anqi2723 分钟前
如何在 IntelliJ IDEA 中编写 Speak 程序
java·大数据·开发语言·spark·intellij-idea
XuX0327 分钟前
MATLAB小试牛刀系列(1)
开发语言·matlab
袋鱼不重28 分钟前
Cursor 最简易上手体验:谷歌浏览器插件开发3s搞定!
前端·后端·cursor
嘻嘻哈哈开森30 分钟前
Agent 系统技术分享
后端