智能指针 - RefCell<T> 与内部可变性模式

优质
小牛编辑
128浏览
2023-12-01

内部可变性Interior mutability)是 Rust 中的一个设计模式,它允许你即使在有不可变引用时改变数据,这通常是借用规则所不允许的。为此,该模式在数据结构中使用 unsafe 代码来模糊 Rust 通常的可变性和借用规则。我们还未讲到不安全代码;第十九章会学习它们。当可以确保代码在运行时会遵守借用规则,即使编译器不能保证的情况,可以选择使用那些运用内部可变性模式的类型。所涉及的 unsafe 代码将被封装进安全的 API 中,而外部类型仍然是不可变的。

让我们通过遵循内部可变性模式的 RefCell<T> 类型来开始探索。

不同于 Rc<T>RefCell<T> 代表其数据的唯一的所有权。那么是什么让 RefCell<T> 不同于像 Box<T> 这样的类型呢?回忆一下第四章所学的借用规则:

  1. 在任意给定时间,只能 拥有如下中的一个:
    • 一个可变引用。
    • 任意数量的不可变引用。
  2. 引用必须总是有效的。

对于引用和 Box<T>,借用规则的不可变性作用于编译时。对于 RefCell<T>,这些不可变性作用于 运行时。对于引用,如果违反这些规则,会得到一个编译错误。而对于RefCell<T>,违反这些规则会 panic!

在编译时检查借用规则的好处是这些错误将在开发过程的早期被捕获同时对没有运行时性能影响,因为所有的分析都提前完成了。为此,在编译时检查借用规则是大部分情况的最佳选择,这也正是其为何是 Rust 的默认行为。

相反在运行时检查借用规则的好处是特定内存安全的场景是允许的,而它们在编译时检查中是不允许的。静态分析,正如 Rust 编译器,是天生保守的。代码的一些属性则不可能通过分析代码发现:其中最著名的就是 ,这超出了本书的范畴,不过如果你感兴趣的话这是一个值得研究的有趣主题。

因为一些分析是不可能的,如果 Rust 编译器不能通过所有权规则编译,它可能会拒绝一个正确的程序;从这种角度考虑它是保守的。如果 Rust 接受不正确的程序,那么人们也就不会相信 Rust 所做的保证了。然而,如果 Rust 拒绝正确的程序,会给程序员带来不便,但不会带来灾难。RefCell<T> 正是用于当你确信代码遵守借用规则,而编译器不能理解和确定的时候。

类似于 Rc<T>RefCell<T> 只能用于单线程场景。如果尝试在多线程上下文中使用RefCell<T>,会得到一个编译错误。第十六章会介绍如何在多线程程序中使用 RefCell<T> 的功能。

<!-- I'm not really clear at this point what the difference between Rc and RefCell is, perhaps a succinct round up would help? -->

如下为选择 Box<T>Rc<T>RefCell<T> 的理由:

  • Rc<T> 允许相同数据有多个所有者;Box<T>RefCell<T> 有单一所有者。
  • Box<T> 允许在编译时执行不可变(或可变)借用检查;Rc<T>仅允许在编译时执行不可变借用检查;RefCell<T> 允许在运行时执行不可变(或可变)借用检查。
  • 因为 RefCell<T> 允许在运行时执行可变借用检查,所以我们可以在即便 RefCell<T> 自身是不可变的情况下修改其内部的值。

最后一个理由便是指 内部可变性 模式。让我们看看何时内部可变性是有用的,并讨论这是如何成为可能的。

借用规则的一个推论是当有一个不可变值时,不能可变的借用它。例如,如下代码不能编译:

如果尝试编译,会得到如下错误:

  1. error[E0596]: cannot borrow immutable local variable `x` as mutable
  2. --> src/main.rs:3:18
  3. |
  4. 2 | let x = 5;
  5. | - consider changing this to `mut x`
  6. 3 | let y = &mut x;
  7. | ^ cannot borrow mutably

