Rust 从 0 到 1- 智能指针 -RefCell<T>
内部可变性(Interior mutability)是 Rust 中的一种设计模式,它让我们甚至可以在使用不可变引用时也可以改变数据,而正常情况下,这违反了借用规则,是不被允许的。为了改变数据,它通过在数据结构中使用“不安全的代码”(unsafe code,后面章节会详细介绍)来绕过正常情况下 Rust 的可变性和借用规则约束。如果我们可以确保即使在编译器无法保证的情况下,代码在运行时也会遵守借用规则,那么就可以使用那些具有内部可变性模式的类型。类型中内部可变性相关的“不安全的代码”将被封装为“安全的 API” ,而从外部来看其仍然是不可变的。下面让我们通过类型 RefCell<T> 来进一步理解内部可变性相关的概念。
在运行时强制执行借用规则
与 Rc<T> 不同,RefCell<T> 类型的数据的只能拥有单一的所有权。那么它与 Box<T> 这样的类型有什么不同呢?首先让我们回过来看下前面介绍的借用规则:
在任意时刻,只能拥有一个可变引用或任意数量的不可变引用。
引用必须一直是有效的。
对于 Box<T> 类型的引用,借用规则中的不可变性约束在编译时就会强制检查,如果违反这些规则,就会发生编译错误;而对于 RefCell<T> 类型,则是在运行时,如果违反这些规则程序就会发生 panic 并退出。
在编译时检查借用规则的优点是可以在开发阶段尽早的发现和捕获错误,同时因为所有的分析工作都在之前完成了,对运行时的性能也不会造成影响。因此,在编译时检查借用规则在大部分情况下是最好的选择,也因为如此,这也是 Rust 的默认行为。
在运行时检查借用规则的优点是可以实现某些特殊的场景,即虽然在编译时检查是不满足借用规则的,但实际上是内存安全的。Rust 编译器,属于静态分析工具,天生偏向保守。但仅仅通过分析代码无法发现代码所有的属性:其中最著名的例子就是“停机问题”(Halting Problem),我们不在这里对其讨论,如果感兴趣的话大家可以自行搜索研究。
正是因为有些分析是无法做到的,如果 Rust 编译器不能确定是否符合所有权规则,它会拒绝编译通过,即使这可能是一个正确的程序;从这方面来看它是保守的。而采取保守的策略是因为,如果 Rust 放过了不正确的程序,那么 Rust 所做的保证就会被打破,用户也就无法对其信任。而,如果正确的程序被拒绝了,虽然会带来一定的不便,但不会造成任何危害。RefCell<T> 正是用于这种场景:当我们确信,而编译器无法理解和确保代码遵守借用规则。
于 Rc<T> 一样,RefCell<T> 也只能用于单线程。如果在多线程中使用 RefCell<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> 是不可变的,我们也可以修改其内部的值。
改变不可变值内部的值就是内部可变性。下面让我们看看这是如何做到的,以及其适用的场景。
内部可变性
借用规则其中一个规则就是当有一个不可变值时,不能可变地借用它。参考下面的代码:
如果我们尝试编译,会产生类似下面的错误:
然而,对于有些场景来说,可以通过数据本身的“方法”(methods )改变自身的值同时对于外部的代码来说仍然是不可变的,是非常有用的。此时其仍然是不可变的,外部的代码不能直接修改它。RefCell<T> 就是这样的一种类型,但它并没有完全避开借用规则:编译器的借用检查器允许内部可变性,但是相应地会在运行时检查借用规则。如果违反了这些规则,程序会发生 panic。下面让我们通过一个实际的例子来看看如何适用 RefCell<T> ,以及为什么这么做。
模拟对象(Mock Objects)
测试替身(test double,这个翻译可能有些不太理解,大家可以看下 stunt double)是编程中的一个通用概念,意思是在测试中替换待测程序的某一部分从而完成测试。而模拟对象(Mock Objects)就是这个替身,它可以记录测试过程中发生了什么,因此我们可以用来断言被测对象的行为是否正确。
Rust 中的对象的概念与其他语言并不相同,Rust 也没有在标准库中内建对模拟对象功能的支持,但是我们可以通过使用结构体达到与模拟对象相同的目的。
假设我们有如下想要测试的场景:我们要编写一个跟踪某个值与最大值差距的库,它会在当前值接近最大值时发送消息。譬如,这个库可以用于跟踪用户调用 API 数量的限额。该库只跟踪与最大值的差距,并在达到指定的容量时发送指定的消息。使用此库的应用则需要提供实际的发送消息的实现:应用可以通过 email、短信或任何其它方式发送消息。该库本身无需知道具体实现细节,应用只需要实现我们提供的 Messenger trait 就可以。参考下面的例子:
上面例子中,需要在这里重点关注的是拥有 send 方法的 Messenger trait ,其参数是指向自身的 self 不可变引用和需要发送的信息。这就是模拟对象所需要实现的接口,这样我们就可以像使用真实的应用一样使用模拟对象。另外一个需要我们关注的是,我们需要测试 LimitTracker 的 set_value 方法。我们可以改变 value 参数的值,但是 set_value 并没有返回任何可用于断言的信息。我们希望当使用指定的 Messenger trait 实现、max 值 和不同的 value 创建 LimitTracker 实例后,根据不同的 value 值,消息发送者会按照我们期望的结果收到需要发送的消息。
我们需要一个模拟对象记录需要被发送的信息,用于替代真实发送 emai 或短信的实现。参考下面的例子:
在上面的例子中,我们定义了一个 MockMessenger 结构体,其中 sent_messages 用来记录需要发送的消息。我们为 MockMessenger 实现了 Messenger trait ,在 send 方法中我们将需要发送的消息储存在 sent_messages 字段中。这样其就可以做为 LimitTracker 的参数用于替代真实的发送消息的功能。接着我们测了当 value 值超过 max 值 75%时发送消息的场景。但是如果我们尝试运行测试,是无法编译通过的,因为其违反了借用规则,我们会得到类似下面的结果:
我们无法修改 MockMessenger 来记录消息,因为 send 方法获取的是 self 的不可变引用。我们也不能参考错误提示的建议使用 &mut self ,因为这不符合 Messenger trait 中 send 方法的定义(大家可以试着按照错误提示修改一下,看看会报什么错误)这时候内部可变性就可以派上用场了!我们通过 RefCell 来储存消息,这样我们就可以在 send 中修改 sent_messages 了。参考下面的例子:
在上面的例子中,我们将 sent_messages 字段的类型定义为 RefCell<Vec<String>> 用来替换 Vec<String>,并在 new 函数中创建了一个包含空 vector 的 RefCell 实例。对于 send 方法的实现来说,第一个参数仍然是 self 的不可变借用,这与其在 Messenger trait 中的定义是一致的。我们可以通过调用 RefCell<Vec<String>> 的 borrow_mut 方法来获取其包含的 vector 的可变引用,接着就可以调用 push 方法存储测试过程中发送的消息。最后在断言中我们通过调用 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 trait,可以看作常规引用。
RefCell<T> 会记录当前正在使用的 Ref<T> 和 RefMut<T> 数量。以 Ref<T> 为例,每次调用 borrow,不可变借用计数加一;当 Ref<T> 类型的值离开作用域时,不可变借用计数减一。和编译时的借用规则一样,RefCell<T> 在任何时刻只允许存在多个不可变借用或一个可变借用。
如果我们违反了 RefCell<T> 的借用规则,Rust 不会在编译时报错,而是在运行时 panic!。参考下面的例子:
上面的例子中我们通过两次调用 borrow_mut 创建了 one_borrow 和 two_borrow 两个可变引用,这违反了借用规则,但是在编译时不会产生任何错误,不过运行测试时会产生类似下面的错误:
注意上面产生的 panic 信息 already borrowed: BorrowMutError。这就是 RefCell<T> 在运行时违反了借用规则的报错。
在运行时而不是编译时捕获借用错误会导致我们可能在开发过程的后期才会发现错误,甚至有可能在部署到生产环境以后才发现;此外还会带来少量的运行时性能损耗。但是,RefCell<T> 使我们可以在只允许不可变值的情况下可以编写修改自身的模拟对象成为可能。是否选择使用 RefCell<T> 来获得相对常规引用来说更多的功能,这是需要我们根据实际场景进行权衡的。
结合 Rc<T> 和 RefCell<T> 实现多个可变数据所有者
RefCell<T> 的一个常见用法是与 Rc<T> 结合使用。Rc<T> 使数据可以有多个所有者,但是只能读取数据。如果把 RefCell<T> 类型的数据存储到 Rc<T> 中的话,那么数据就可以有多个所有者并且还可以对其修改!下面让我修改介绍 Rc<T> 时的例子,加入 RefCell<T> 来使列表中的值可以被修改:
在上面的例子中我们创建了一个 Rc<RefCell<i32>> 实例并赋值给变量 value ,以便之后使用。后续列表的创建过程与之前的例子类似,但是注意,我们把 i32 类型的数据替换为 RefCell<i32> (如,3 替换为 RefCell::new(3) )。在创建了列表 a、b 和 以后,我们通过调用 borrow_mut 方法将 value 的值加 10。这里使用了前面讨论过的自动解引用功能来解引用 Rc<T> ,从而获得其内部 RefCell<T> 类型的值,而 borrow_mut 方法则会返回 RefMut<T> 类型的智能指针,可以对其使用解引用运算符并修改其存储的值。
尝试运行上面的例子,我们会得到类似下面的结果:
这是非常巧妙的!通过使用 RefCell<T>,我们可以获得一个表面上不可变的 List,不过利用 RefCell<T> 提供的内部可变性,我们可以在需要时修改数据。RefCell<T> 的运行时借用规则检查将保护我们避免出现数据竞争,并且在有些场景下牺牲一些性能而获得更多的灵活性是值得的。
标准库中还提供了其它具有内部可变性的类型,如 Cell<T>,它和 RefCell<T>类似,不过额外提供了 k 拷贝的功能;还有 Mutex<T>,它提供了线程安全的内部可变性,我们将在后面讨论并发的时候介绍它。大家可以通过查看标准库来文档来了解更多细节以及它们之间的区别。
版权声明: 本文为 InfoQ 作者【山】的原创文章。
原文链接:【http://xie.infoq.cn/article/bb19414f8ff89ff6ce038369f】。文章转载请联系作者。
评论