RefCell<T> 与内部可变性模式

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到RefCell 与内部可变性模式。本实验是 Rust 程序设计语言 的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,我们将探索 Rust 中的内部可变性概念,以及如何使用 RefCell<T> 类型来实现它。

RefCell<T> 与内部可变性模式

内部可变性 是 Rust 中的一种设计模式,它允许你即使在数据存在不可变引用的情况下也能对其进行变异;通常,这种操作是被借用规则禁止的。为了对数据进行变异,该模式在数据结构内部使用 unsafe 代码来打破 Rust 中关于变异和借用的常规规则。unsafe 代码向编译器表明我们正在手动检查规则,而不是依赖编译器为我们检查;我们将在第 19 章中更深入地讨论 unsafe 代码。

只有当我们能够确保在运行时遵循借用规则时,我们才能使用采用内部可变性模式的类型,尽管编译器无法保证这一点。涉及的 unsafe 代码随后会被包装在一个安全的 API 中,并且外部类型仍然是不可变的。

让我们通过研究遵循内部可变性模式的 RefCell<T> 类型来探索这个概念。

使用 RefCell<T> 在运行时强制实施借用规则

Rc<T> 不同,RefCell<T> 类型表示对其持有的数据的单一所有权。那么 RefCell<T>Box<T> 这样的类型有何不同呢?回忆一下你在第4章中学到的借用规则:

  • 在任何给定时间,你可以有一个可变引用或者任意数量的不可变引用(但不能同时拥有两者)。
  • 引用必须始终有效。

对于引用和 Box<T>,借用规则的不变性在编译时得到强制实施。对于 RefCell<T>,这些不变性在运行时得到强制实施。对于引用,如果你违反这些规则,将会得到一个编译时错误。对于 RefCell<T>,如果你违反这些规则,你的程序将会恐慌并退出。

在编译时检查借用规则的优点是,错误会在开发过程中更早地被捕获,并且对运行时性能没有影响,因为所有分析都是预先完成的。出于这些原因,在大多数情况下,在编译时检查借用规则是最佳选择,这就是为什么这是 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> 内部的值。

在不可变值内部变异值就是内部可变性模式。让我们看一个内部可变性有用的情况,并研究它是如何实现的。

内部可变性:对不可变值的可变借用

借用规则的一个结果是,当你有一个不可变值时,你不能对其进行可变借用。例如,这段代码无法编译:

文件名:src/main.rs

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
  |
2 |     let x = 5;
  |         - help: consider changing this to be mutable: `mut x`
3 |     let y = &mut x;
  |             ^^^^^^ cannot borrow as mutable

然而,在某些情况下,一个值在其方法中自我变异但对其他代码看起来是不可变的会很有用。该值的方法外部的代码将无法变异该值。使用 RefCell<T> 是获得内部可变性的一种方法,但 RefCell<T> 并没有完全避开借用规则:编译器中的借用检查器允许这种内部可变性,并且借用规则在运行时进行检查。如果你违反了规则,将会得到一个 panic! 而不是编译时错误。

让我们来看一个实际的例子,在这个例子中我们可以使用 RefCell<T> 来变异一个不可变值,并看看为什么这很有用。

内部可变性的一个用例:模拟对象

有时在测试期间,程序员会使用一种类型来替代另一种类型,以便观察特定行为并断言其实现是否正确。这种占位类型称为测试替身。可以把它想象成电影制作中的替身演员,在拍摄特别棘手的场景时,由替身演员代替演员出演。在运行测试时,测试替身会替代其他类型。模拟对象是特定类型的测试替身,它会记录测试期间发生的事情,这样你就可以断言是否发生了正确的操作。

Rust 中的对象与其他语言中的对象概念不同,并且 Rust 的标准库中没有像其他一些语言那样内置模拟对象功能。不过,你肯定可以创建一个结构体来实现与模拟对象相同的目的。

下面是我们要测试的场景:我们将创建一个库,该库会根据最大值跟踪一个值,并根据当前值与最大值的接近程度发送消息。例如,这个库可用于跟踪用户允许进行的 API 调用次数配额。

我们的库仅提供跟踪值与最大值的接近程度以及在不同时刻应发送什么消息的功能。使用我们库的应用程序需要提供发送消息的机制:应用程序可以在应用内放置消息、发送电子邮件、发送短信或执行其他操作。库不需要了解这些细节。它所需要的只是实现我们提供的名为 Messenger 的 trait 的某个东西。清单 15 - 20 展示了库代码。