然而,特定情况下在值的方法内部能够修改自身是很有用的;而不是在其他代码中,此时值仍然是不可变。值方法外部的代码不能修改其值。RefCell<T> 是一个获得内部可变性的方法。RefCell<T> 并没有完全绕开借用规则,编译器中的借用检查器允许内部可变性并相应的在运行时检查借用规则。如果违反了这些规则,会得到 panic! 而不是编译错误。

让我们通过一个实际的例子来探索何处可以使用 RefCell<T> 来修改不可变值并看看为何这么做是有意义的。

内部可变性的用例:mock 对象

测试替身test double)是一个通用编程概念,它代表一个在测试中替代某个类型的类型。mock 对象 是特定类型的测试替身,它们记录测试过程中发生了什么以便可以断言操作是正确的。

虽然 Rust 没有与其他语言中的对象完全相同的对象,Rust 也没有像其他语言那样在标准库中内建 mock 对象功能,不过我们确实可以创建一个与 mock 对象有着相同功能的结构体。

如下是一个我们想要测试的场景:我们在编写一个记录某个值与最大值的差距的库,并根据当前值与最大值的差距来发送消息。例如,这个库可以用于记录用户所允许的 API 调用数量限额。

文件名: src/lib.rs

  1. pub trait Messenger {
  2. fn send(&self, msg: &str);
  3. }
  4. pub struct LimitTracker<'a, T: 'a + Messenger> {
  5. messenger: &'a T,
  6. value: usize,
  7. max: usize,
  8. }
  9. impl<'a, T> LimitTracker<'a, T>
  10. where T: Messenger {
  11. pub fn new(messenger: &T, max: usize) -> LimitTracker<T> {
  12. LimitTracker {
  13. messenger,
  14. value: 0,
  15. max,
  16. }
  17. }
  18. self.value = value;
  19. let percentage_of_max = self.value as f64 / self.max as f64;
  20. if percentage_of_max >= 0.75 && percentage_of_max < 0.9 {
  21. self.messenger.send("Warning: You've used up over 75% of your quota!");
  22. } else if percentage_of_max >= 0.9 && percentage_of_max < 1.0 {
  23. self.messenger.send("Urgent warning: You've used up over 90% of your quota!");
  24. } else if percentage_of_max >= 1.0 {
  25. self.messenger.send("Error: You are over your quota!");
  26. }
  27. }
  28. }

示例 15-23:一个记录某个值与最大值差距的库,并根据此值的特定级别发出警告

这些代码中一个重要部分是拥有一个方法 sendMessenger trait,其获取一个 self 的不可变引用和文本信息。这是我们的 mock 对象所需要拥有的接口。另一个重要的部分是我们需要测试 LimitTrackerset_value 方法的行为。可以改变传递的 value 参数的值,不过 set_value 并没有返回任何可供断言的值。也就是说,如果使用某个实现了 Messenger trait 的值和特定的 max 创建 LimitTracker,当传递不同 value 值时,消息发送者应被告知发送合适的消息。

我们所需的 mock 对象是,调用 send 不同于实际发送 email 或短息,其只记录信息被通知要发送了。可以新建一个 mock 对象示例,用其创建 LimitTracker,调用 LimitTrackerset_value 方法,然后检查 mock 对象是否有我们期望的消息。示例 15-24 展示了一个如此尝试的 mock 对象实现,不过借用检查器并不允许:

文件名: src/lib.rs

示例 15-24:尝试实现 MockMessenger,借用检查器并不允许

测试代码定义了一个 MockMessenger 结构体,其 字段为一个 String 值的 Vec 用来记录被告知发送的消息。我们还定义了一个关联函数 new 以便于新建从空消息列表开始的 MockMessenger 值。接着为 MockMessenger 实现 Messenger trait 这样就可以为 LimitTracker 提供一个 MockMessenger。在 send 方法的定义中,获取传入的消息作为参数并储存在 MockMessengersent_messages 列表中。

在测试中,我们测试了当 LimitTracker 被告知将 value 设置为超过 max 值 75% 的某个值。首先新建一个 MockMessenger,其从空消息列表开始。接着新建一个 LimitTracker 并传递新建 MockMessenger 的引用和 max 值 100。我们使用值 80 调用 LimitTrackerset_value 方法,这超过了 100 的 75%。接着断言 MockMessenger 中记录的消息列表应该有一条消息。

然而,这个测试是有问题的:

  1. error[E0596]: cannot borrow immutable field `self.sent_messages` as mutable
  2. --> src/lib.rs:46:13
  3. |
  4. 45 | fn send(&self, message: &str) {
  5. | ----- use `&mut self` here to make mutable
  6. 46 | self.sent_messages.push(String::from(message));
  7. | ^^^^^^^^^^^^^^^^^^ cannot mutably borrow immutable field

不能修改 MockMessenger 来记录消息,因为 send 方法获取 self 的不可变引用。我们也不能参考错误文本的建议使用 &mut self 替代,因为这样 send 的签名就不符合 Messenger trait 定义中的签名了(请随意尝试如此修改并看看会出现什么错误信息)。

这正是内部可变性的用武之地!我们将通过 RefCell 来储存 sent_messages,然而 send 将能够修改 sent_messages 并储存消息。示例 15-25 展示了代码:

文件名: src/lib.rs

  1. #[cfg(test)]
  2. mod tests {
  3. use super::*;
  4. use std::cell::RefCell;
  5. struct MockMessenger {
  6. sent_messages: RefCell<Vec<String>>,
  7. }
  8. impl MockMessenger {
  9. fn new() -> MockMessenger {
  10. MockMessenger { sent_messages: RefCell::new(vec![]) }
  11. }
  12. }
  13. impl Messenger for MockMessenger {
  14. fn send(&self, message: &str) {
  15. self.sent_messages.borrow_mut().push(String::from(message));
  16. }
  17. #[test]
  18. fn it_sends_an_over_75_percent_warning_message() {
  19. // --snip--
  20. # let mock_messenger = MockMessenger::new();
  21. # let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
  22. # limit_tracker.set_value(75);
  23. assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
  24. }
  25. }

示例 15-25:使用 RefCell<T> 能够在外部值被认为是不可变的情况下修改内部值

现在 sent_messages 字段的类型是 RefCell<Vec<String>> 而不是 Vec<String>。在 new 函数中新建了一个 RefCell 示例替代空 vector。

对于 send 方法的实现,第一个参数仍为 self 的不可变借用,这是符合方法定义的。我们调用 self.sent_messagesRefCellborrow_mut 方法来获取 RefCell 中值的可变引用,这是一个 vector。接着可以对 vector 的可变引用调用 push 以便记录测试过程中看到的消息。

最后必须做出的修改位于断言中:为了看到其内部 vector 中有多少个项,需要调用 RefCellborrow 以获取 vector 的不可变引用。

现在我们见识了如何使用 RefCell<T>,让我们研究一下它怎样工作的!

RefCell<T> 记录当前有多少个活动的 RefRefMut 智能指针。每次调用 borrowRefCell<T> 将活动的不可变借用计数加一。当 Ref 值离开作用域时,不可变借用计数减一。就像编译时借用规则一样,RefCell<T> 在任何时候只允许有多个不可变借用或一个可变借用。

如果我们尝试违反这些规则,相比引用时的编译时错误, 的实现会在运行时 panic!。示例 15-26 展示了对示例 15-25 中 send 实现的修改,这里我们故意尝试在相同作用域创建两个可变借用以便演示 RefCell<T> 不允许我们在运行时这么做:

文件名: src/lib.rs

示例 15-26:在同一作用域中创建两个可变引用并观察 RefCell<T> panic

这里为 borrow_mut 返回的 RefMut 智能指针创建了 one_borrow 变量。接着用相同的方式在变量 two_borrow 创建了另一个可变借用。这会在相同作用域中创建一个可变引用,这是不允许的,如果运行库的测试,编译时不会有任何错误,不过测试会失败:

  1. ---- tests::it_sends_an_over_75_percent_warning_message stdout ----
  2. thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at
  3. 'already borrowed: BorrowMutError', src/libcore/result.rs:906:4
  4. note: Run with `RUST_BACKTRACE=1` for a backtrace.

可以看到代码 panic 和信息already borrowed: BorrowMutError。这也就是 RefCell<T> 如何在运行时处理违反借用规则的情况。

在运行时捕获借用错误而不是编译时意味着将会在开发过程的后期才会发现错误 ———— 甚至有可能发布到生产环境才发现。还会因为在运行时而不是编译时记录借用而导致少量的运行时性能惩罚。然而,使用 RefCell 使得在只允许不可变值的上下文中编写修改自身以记录消息的 mock 对象成为可能。虽然有取舍,但是我们可以选择使用 RefCell<T> 来获得比常规引用所能提供的更多的功能。

RefCell<T> 的一个常见用法是与 Rc<T> 结合。回忆一下 Rc<T> 允许对相同数据有多个所有者,不过只能提供数据的不可变访问。如果有一个储存了 RefCell<T>Rc<T> 的话,就可以得到有多个所有者 并且 可以修改的值了!

例如,回忆示例 15-13 的 cons list 的例子中使用 Rc<T> 使得多个列表共享另一个列表的所有权。因为 Rc<T> 只存放不可变值,所以一旦创建了这些列表值后就不能修改。让我们加入 RefCell<T> 来获得修改列表中值的能力。示例 15-27 展示了通过在 Cons 定义中使用 RefCell<T>,我们就允许修改所有列表中的值了:

文件名: src/main.rs

  1. #[derive(Debug)]
  2. enum List {
  3. Cons(Rc<RefCell<i32>>, Rc<List>),
  4. Nil,
  5. }
  6. use List::{Cons, Nil};
  7. use std::rc::Rc;
  8. use std::cell::RefCell;
  9. fn main() {
  10. let value = Rc::new(RefCell::new(5));
  11. let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));
  12. let b = Cons(Rc::new(RefCell::new(6)), Rc::clone(&a));
  13. let c = Cons(Rc::new(RefCell::new(10)), Rc::clone(&a));
  14. *value.borrow_mut() += 10;
  15. println!("a after = {:?}", a);
  16. println!("b after = {:?}", b);
  17. println!("c after = {:?}", c);
  18. }

