Rust 学习笔记:RefCell 和内部可变性模式
Rust 学习笔记:RefCell<T> 和内部可变性模式
内部可变性是 Rust 中的一种设计模式,它允许你改变数据,即使数据有不可变的引用。
为了改变数据,该模式在数据结构内部使用 unsafe 代码来改变 Rust 通常的借用规则。unsafe 代码向编译器表明,我们正在手动检查规则,而不是依赖编译器为我们检查规则。
只有当我们能够确保在运行时遵循借用规则时,才可以使用使用内部可变性模式的类型,尽管编译器不能保证这一点。然后将涉及的 unsafe 代码包装在安全 API 中,并且外部类型仍然是不可变的。
让我们通过查看遵循内部可变性模式的 RefCell<T> 类型来探索这个概念。
在运行时使用 RefCell<T> 强制执行借用规则
与 Rc<T> 不同,RefCell<T> 类型表示对其持有的数据的单一所有权。
那么是什么使 RefCell<T> 不同于 Box<T> 这样的类型呢?回想一下借用规则:
- 在任何时刻,要么有一个可变引用,要么有任意数量的不可变引用。
- 引用必须总是有效的。
比较:
引用和 Box<T> | RefCell<T> | |
---|---|---|
借用规则 | 编译时强制执行 | 运行时强制执行 |
若违反规则 | 编译报错 | 程序 panic 并退出 |
在编译时检查借用规则的优点是,在开发过程中可以更快地捕获错误,并且不会对运行时性能产生影响。
在大多数情况下,在编译时检查借用规则是最好的选择,这就是为什么这是 Rust 的默认值。
在运行时检查借阅规则的优点是,在编译时检查不允许的情况下,允许某些内存安全的场景。静态分析和 Rust 编译器一样,本质上是保守的。代码的一些属性是不可能通过分析代码来检测的,如果 Rust 编译器不能确定代码符合所有权规则,它可能会拒绝一个正确的程序。
当你确信你的代码遵循借用规则,但编译器无法理解和保证这一点时,RefCell<T>类型是有用的。
与 Rc<T> 类似,RefCell<T> 仅用于单线程场景,如果尝试在多线程环境中使用它,则会得到一个编译时错误。
现在我们对三种智能指针都有了了解,以下是选择 Box<T>、Rc<T> 或RefCell<T> 的原因概述:
- Rc<T>允许同一数据的多个所有者;Box<T> 和 RefCell<T> 具有单个所有者。
- Box<T> 允许在编译时检查不可变或可变借用;Rc<T> 只允许在编译时检查的不可变借用;RefCell<T> 允许在运行时检查不可变或可变的借用。
- 因为 RefCell<T> 允许在运行时检查可变借用,你可以改变 RefCell<T> 内的值,即使 RefCell<T> 是不可变的。
总结成表格:
Box<T> | Rc<T> | RefCell<T> | |
---|---|---|---|
所有权 | 单一所有者 | 多个所有者 | 单一所有者 |
借用规则检查时机 | 编译时 | 编译时 | 运行时 |
允许的借用类型 | 可变、不可变 | 不可变 | 可变、不可变 |
内部可变性 | 不支持 | 不支持 | 支持 |
线程安全 | 看情况 | 仅单线程 | 仅单线程 |
内部可变性模式实现了在不可变值中改变值。让我们看看内部可变性有用的情况,并研究它是如何实现的。
内部可变性:对不可变值的可变借用
借用规则的一个结果是,当你有一个不可变的值时,你不能以可变的方式借用它。
例如,以下代码将无法编译:
fn main() {
let x = 5;
let y = &mut x;
}
在某些情况下,值在其方法中改变自身,但在其他代码中显示为不可变是有用的。值的方法之外的代码将无法改变值。
使用 RefCell<T> 是一种获得内部可变性能力的方法,但是 RefCell<T> 并不能完全绕过借用规则:编译器中的借用检查器允许这种内部可变性,而借用规则是在运行时检查的。如果违反了规则,会出现 panic 而不是编译器错误。
让我们通过一个实际的例子学习如何使用 RefCell<T> 来改变一个不可变的值。
内部可变性的一个示例:Mock 对象
有时在测试过程中,程序员会使用一种类型来代替另一种类型,以便观察特定的行为并断言它是正确实现的。这种占位符类型称为 test double 类型。Mock 对象是特定类型的测试副本,它记录了测试期间发生的事情,因此你可以断言发生了正确的操作。
下面是我们要测试的场景:我们将创建一个库,根据最大值跟踪一个值,并根据当前值与最大值的接近程度发送消息。这里的例子是 API 调用配额。库不关心消息的具体发送方式,只需程序提供实现称为 Messenger 的 trait。
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 和消息文本的不可变引用。Messenger trait 是 Mock 对象需要实现的接口,这样模拟就可以像真实对象一样被使用。
我们想要测试 LimitTracker 上 set_value 方法的行为。我们可以更改传递给 value 形参的内容,但是 set_value 不会返回任何可供我们进行断言的内容。如果我们用实现 Messenger trait 的东西创建一个 LimitTracker,并为 max 设置一个特定的值,当我们为 value 传递不同的数字时,Messenger 被告知发送适当的消息。
我们需要一个 Mock 对象,当我们调用 send 时,它不会发送电子邮件或短信,而是只会跟踪它被告知要发送的消息。我们可以创建 Mock 对象的一个新实例,然后创建一个使用 Mock 对象的 LimitTracker,调用 set_value 方法,检查模拟对象是否具有我们期望的消息。
编写 tests 模块:
#[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 结构体,该结构体有一个 sent_messages 字段,该字段是一个 String 数组,用于跟踪它被告知要发送的消息。我们还定义了一个关联函数 new,以便创建消息列表为空的新 MockMessenger 值。然后我们为 MockMessenger 实现 Messenger trait,这样我们就可以给一个 LimitTracker 提供一个 MockMessenger。在 send 方法的定义中,我们将传入的消息作为参数,并将其存储在 sent_messages 中。
在测试中,我们测试了当 LimitTracker 被设置 value / max = 0.8 的情况。然后我们断言,MockMessenger 正在跟踪的消息列表现在应该包含一条消息。
然而,运行 cargo test,出错了:
因为 send 方法接受对 self 的不可变引用,我们不可以在该方法的主体内向 self.sent_messages 插入字符串。
我们也不能接受错误文本的建议,即在 impl 方法和 trait 定义中都使用 &mut self。我们不希望仅仅为了测试而更改 Messenger trait。
相反,我们需要找到一种方法,使我们的测试代码与我们现有的设计正确地工作。
在这种情况下,内部可变性可以提供帮助!我们将把 sent_messages 存储在 RefCell<T> 中,然后 send 方法将能够修改 sent_messages 来存储我们看到的消息。
#[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);
}
}
sent_messages 字段现在的类型是 RefCell<Vec<String>>。在 new 函数中,我们创建了一个新的 RefCell<Vec<String>> 实例,以空数组做初始化。
对于 send 方法的实现,第一个参数仍然是 self 的不可变借用,它与 Messenger trait 定义相匹配。我们调用 self.sent_messages(类型为 RefCell<Vec<String>>) 上的 borrow_mut 方法,以获得对 RefCell<Vec<String>> 内值(即字符串数组)的可变引用。然后,我们可以在 vector 的可变引用上调用 push,以跟踪测试期间发送的消息。
在断言中,为了查看内部向量中有多少项,我们在 mock_messenger.sent_messages(类型为 RefCell<Vec<String>>)上调用 borrow 方法,以获得对 Vec<String> 的不可变引用。
现在你已经看到了如何使用 RefCell<T>,让我们深入了解它是如何工作的!
在运行时使用 RefCell<T> 跟踪借用
在创建不可变引用和可变引用时,我们分别使用 & 和& mut 语法。
对于 RefCell<T>,我们使用 borrow 和 borrow_mut 方法,它们是属于 RefCell<T> 的安全 API 的一部分。
borrow 方法返回 Ref<T>, borrow_mut 方法返回 RefMut<T>。
Ref<T> 和 RefMut<T> 都实现了 Deref trait,因此我们可以将它们视为常规引用。
RefCell<T> 跟踪当前活动的 Ref<T> 和RefMut<T> 智能指针的数量。每次调用 borrow 时,RefCell<T> 都会增加活动的不可变借用的数量。当 Ref<T> 值超出作用域时,不可变借用的计数减少 1。就像编译时借用规则一样,RefCell<T> 允许我们在任何时间点有许多不可变的借用或一个可变的借用。
如果我们试图违反这些规则,RefCell<T> 不会像引用那样得到编译器错误,而是在运行时 panic。
我们修改 MockMessenger 的 send 方法,故意尝试为同一作用域创建两个可变借用,以说明 RefCell<T> 阻止我们在运行时这样做。
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
let mut one_borrow = self.sent_messages.borrow_mut();
let mut two_borrow = self.sent_messages.borrow_mut();
one_borrow.push(String::from(message));
two_borrow.push(String::from(message));
}
}
运行 cargo build,是通过的:
但是,运行 cargo test 时,测试失败了。
程序出现了 panic,信息为 already borrowed: BorrowMutError。
选择在运行时而不是编译时捕获借用错误,这意味着你可能直到代码部署到生产环境中才会发现错误。然而,使用 RefCell<T> 可以编写一个 Mock 对象,它可以修改自己来跟踪它在只允许不可变值的上下文中使用时看到的消息。你可以使用 RefCell<T> 来获得比常规引用提供的更多的功能,尽管它需要权衡。
通过 Rc<T> 和 RefCell<T> 允许可变数据的多个所有者
Rc<T> 和 RefCell<T> 通常结合在一起使用。
回想一下,Rc<T> 允许对数据的多个使用者,但它只提供对该数据的不可变访问。如果用一个 Rc<T> 包含一个 RefCell<T>,你可以得到一个值,可以有多个所有者,并且可以改变!
回想一下之前的 cons 列表,我们使用 Rc<T> 来允许多个列表共享另一个列表的所有权。因为 Rc<T> 只保存不可变的值,所以一旦创建了列表中的任何值,就不能更改它们。让我们添加 RefCell<T>,以便它能够更改列表中的值。
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
enum List {
Cons(Rc<RefCell<i32>>, Rc<List>),
Nil,
}
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:?}");
}
我们创建了一个值,它是 Rc<RefCell<i32>> 的实例,并将其存储在一个名为 value 的变量中。然后,我们在 a 中创建一个 List,它带有保存值的 Cons 变体。我们需要克隆 value,这样 a 和 value 都拥有内部值 5 的所有权。
我们将列表 a 封装在 Rc<T> 中,这样当我们创建列表 b 和 c 时,它们都可以引用 a。
我们想要给 value 中的值加 10,通过在value上调用 borrow_mut 来实现这一点。*value 这里发生了隐式解引用,将 Rc<T> 解引用到内部的 RefCell<T>。borrow_mut 方法返回一个 RefMut<T>,对其解引用并更改内部值。
程序输出:
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))
通过使用 RefCell<T>,我们有一个外部不可变的 List 值。但是我们可以使用 RefCell<T> 的方法来访问其内部可变性,这样我们就可以在需要的时候修改我们的数据。
借用规则的运行时检查可以保护我们免受数据竞争的影响,虽然会增加运行时开销,但有时为了数据结构中的这种灵活性而牺牲一点速度是值得的。
注意,RefCell<T> 不能用于多线程!Mutex<T> 是 RefCell<T> 的线程安全版本。