文件名:src/lib.rs

pub trait Messenger {
  1 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,
        }
    }

  2 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: You're at 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
               .send("Warning: You're at 75% of your quota!");
        }
    }
}

清单 15 - 20:一个用于跟踪值与最大值的接近程度并在值达到特定水平时发出警告的库

这段代码的一个重要部分是,Messenger trait 有一个名为 send 的方法,该方法接受对 self 的不可变引用和消息文本 [1]。这个 trait 是我们的模拟对象需要实现的接口,这样模拟对象就可以像真实对象一样被使用。另一个重要部分是,我们想要测试 LimitTracker 上的 set_value 方法的行为 [2]。我们可以更改传递给 value 参数的值,但 set_value 没有返回任何东西供我们进行断言。我们希望能够说,如果我们使用实现了 Messenger trait 的某个东西和特定的 max 值创建一个 LimitTracker,当我们为 value 传递不同的数字时,会告知信使发送适当的消息。

我们需要一个模拟对象,当我们调用 send 时,它不会发送电子邮件或短信,而是只跟踪被告知发送的消息。我们可以创建模拟对象的新实例,创建一个使用该模拟对象的 LimitTracker,调用 LimitTracker 上的 set_value 方法,然后检查模拟对象是否有我们期望的消息。清单 15 - 21 展示了尝试实现这样一个模拟对象的代码,但借用检查器不允许这样做。

文件名:src/lib.rs

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

  1 struct MockMessenger {
      2 sent_messages: Vec<String>,
    }

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

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

    #[test]
  6 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 结构体 [1],它有一个 sent_messages 字段,该字段是一个 String 值的 Vec [2],用于跟踪被告知发送的消息。我们还定义了一个关联函数 new [3],以便于创建新的 MockMessenger 值,这些值以空消息列表开始。然后我们为 MockMessenger 实现 Messenger trait [4],这样我们就可以将 MockMessenger 传递给 LimitTracker。在 send 方法的定义中 [5],我们将传入的消息作为参数,并将其存储在 MockMessengersent_messages 列表中。

在测试中,我们测试当 LimitTracker 被告知将 value 设置为超过 max 值的 75% 时会发生什么 [6]。首先,我们创建一个新的 MockMessenger,它将以空消息列表开始。然后我们创建一个新的 LimitTracker,并将新的 MockMessenger 的引用和 max100 传递给它。我们使用值 80 调用 LimitTracker 上的 set_value 方法,该值超过了 100 的 75%。然后我们断言 MockMessenger 正在跟踪的消息列表现在应该有一条消息。

然而,这段测试有一个问题,如下所示:

error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a
`&` reference
  --> src/lib.rs:58:13
   |
2  |     fn send(&self, msg: &str);
   |             ----- help: consider changing that to be a mutable reference:
`&mut self`
...
58 |             self.sent_messages.push(String::from(message));
   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `self` is a
`&` reference, so the data it refers to cannot be borrowed as mutable

我们不能修改 MockMessenger 来跟踪消息,因为 send 方法接受对 self 的不可变引用。我们也不能按照错误提示使用 &mut self,因为那样 send 方法的签名就与 Messenger trait 定义中的签名不匹配了(你可以尝试一下,看看会得到什么错误消息)。

在这种情况下,内部可变性就能帮上忙了!我们将把 sent_messages 存储在一个 RefCell<T> 中,然后 send 方法就能够修改 sent_messages 来存储我们看到的消息。清单 15 - 22 展示了具体实现。

文件名:src/lib.rs

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

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

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

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

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        --snip--

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

清单 15 - 22:在外部值被视为不可变时使用 RefCell<T> 来变异内部值

sent_messages 字段现在是 RefCell<Vec<String>> 类型 [1],而不是 Vec<String>。在 new 函数中,我们围绕空向量创建一个新的 RefCell<Vec<String>> 实例 [2]。

对于 send 方法的实现,第一个参数仍然是对 self 的不可变借用,这与 trait 定义匹配。我们对 self.sent_messages 中的 RefCell<Vec<String>> 调用 borrow_mut [3],以获取对 RefCell<Vec<String>> 内部值(即向量)的可变引用。然后我们可以对向量的可变引用调用 push 来跟踪测试期间发送的消息。

