RefCell<T>
和内部可变性模式
内部可变性 是 Rust 中的一种设计模式,它允许你即使在有对该数据的不可变引用的情况下也能改变数据;通常,这种行为是被借用规则所禁止的。为了改变数据,该模式在数据结构内部使用 unsafe
代码来弯曲 Rust 通常的控制可变性和借用的规则。不安全代码向编译器表明,我们正在手动检查规则,而不是依赖编译器为我们检查;我们将在第 19 章中更详细地讨论不安全代码。
只有当我们能够确保即使编译器无法保证借用规则在运行时也会被遵守时,我们才能使用这种内部可变性模式的类型。所涉及的 unsafe
代码随后被封装在安全的 API 中,并且外部类型仍然是不可变的。
让我们通过查看遵循内部可变性模式的 RefCell<T>
类型来探索这个概念。
使用 RefCell<T>
在运行时强制借用规则
与 Rc<T>
不同,RefCell<T>
类型表示对其保存的数据的单一所有权。那么,是什么使 RefCell<T>
与 Box<T>
这样的类型不同呢?回忆一下你在第 4 章中学习的借用规则
- 在任何给定时间,你可以拥有要么(但不能两者都)一个可变引用或任意数量的不可变引用。
- 引用必须始终有效。
对于引用和 Box<T>
,借用规则的约束是在编译时强制执行的。对于 RefCell<T>
,这些约束是在运行时强制执行的。对于引用,如果你违反这些规则,你会收到编译器错误。对于 RefCell<T>
,如果你违反这些规则,你的程序会 panic 并退出。
在编译时检查借用规则的优点是,错误会在开发过程中更早地被捕获,并且对运行时性能没有影响,因为所有的分析都是预先完成的。由于这些原因,在大多数情况下,在编译时检查借用规则是最佳选择,这也是为什么这是 Rust 的默认设置。
相反,在运行时检查借用规则的优点是,允许某些内存安全场景,这些场景会被编译时检查所禁止。静态分析,如 Rust 编译器,本质上是保守的。代码的某些属性是不可能通过分析代码来检测的:最著名的例子是停机问题,这超出了本书的范围,但这是一个有趣的研究主题。
由于一些分析是不可能的,如果 Rust 编译器不能确定代码符合所有权规则,它可能会拒绝一个正确的程序;在这种方式下,它是保守的。如果 Rust 接受了一个不正确的程序,用户将无法信任 Rust 所做的保证。然而,如果 Rust 拒绝了一个正确的程序,程序员会感到不便,但不会发生灾难性的事情。当你确定你的代码遵循借用规则,但编译器无法理解和保证这一点时,RefCell<T>
类型很有用。
与 Rc<T>
类似,RefCell<T>
仅用于单线程场景,如果你尝试在多线程上下文中使用它,则会给你一个编译时错误。我们将在第 16 章讨论如何在多线程程序中获得 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>
内部的值。
改变一个不可变值内部的值是内部可变性模式。让我们看看内部可变性有用的情况,并研究它是如何实现的。
内部可变性:对不可变值的可变借用
借用规则的一个后果是,当你有一个不可变的值时,你不能可变地借用它。例如,这段代码不会编译
fn main() {
let x = 5;
let y = &mut x;
}
如果你尝试编译这段代码,你会收到以下错误
$ cargo run
Compiling borrowing v0.1.0 (file:///projects/borrowing)
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>
并不能完全绕过借用规则:编译器中的借用检查器允许这种内部可变性,并且借用规则是在运行时检查的。如果你违反了规则,你会得到一个 panic!
而不是编译器错误。
让我们通过一个实际的例子来了解如何使用 RefCell<T>
来改变一个不可变的值,并了解为什么这很有用。
内部可变性的用例:模拟对象
有时在测试期间,程序员会使用一个类型来代替另一个类型,以便观察特定的行为并断言它是否正确实现。这个占位符类型被称为测试替身。把它想象成电影制作中的“特技替身”,一个人介入并代替演员来完成一个特定的棘手场景。当我们在运行测试时,测试替身会代替其他类型。模拟对象是特定类型的测试替身,它记录在测试期间发生的事情,以便你可以断言发生了正确的操作。
Rust 没有像其他语言那样具有对象的概念,并且 Rust 没有像其他一些语言那样将模拟对象功能内置到标准库中。然而,你绝对可以创建一个结构体,它将达到与模拟对象相同的目的。
这是我们将要测试的场景:我们将创建一个库,该库跟踪一个值相对于最大值,并根据当前值与最大值的接近程度发送消息。例如,这个库可以用来跟踪用户允许进行的 API 调用数量的配额。
我们的库将只提供跟踪值与最大值的接近程度以及在什么时间应该发送什么消息的功能。使用我们库的应用程序将需要提供发送消息的机制:应用程序可以将消息放入应用程序中、发送电子邮件、发送短信或其他内容。该库不需要知道这些细节。它只需要一些实现我们提供的名为 Messenger
的 trait 的东西。清单 15-20 显示了库代码
文件名:src/lib.rs
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!");
}
}
}
清单 15-20:一个库,用于跟踪值与最大值的接近程度,并在值达到特定级别时发出警告
这段代码的一个重要部分是 Messenger
trait 有一个名为 send
的方法,该方法接受对 self
的不可变引用和消息文本。这个 trait 是我们的模拟对象需要实现的接口,以便可以像使用真实对象一样使用模拟对象。另一个重要部分是我们想测试 LimitTracker
上 set_value
方法的行为。我们可以更改我们为 value
参数传入的内容,但 set_value
不会返回任何内容供我们进行断言。我们希望能够说,如果我们创建一个 LimitTracker
,它带有一个实现 Messenger
trait 的东西和一个特定的 max
值,当我们为 value
传递不同的数字时,会告知 messenger 发送适当的消息。
我们需要一个模拟对象,当我们调用 send
时,它不会发送电子邮件或短信,而只会跟踪它被告知要发送的消息。我们可以创建一个新的模拟对象实例,创建一个使用模拟对象的 LimitTracker
,调用 LimitTracker
上的 set_value
方法,然后检查模拟对象是否包含我们期望的消息。清单 15-21 显示了尝试实现模拟对象来做到这一点,但借用检查器不允许这样做
文件名:src/lib.rs
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::*;
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);
}
}
清单 15-21:尝试实现一个不被借用检查器允许的 MockMessenger
此测试代码定义了一个 MockMessenger
结构体,它有一个 sent_messages
字段,其中包含一个 String
值的 Vec
,用于跟踪它被告知要发送的消息。我们还定义了一个关联函数 new
,以便方便地创建以空消息列表开头的新 MockMessenger
值。然后,我们为 MockMessenger
实现 Messenger
trait,以便我们可以将 MockMessenger
提供给 LimitTracker
。在 send
方法的定义中,我们将传入的消息作为参数,并将其存储在 MockMessenger
的 sent_messages
列表中。
在测试中,我们正在测试当 LimitTracker
被告知将 value
设置为大于 max
值的 75% 的值时会发生什么。首先,我们创建一个新的 MockMessenger
,它将以空消息列表开始。然后,我们创建一个新的 LimitTracker
,并为其提供对新 MockMessenger
的引用和 100 的 max
值。我们使用 80 的值调用 LimitTracker
上的 set_value
方法,该值大于 100 的 75%。然后,我们断言 MockMessenger
正在跟踪的消息列表现在应该包含一条消息。
然而,这个测试有一个问题,如下所示
$ cargo test
Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
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
来跟踪消息,因为 send
方法接受对 self
的不可变引用。我们也无法接受错误文本的建议而改用 &mut self
,因为那样 send
的签名将与 Messenger
trait 定义中的签名不匹配(请随意尝试并查看你会收到什么错误消息)。
这是一个内部可变性可以提供帮助的情况!我们将 sent_messages
存储在 RefCell<T>
中,然后 send
方法将能够修改 sent_messages
以存储我们看到的消息。清单 15-22 显示了它的样子
文件名:src/lib.rs
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() {
// --snip--
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-22:使用 RefCell<T>
在外部值被视为不可变的情况下改变内部值
sent_messages
字段现在是 RefCell<Vec<String>>
类型,而不是 Vec<String>
。在 new
函数中,我们在空向量周围创建一个新的 RefCell<Vec<String>>
实例。
在 send
方法的实现中,第一个参数仍然是 self
的不可变借用,这与 trait 的定义相匹配。我们对 self.sent_messages
中的 RefCell<Vec<String>>
调用 borrow_mut
,以获取 RefCell<Vec<String>>
内部值的可变引用,也就是那个 vector。然后我们可以对 vector 的可变引用调用 push
,来跟踪测试期间发送的消息。
我们必须做的最后一个更改是在断言中:要查看内部 vector 中有多少项,我们对 RefCell<Vec<String>>
调用 borrow
,以获取 vector 的不可变引用。
现在你已经了解了如何使用 RefCell<T>
,让我们深入研究它的工作原理!
使用 RefCell<T>
在运行时跟踪借用
在创建不可变引用和可变引用时,我们分别使用 &
和 &mut
语法。对于 RefCell<T>
,我们使用 borrow
和 borrow_mut
方法,它们是属于 RefCell<T>
的安全 API 的一部分。 borrow
方法返回智能指针类型 Ref<T>
,而 borrow_mut
返回智能指针类型 RefMut<T>
。两种类型都实现了 Deref
,因此我们可以像对待常规引用一样对待它们。
RefCell<T>
会跟踪当前有多少个 Ref<T>
和 RefMut<T>
智能指针处于活动状态。每次我们调用 borrow
时,RefCell<T>
都会增加其活动不可变借用的计数。当 Ref<T>
值超出作用域时,不可变借用的计数会减少 1。就像编译时的借用规则一样,RefCell<T>
允许我们在任何时候拥有多个不可变借用或一个可变借用。
如果我们试图违反这些规则,那么 RefCell<T>
的实现将在运行时 panic,而不是像使用引用那样得到编译器错误。代码清单 15-23 显示了代码清单 15-22 中 send
实现的修改。我们故意尝试为同一作用域创建两个活动的可变借用,以说明 RefCell<T>
如何在运行时阻止我们这样做。
文件名:src/lib.rs
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) {
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));
}
}
#[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-23:在同一作用域中创建两个可变引用以查看 RefCell<T>
是否会 panic
我们为从 borrow_mut
返回的 RefMut<T>
智能指针创建一个名为 one_borrow
的变量。然后,我们以相同的方式在变量 two_borrow
中创建另一个可变借用。这使得在同一作用域中有两个可变引用,这是不允许的。当我们运行库的测试时,代码清单 15-23 中的代码将编译,而不会出现任何错误,但测试将失败。
$ cargo test
Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)
running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED
failures:
---- tests::it_sends_an_over_75_percent_warning_message stdout ----
thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at src/lib.rs:60:53:
already borrowed: BorrowMutError
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::it_sends_an_over_75_percent_warning_message
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
请注意,代码因消息 already borrowed: BorrowMutError
而 panic。这就是 RefCell<T>
在运行时处理违反借用规则的方式。
像我们在这里所做的那样,选择在运行时而不是编译时捕获借用错误,意味着你可能会在开发过程的后期发现代码中的错误:可能直到你的代码部署到生产环境时才会发现。此外,由于在运行时而不是编译时跟踪借用,你的代码将产生很小的运行时性能损失。但是,使用 RefCell<T>
可以编写一个 mock 对象,该对象可以修改自身,以在你将它用于仅允许不可变值的上下文中时,跟踪它所看到的消息。尽管有权衡,你仍然可以使用 RefCell<T>
来获得比常规引用更多的功能。
通过组合 Rc<T>
和 RefCell<T>
来实现可变数据的多个所有者
使用 RefCell<T>
的常见方法是与 Rc<T>
结合使用。回想一下,Rc<T>
允许你拥有一些数据的多个所有者,但它仅提供对该数据的不可变访问。如果你有一个持有 RefCell<T>
的 Rc<T>
,那么你可以获得一个可以有多个所有者并且可以被修改的值!
例如,回想一下代码清单 15-18 中的 cons list 示例,我们在其中使用 Rc<T>
来允许多个 list 共享对另一个 list 的所有权。因为 Rc<T>
仅持有不可变值,所以一旦创建了 list,我们就无法更改 list 中的任何值。让我们添加 RefCell<T>
以获得更改 list 中值的能力。代码清单 15-24 显示,通过在 Cons
定义中使用 RefCell<T>
,我们可以修改存储在所有 list 中的值。
文件名:src/main.rs
#[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:?}"); }
代码清单 15-24:使用 Rc<RefCell<i32>>
创建可以修改的 List
我们创建一个 Rc<RefCell<i32>>
的实例值,并将其存储在名为 value
的变量中,以便稍后可以直接访问它。然后,我们在 a
中创建一个 List
,其中包含一个保存 value
的 Cons
变体。我们需要克隆 value
,以便 a
和 value
都拥有内部 5
值的所有权,而不是将所有权从 value
转移到 a
或让 a
从 value
借用。
我们将 list a
包装在 Rc<T>
中,以便当我们创建 list b
和 c
时,它们都可以引用 a
,这与我们在代码清单 15-18 中所做的一样。
在我们在 a
、b
和 c
中创建 list 之后,我们想要将 10 添加到 value
中的值。我们通过对 value
调用 borrow_mut
来实现这一点,它使用我们在第 5 章中讨论的自动解引用功能(请参阅 “->
运算符在哪里?” 部分)。将 Rc<T>
解引用为内部 RefCell<T>
值。 borrow_mut
方法返回一个 RefMut<T>
智能指针,我们对其使用解引用运算符并更改内部值。
当我们打印 a
、b
和 c
时,我们可以看到它们都具有修改后的值 15 而不是 5。
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
Running `target/debug/cons-list`
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>
的线程安全版本,我们将在第 16 章中讨论 Mutex<T>
。