示例 15-27:使用 Rc<RefCell<i32>> 创建可以修改的 List

这里创建了一个 Rc<RefCell<i32> 实例并储存在变量 value 中以便之后直接访问。接着在 a 中用包含 valueCons 成员创建了一个 List。需要克隆 value 以便 avalue 都能拥有其内部值 5 的所有权,而不是将所有权从 value 移动到 a 或者让 a 借用 value

我们将列表 a 封装进了 Rc<T> 这样当创建列表 bc 时,他们都可以引用 a,正如示例 15-13 一样。

一旦创建了列表 abc,我们将 value 的值加 10。为此对 value 调用了 borrow_mut,这里使用了第五章讨论的自定解引用功能(“->运算符到哪去了?”)来解引用 Rc<T> 以获取其内部的 RefCell<T> 值。borrow_mut 方法返回 RefMut<T> 智能指针,可以对其使用解引用运算符并修改其内部值。

当我们打印出 abc 时,可以看到他们都拥有修改后的值 15 而不是 5:

这是非常巧妙的!通过使用 RefCell<T>,我们可以拥有一个表面上不可变的 List,不过可以使用 RefCell<T> 中提供内部可变性的方法来在需要时修改数据。RefCell<T> 的运行时借用规则检查也确实保护我们免于出现数据竞争,而且我们也决定牺牲一些速度来换取数据结构的灵活性。

标准库中也有其他提供内部可变性的类型,比如 Cell<T>,它有些类似(RefCell<T>)除了相比提供内部值的引用,其值被拷贝进和拷贝出 。还有 Mutex<T>,其提供线程间安全的内部可变性,下一章并发会讨论它的应用。请查看标准库来获取更多细节和不同类型之间的区别。