我们必须做出的最后一个更改是在断言中:为了查看内部向量中有多少项,我们对 RefCell<Vec<String>> 调用 borrow 以获取对向量的不可变引用 [4]。

现在你已经了解了如何使用 RefCell<T>,让我们深入研究一下它是如何工作的!

使用 RefCell<T> 在运行时跟踪借用情况

在创建不可变引用和可变引用时,我们分别使用 &&mut 语法。对于 RefCell<T>,我们使用 borrowborrow_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> 的实现会在运行时恐慌。清单 15 - 23 展示了对清单 15 - 22 中 send 方法实现的修改。我们故意尝试在同一作用域内创建两个活动的可变借用,以说明 RefCell<T> 在运行时会阻止我们这样做。

文件名:src/lib.rs

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));
    }
}

清单 15 - 23:在同一作用域内创建两个可变引用以查看 RefCell<T> 会恐慌

我们为从 borrow_mut 返回的 RefMut<T> 智能指针创建一个变量 one_borrow。然后我们以相同的方式在变量 two_borrow 中创建另一个可变借用。这在同一作用域内创建了两个可变引用,这是不允许的。当我们运行库的测试时,清单 15 - 23 中的代码将在没有任何错误的情况下编译,但测试会失败:

---- tests::it_sends_an_over_75_percent_warning_message stdout ----
thread 'main' panicked at 'already borrowed: BorrowMutError', src/lib.rs:60:53
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

注意,代码因消息 already borrowed: BorrowMutError 而恐慌。这就是 RefCell<T> 在运行时处理违反借用规则的方式。

像我们在这里所做的那样,选择在运行时而不是编译时捕获借用错误,意味着你可能会在开发过程的后期才发现代码中的错误:可能直到你的代码部署到生产环境中才会发现。此外,由于在运行时而不是编译时跟踪借用情况,你的代码会在运行时产生轻微的性能开销。然而,使用 RefCell<T> 可以让你编写一个模拟对象,在只允许不可变值的上下文中使用它时,该模拟对象可以自我修改以跟踪它所看到的消息。尽管有这些权衡,你仍然可以使用 RefCell<T> 来获得比普通引用更多的功能。

使用 Rc<T> 和 RefCell<T> 允许可变数据有多个所有者

使用 RefCell<T> 的一种常见方式是将其与 Rc<T> 结合使用。回忆一下,Rc<T> 允许你对某些数据有多个所有者,但它只提供对该数据的不可变访问。如果你有一个持有 RefCell<T>Rc<T>,你可以得到一个既能有多个所有者能变异的值!

例如,回忆一下清单 15 - 18 中的链表示例,我们使用 Rc<T> 允许多个链表共享另一个链表的所有权。因为 Rc<T> 只持有不可变值,一旦我们创建了链表中的值,就不能再更改它们。让我们加入 RefCell<T> 以获得更改链表中值的能力。清单 15 - 24 展示了通过在 Cons 定义中使用 RefCell<T>,我们可以修改存储在所有链表中的值。

文件名: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() {
  1 let value = Rc::new(RefCell::new(5));

  2 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));

  3 *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 的变量中 [1],这样我们稍后就可以直接访问它。然后我们在 a 中创建一个 List,其 Cons 变体持有 value [2]。我们需要克隆 value,这样 avalue 都拥有内部值 5 的所有权,而不是将所有权从 value 转移到 a,或者让 avalue 借用。

我们将链表 a 包装在一个 Rc<T> 中,这样当我们创建链表 bc 时,它们都可以引用 a,这与我们在清单 15 - 18 中所做的一样。

在我们创建了 abc 中的链表之后,我们想将 value 中的值增加 10 [3]。我们通过对 value 调用 borrow_mut 来做到这一点,它使用了我们在“-> 运算符在哪里?”中讨论的自动解引用功能,将 Rc<T> 解引用为内部的 RefCell<T> 值。borrow_mut 方法返回一个 RefMut<T> 智能指针,我们对其使用解引用运算符并更改内部值。

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

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>

总结

恭喜你!你已经完成了 RefCell<T> 和内部可变性模式实验。你可以在 LabEx 中练习更多实验来提升你的技能。