ch15-05-interior-mutability.md
commit 74edb8dfe07edf8fdae49c6385c72840c07dd18f
內(nèi)部可變性(Interior mutability)是 Rust 中的一個設(shè)計模式,它允許你即使在有不可變引用時也可以改變數(shù)據(jù),這通常是借用規(guī)則所不允許的。為了改變數(shù)據(jù),該模式在數(shù)據(jù)結(jié)構(gòu)中使用 unsafe
代碼來模糊 Rust 通常的可變性和借用規(guī)則。我們還未講到不安全代碼;第十九章會學(xué)習(xí)它們。當(dāng)可以確保代碼在運行時會遵守借用規(guī)則,即使編譯器不能保證的情況,可以選擇使用那些運用內(nèi)部可變性模式的類型。所涉及的 unsafe
代碼將被封裝進安全的
API 中,而外部類型仍然是不可變的。
讓我們通過遵循內(nèi)部可變性模式的 RefCell<T>
類型來開始探索。
不同于 Rc<T>
,RefCell<T>
代表其數(shù)據(jù)的唯一的所有權(quán)。那么是什么讓 RefCell<T>
不同于像 Box<T>
這樣的類型呢?回憶一下第四章所學(xué)的借用規(guī)則:
對于引用和 Box<T>
,借用規(guī)則的不可變性作用于編譯時。對于 RefCell<T>
,這些不可變性作用于 運行時。對于引用,如果違反這些規(guī)則,會得到一個編譯錯誤。而對于 RefCell<T>
,如果違反這些規(guī)則程序會 panic 并退出。
在編譯時檢查借用規(guī)則的優(yōu)勢是這些錯誤將在開發(fā)過程的早期被捕獲,同時對運行時沒有性能影響,因為所有的分析都提前完成了。為此,在編譯時檢查借用規(guī)則是大部分情況的最佳選擇,這也正是其為何是 Rust 的默認行為。
相反在運行時檢查借用規(guī)則的好處則是允許出現(xiàn)特定內(nèi)存安全的場景,而它們在編譯時檢查中是不允許的。靜態(tài)分析,正如 Rust 編譯器,是天生保守的。但代碼的一些屬性不可能通過分析代碼發(fā)現(xiàn):其中最著名的就是 停機問題(Halting Problem),這超出了本書的范疇,不過如果你感興趣的話這是一個值得研究的有趣主題。
因為一些分析是不可能的,如果 Rust 編譯器不能通過所有權(quán)規(guī)則編譯,它可能會拒絕一個正確的程序;從這種角度考慮它是保守的。如果 Rust 接受不正確的程序,那么用戶也就不會相信 Rust 所做的保證了。然而,如果 Rust 拒絕正確的程序,雖然會給程序員帶來不便,但不會帶來災(zāi)難。RefCell<T>
正是用于當(dāng)你確信代碼遵守借用規(guī)則,而編譯器不能理解和確定的時候。
類似于 Rc<T>
,RefCell<T>
只能用于單線程場景。如果嘗試在多線程上下文中使用RefCell<T>
,會得到一個編譯錯誤。第十六章會介紹如何在多線程程序中使用 RefCell<T>
的功能。
如下為選擇 Box<T>
,Rc<T>
或 RefCell<T>
的理由:
Rc<T>
? 允許相同數(shù)據(jù)有多個所有者;?Box<T>
? 和 ?RefCell<T>
? 有單一所有者。Box<T>
? 允許在編譯時執(zhí)行不可變或可變借用檢查;?Rc<T>
?僅允許在編譯時執(zhí)行不可變借用檢查;?RefCell<T>
? 允許在運行時執(zhí)行不可變或可變借用檢查。RefCell<T>
? 允許在運行時執(zhí)行可變借用檢查,所以我們可以在即便 ?RefCell<T>
? 自身是不可變的情況下修改其內(nèi)部的值。在不可變值內(nèi)部改變值就是 內(nèi)部可變性 模式。讓我們看看何時內(nèi)部可變性是有用的,并討論這是如何成為可能的。
借用規(guī)則的一個推論是當(dāng)有一個不可變值時,不能可變地借用它。例如,如下代碼不能編譯:
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
|
2 | let x = 5;
| - help: consider changing this to be mutable: `mut x`
3 | let y = &mut x;
| ^^^^^^ cannot borrow as mutable
For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` due to previous error
然而,特定情況下,令一個值在其方法內(nèi)部能夠修改自身,而在其他代碼中仍視為不可變,是很有用的。值方法外部的代碼就不能修改其值了。RefCell<T>
是一個獲得內(nèi)部可變性的方法。RefCell<T>
并沒有完全繞開借用規(guī)則,編譯器中的借用檢查器允許內(nèi)部可變性并相應(yīng)地在運行時檢查借用規(guī)則。如果違反了這些規(guī)則,會出現(xiàn) panic 而不是編譯錯誤。
讓我們通過一個實際的例子來探索何處可以使用 RefCell<T>
來修改不可變值并看看為何這么做是有意義的。
測試替身(test double)是一個通用編程概念,它代表一個在測試中替代某個類型的類型。mock 對象 是特定類型的測試替身,它們記錄測試過程中發(fā)生了什么以便可以斷言操作是正確的。
雖然 Rust 中的對象與其他語言中的對象并不是一回事,Rust 也沒有像其他語言那樣在標(biāo)準(zhǔn)庫中內(nèi)建 mock 對象功能,不過我們確實可以創(chuàng)建一個與 mock 對象有著相同功能的結(jié)構(gòu)體。
如下是一個我們想要測試的場景:我們在編寫一個記錄某個值與最大值的差距的庫,并根據(jù)當(dāng)前值與最大值的差距來發(fā)送消息。例如,這個庫可以用于記錄用戶所允許的 API 調(diào)用數(shù)量限額。
該庫只提供記錄與最大值的差距,以及何種情況發(fā)送什么消息的功能。使用此庫的程序則期望提供實際發(fā)送消息的機制:程序可以選擇記錄一條消息、發(fā)送 email、發(fā)送短信等等。庫本身無需知道這些細節(jié);只需實現(xiàn)其提供的 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: &T, max: usize) -> LimitTracker<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:一個記錄某個值與最大值差距的庫,并根據(jù)此值的特定級別發(fā)出警告
這些代碼中一個重要部分是擁有一個方法 send
的 Messenger
trait,其獲取一個 self
的不可變引用和文本信息。這個 trait 是 mock 對象所需要實現(xiàn)的接口庫,這樣 mock 就能像一個真正的對象那樣使用了。另一個重要的部分是我們需要測試 LimitTracker
的 set_value
方法的行為??梢愿淖儌鬟f的 value
參數(shù)的值,不過 set_value
并沒有返回任何可供斷言的值。也就是說,如果使用某個實現(xiàn)了 Messenger
trait
的值和特定的 max
創(chuàng)建 LimitTracker
,當(dāng)傳遞不同 value
值時,消息發(fā)送者應(yīng)被告知發(fā)送合適的消息。
我們所需的 mock 對象是,調(diào)用 send
并不實際發(fā)送 email 或消息,而是只記錄信息被通知要發(fā)送了。可以新建一個 mock 對象實例,用其創(chuàng)建 LimitTracker
,調(diào)用 LimitTracker
的 set_value
方法,然后檢查 mock 對象是否有我們期望的消息。示例 15-21 展示了一個如此嘗試的
mock 對象實現(xiàn),不過借用檢查器并不允許:
文件名: src/lib.rs
#[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:嘗試實現(xiàn) MockMessenger
,借用檢查器不允許這么做
測試代碼定義了一個 MockMessenger
結(jié)構(gòu)體,其 sent_messages
字段為一個 String
值的 Vec
用來記錄被告知發(fā)送的消息。我們還定義了一個關(guān)聯(lián)函數(shù) new
以便于新建從空消息列表開始的 MockMessenger
值。接著為 MockMessenger
實現(xiàn) Messenger
trait
這樣就可以為 LimitTracker
提供一個 MockMessenger
。在 send
方法的定義中,獲取傳入的消息作為參數(shù)并儲存在 MockMessenger
的 sent_messages
列表中。
在測試中,我們測試了當(dāng) LimitTracker
被告知將 value
設(shè)置為超過 max
值 75% 的某個值。首先新建一個 MockMessenger
,其從空消息列表開始。接著新建一個 LimitTracker
并傳遞新建 MockMessenger
的引用和 max
值
100。我們使用值 80 調(diào)用 LimitTracker
的 set_value
方法,這超過了 100 的 75%。接著斷言 MockMessenger
中記錄的消息列表應(yīng)該有一條消息。
然而,這個測試是有問題的:
$ 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
|
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
For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` due to previous error
warning: build failed, waiting for other jobs to finish...
error: build failed
不能修改 MockMessenger
來記錄消息,因為 send
方法獲取了 self
的不可變引用。我們也不能參考錯誤文本的建議使用 &mut self
替代,因為這樣 send
的簽名就不符合 Messenger
trait
定義中的簽名了(可以試著這么改,看看會出現(xiàn)什么錯誤信息)。
這正是內(nèi)部可變性的用武之地!我們將通過 RefCell
來儲存 sent_messages
,然后 send
將能夠修改 sent_messages
并儲存消息。示例 15-22 展示了代碼:
文件名: src/lib.rs
#[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--
assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
}
}
示例 15-22:使用 RefCell<T>
能夠在外部值被認為是不可變的情況下修改內(nèi)部值
現(xiàn)在 sent_messages
字段的類型是 RefCell<Vec<String>>
而不是 Vec<String>
。在 new
函數(shù)中新建了一個 RefCell<Vec<String>>
實例替代空 vector。
對于 send
方法的實現(xiàn),第一個參數(shù)仍為 self
的不可變借用,這是符合方法定義的。我們調(diào)用 self.sent_messages
中 RefCell
的 borrow_mut
方法來獲取 RefCell
中值的可變引用,這是一個
vector。接著可以對 vector 的可變引用調(diào)用 push
以便記錄測試過程中看到的消息。
最后必須做出的修改位于斷言中:為了看到其內(nèi)部 vector 中有多少個項,需要調(diào)用 RefCell
的 borrow
以獲取 vector 的不可變引用。
現(xiàn)在我們見識了如何使用 RefCell<T>
,讓我們研究一下它怎樣工作的!
當(dāng)創(chuàng)建不可變和可變引用時,我們分別使用 &
和 &mut
語法。對于 RefCell<T>
來說,則是 borrow
和 borrow_mut
方法,這屬于 RefCell<T>
安全 API 的一部分。borrow
方法返回 Ref<T>
類型的智能指針,borrow_mut
方法返回 RefMut<T>
類型的智能指針。這兩個類型都實現(xiàn)了 Deref
,所以可以當(dāng)作常規(guī)引用對待。
RefCell<T>
記錄當(dāng)前有多少個活動的 Ref<T>
和 RefMut<T>
智能指針。每次調(diào)用 borrow
,RefCell<T>
將活動的不可變借用計數(shù)加一。當(dāng) Ref<T>
值離開作用域時,不可變借用計數(shù)減一。就像編譯時借用規(guī)則一樣,RefCell<T>
在任何時候只允許有多個不可變借用或一個可變借用。
如果我們嘗試違反這些規(guī)則,相比引用時的編譯時錯誤,RefCell<T>
的實現(xiàn)會在運行時出現(xiàn) panic。示例 15-23 展示了對示例 15-22 中 send
實現(xiàn)的修改,這里我們故意嘗試在相同作用域創(chuàng)建兩個可變借用以便演示 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:在同一作用域中創(chuàng)建兩個可變引用并觀察 RefCell<T>
panic
這里為 borrow_mut
返回的 RefMut
智能指針創(chuàng)建了 one_borrow
變量。接著用相同的方式在變量 two_borrow
創(chuàng)建了另一個可變借用。這會在相同作用域中創(chuàng)建兩個可變引用,這是不允許的。當(dāng)運行庫的測試時,示例 15-23 編譯時不會有任何錯誤,不過測試會失?。?br>
$ cargo test
Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
Finished test [unoptimized + debuginfo] target(s) in 0.91s
Running unittests (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 'main' panicked at 'already borrowed: BorrowMutError', src/lib.rs:60:53
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'
注意代碼 panic 和信息 already borrowed: BorrowMutError
。這也就是 RefCell<T>
如何在運行時處理違反借用規(guī)則的情況。
在運行時捕獲借用錯誤而不是編譯時意味著將會在開發(fā)過程的后期才會發(fā)現(xiàn)錯誤,甚至有可能發(fā)布到生產(chǎn)環(huán)境才發(fā)現(xiàn);還會因為在運行時而不是編譯時記錄借用而導(dǎo)致少量的運行時性能懲罰。然而,使用 RefCell
使得在只允許不可變值的上下文中編寫修改自身以記錄消息的 mock 對象成為可能。雖然有取舍,但是我們可以選擇使用 RefCell<T>
來獲得比常規(guī)引用所能提供的更多的功能。
RefCell<T>
的一個常見用法是與 Rc<T>
結(jié)合。回憶一下 Rc<T>
允許對相同數(shù)據(jù)有多個所有者,不過只能提供數(shù)據(jù)的不可變訪問。如果有一個儲存了 RefCell<T>
的 Rc<T>
的話,就可以得到有多個所有者 并且 可以修改的值了!
例如,回憶示例 15-18 的 cons list 的例子中使用 Rc<T>
使得多個列表共享另一個列表的所有權(quán)。因為 Rc<T>
只存放不可變值,所以一旦創(chuàng)建了這些列表值后就不能修改。讓我們加入 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() {
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>>
創(chuàng)建可以修改的 List
這里創(chuàng)建了一個 Rc<RefCell<i32>>
實例并儲存在變量 value
中以便之后直接訪問。接著在 a
中用包含 value
的 Cons
成員創(chuàng)建了一個 List
。需要克隆 value
以便 a
和 value
都能擁有其內(nèi)部值 5
的所有權(quán),而不是將所有權(quán)從 value
移動到 a
或者讓 a
借用 value
。
我們將列表 a
封裝進了 Rc<T>
這樣當(dāng)創(chuàng)建列表 b
和 c
時,他們都可以引用 a
,正如示例 15-18 一樣。
一旦創(chuàng)建了列表 a
、b
和 c
,我們將 value
的值加 10。為此對 value
調(diào)用了 borrow_mut
,這里使用了第五章討論的自動解引用功能(“-> 運算符到哪去了?” 部分)來解引用 Rc<T>
以獲取其內(nèi)部的 RefCell<T>
值。borrow_mut
方法返回 RefMut<T>
智能指針,可以對其使用解引用運算符并修改其內(nèi)部值。
當(dāng)我們打印出 a
、b
和 c
時,可以看到他們都擁有修改后的值 15 而不是 5:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished dev [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>
中提供內(nèi)部可變性的方法來在需要時修改數(shù)據(jù)。RefCell<T>
的運行時借用規(guī)則檢查也確實保護我們免于出現(xiàn)數(shù)據(jù)競爭——有時為了數(shù)據(jù)結(jié)構(gòu)的靈活性而付出一些性能是值得的。
標(biāo)準(zhǔn)庫中也有其他提供內(nèi)部可變性的類型,比如 Cell<T>
,它類似 RefCell<T>
但有一點除外:它并非提供內(nèi)部值的引用,而是把值拷貝進和拷貝出 Cell<T>
。還有 Mutex<T>
,其提供線程間安全的內(nèi)部可變性,我們將在第 16 章中討論其用法。請查看標(biāo)準(zhǔn)庫來獲取更多細節(jié)關(guān)于這些不同類型之間的區(qū)別。
更多建議: