Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

15.6 RefCell与内部可变性:“摆脱”安全性限制

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<T>

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 引用。看个例子就明白了:

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> 并没有完全绕开借用规则。虽然编译时检查可以通过,但在运行时违反借用规则仍会导致程序 panic。

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

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

#![allow(unused)]
fn main() {
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:类型为 &'a T
    • value:类型为 usize
    • max:类型为 usize
  • 接着,一个 impl 块为 LimitTracker 定义了关联函数 new。其参数是类型为 &Tmessenger 和类型为 usizemax,返回一个 LimitTracker。这个函数创建一个 LimitTracker 实例,其中:

    • messenger 字段是 messenger 参数的值
    • value 字段是 0
    • max 字段是 max 参数的值
  • LimitTracker 还有一个名为 set_value 的方法。其第一个参数是对 self 的可变引用 &mut self,第二个参数是 value,类型为 usize。 方法内部的逻辑很简单。它把 self.value 字段除以 self.max 字段(两者都转换为 f64 以避免丢失精度)得到一个百分比,存入 percentage_of_max,并根据 percentage_of_max 的大小,使用 Messenger trait 的 send 方法发送不同的警告。

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

这里有一个问题。如果要测试这个 set_value 方法,就需要该方法输出一些可供断言的内容。但 set_value 实际上不返回任何值,因此不会提供任何可用于断言的结果。

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

为了解决这个问题,这里介绍测试替身(test double),中文也叫测试替代。它是一个通用的测试概念,表示测试中使用的替代品。在测试替身中,有一种特定类型叫做模拟对象(Mock Object),负责记录测试过程中发生的事情。我们可以利用这些记录来断言测试是否正确运行。

Rust 没有内置的等价物,标准库也不提供模拟对象,但我们可以自定义一个结构体来做同样的事情。

接着上面的代码继续写:

#![allow(unused)]
fn main() {
#[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,用于存储已发送的消息,类型为 Vec<String>

  • 然后通过 impl 块为 MockMessenger 添加 new 函数,用于创建 MockMessenger 实例。sent_messages 字段初始化为一个空的 Vector

  • 接着为 MockMessenger 实现程序开头定义的 Messenger trait。实现该 trait 后,就可以用 MockMessenger 创建 LimitTracker,因为 LimitTracker 要求其泛型类型实现 Messenger。 当调用 send 方法时,消息会被存入 MockMessengersent_messages 这个 Vector 中。

  • 最后,it_sends_an_over_75_percent_warning_message 测试函数测试超过 75% 的情况。 首先创建名为 mock_messengerMockMessenger 实例,再创建名为 limit_trackerLimitTracker 实例,然后在该 LimitTracker 实例上调用方法。最后通过检查 mock_messenger.sent_messages 中的元素数量来断言。

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

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 it 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 方法时:

#![allow(unused)]
fn main() {
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> 包起来:

#![allow(unused)]
fn main() {
struct MockMessenger {
    sent_messages: RefCell<Vec<String>>,
}
}

因为 RefCell<T> 不在预导入模块中,所以使用前需要先引入作用域:

#![allow(unused)]
fn main() {
use std::cell::RefCell;
}

改完之后,每一处使用 sent_messages 字段的代码也需要用 RefCell<T> 包装:

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

RefCell 该怎么用呢?用 RefCell 创建的数据可以通过 borrow_mut 方法修改。对参数调用 borrow_mut 会得到可变引用,因此 MockMessengersend 方法可以使用 borrow_mut

#![allow(unused)]
fn main() {
impl Messenger for MockMessenger {
    fn send(&self, message: &str) {
        self.sent_messages.borrow_mut().push(String::from(message));
    }
}
}

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

最后修改测试函数中的断言:

#![allow(unused)]
fn main() {
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,即可获得该值的不可变引用用于断言。

现在代码没有任何问题。完整代码如下:

#![allow(unused)]
fn main() {
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>,它实现了 DerefDerefMut trait

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

  • 每次调用 borrow,不可变借用计数加 1。 当任何一个 Ref<T> 值离开作用域并被丢弃时,不可变借用计数减 1
  • 每次调用 borrow_mut,可变借用计数加 1。 当任何一个 RefMut<T> 值离开作用域并被丢弃时,可变借用计数减 1

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

如果我们尝试违反这些规则,RefCell<T> 的实现会在运行时 panic,因为 RefCell<T> 在运行时检查借用规则。panic 信息 already borrowed: BorrowMutError 就是 RefCell<T> 在运行时处理借用规则违反的方式。

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

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

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

#[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 关联的 i32RefCell<> 包裹,以便可以修改该值。外面再用 Rc<> 包裹,以便多个所有者可以共享它,其余保持不变。
  • 记得把 RcRefCell 引入当前作用域。
  • 然后用 Rc::new()RefCell::new() 创建实例。a 通过 Rc::clone() 共享 value 的值,bc 通过 Rc::clone() 共享 a 的值(前提是 aRc<> 包裹)。
  • 最后,对 RefCell<T> 使用 borrow_mut 获得对 value 的可变借用。再用解引用运算符 * 像操作 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>:用于在多线程上下文中实现内部可变性