Rust 從 0 到 1 - 智能指針 - RefCell-T-

 在保持對外不可變的情況下,通過自身的方法改變數據。

內部可變性(Interior mutability)是 Rust 中的一種設計模式,它讓我們甚至可以在使用不可變引用時也可以改變數據,而正常情況下,這違反了借用規則,是不被允許的。爲了改變數據,它通過在數據結構中使用 “不安全的代碼”(unsafe code,後面章節會詳細介紹)來繞過正常情況下 Rust 的可變性和借用規則約束。如果我們可以確保即使在編譯器無法保證的情況下,代碼在運行時也會遵守借用規則,那麼就可以使用那些具有內部可變性模式的類型。類型中內部可變性相關的“不安全的代碼” 將被封裝爲“安全的 API” ,而從外部來看其仍然是不可變的。下面讓我們通過類型 RefCell 來進一步理解內部可變性相關的概念。

01

在運行時強制執行借用規則

與 Rc 不同,RefCell 類型的數據的只能擁有單一的所有權。那麼它與 Box 這樣的類型有什麼不同呢?首先讓我們回過來看下前面介紹的借用規則:

對於 Box 類型的引用,借用規則中的不可變性約束在編譯時就會強制檢查,如果違反這些規則,就會發生編譯錯誤;而對於 RefCell 類型,則是在運行時,如果違反這些規則程序就會發生 panic 並退出。

在編譯時檢查借用規則的優點是可以在開發階段儘早的發現和捕獲錯誤,同時因爲所有的分析工作都在之前完成了,對運行時的性能也不會造成影響。因此,在編譯時檢查借用規則在大部分情況下是最好的選擇,也因爲如此,這也是 Rust 的默認行爲。

在運行時檢查借用規則的優點是可以實現某些特殊的場景,即雖然在編譯時檢查是不滿足借用規則的,但實際上是內存安全的。Rust 編譯器,屬於靜態分析工具,天生偏向保守。但僅僅通過分析代碼無法發現代碼所有的屬性:其中最著名的例子就是 “停機問題”(Halting Problem),我們不在這裏對其討論,如果感興趣的話大家可以自行搜索研究。

正是因爲有些分析是無法做到的,如果 Rust 編譯器不能確定是否符合所有權規則,它會拒絕編譯通過,即使這可能是一個正確的程序;從這方面來看它是保守的。而採取保守的策略是因爲,如果 Rust 放過了不正確的程序,那麼 Rust 所做的保證就會被打破,用戶也就無法對其信任。而,如果正確的程序被拒絕了,雖然會帶來一定的不便,但不會造成任何危害。RefCell 正是用於這種場景:當我們確信,而編譯器無法理解和確保代碼遵守借用規則。

於 Rc 一樣,RefCell 也只能用於單線程。如果在多線程中使用 RefCell,會產生編譯錯誤。後的的章節介紹如何在多線程中使用 RefCell 。以下爲選擇使用 Box,Rc 或 RefCell 場景的概述:

改變不可變值內部的值就是內部可變性。下面讓我們看看這是如何做到的,以及其適用的場景。

02

內部可變性

借用規則其中一個規則就是當有一個不可變值時,不能可變地借用它。參考下面的代碼:

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
error: aborting due to previous error
For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing`
To learn more, run the command again with --verbose.

然而,對於有些場景來說,可以通過數據本身的 “方法”(methods )改變自身的值同時對於外部的代碼來說仍然是不可變的,是非常有用的。此時其仍然是不可變的,外部的代碼不能直接修改它。RefCell 就是這樣的一種類型,但它並沒有完全避開借用規則:編譯器的借用檢查器允許內部可變性,但是相應地會在運行時檢查借用規則。如果違反了這些規則,程序會發生 panic。下面讓我們通過一個實際的例子來看看如何適用 RefCell ,以及爲什麼這麼做。

測試替身(test double,這個翻譯可能有些不太理解,大家可以看下 stunt double)是編程中的一個通用概念,意思是在測試中替換待測程序的某一部分從而完成測試。而模擬對象(Mock Objects)就是這個替身,它可以記錄測試過程中發生了什麼,因此我們可以用來斷言被測對象的行爲是否正確。

Rust 中的對象的概念與其他語言並不相同,Rust 也沒有在標準庫中內建對模擬對象功能的支持,但是我們可以通過使用結構體達到與模擬對象相同的目的。

假設我們有如下想要測試的場景:我們要編寫一個跟蹤某個值與最大值差距的庫,它會在當前值接近最大值時發送消息。譬如,這個庫可以用於跟蹤用戶調用 API 數量的限額。該庫只跟蹤與最大值的差距,並在達到指定的容量時發送指定的消息。使用此庫的應用則需要提供實際的發送消息的實現:應用可以通過 email、短信或任何其它方式發送消息。該庫本身無需知道具體實現細節,應用只需要實現我們提供的 Messenger trait 就可以。參考下面的例子:

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

上面例子中,需要在這裏重點關注的是擁有 send 方法的 Messenger trait ,其參數是指向自身的 self 不可變引用和需要發送的信息。這就是模擬對象所需要實現的接口,這樣我們就可以像使用真實的應用一樣使用模擬對象。另外一個需要我們關注的是,我們需要測試 LimitTracker 的 set_value 方法。我們可以改變 value 參數的值,但是 set_value 並沒有返回任何可用於斷言的信息。我們希望當使用指定的 Messenger trait 實現、max 值 和不同的 value 創建 LimitTracker 實例後,根據不同的 value 值,消息發送者會按照我們期望的結果收到需要發送的消息。

我們需要一個模擬對象記錄需要被髮送的信息,用於替代真實發送 emai 或短信的實現。參考下面的例子:

#[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 用來記錄需要發送的消息。我們爲 MockMessenger 實現了 Messenger trait ,在 send 方法中我們將需要發送的消息儲存在 sent_messages 字段中。這樣其就可以做爲 LimitTracker 的參數用於替代真實的發送消息的功能。接着我們測了當 value 值超過 max 值 75% 時發送消息的場景。但是如果我們嘗試運行測試,是無法編譯通過的,因爲其違反了借用規則,我們會得到類似下面的結果:

$ 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
   |
57 |         fn send(&self, message: &str) {
   |                 ----- help: consider changing this 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
error: aborting due to previous error
For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker`
To learn more, run the command again with --verbose.
warning: build failed, waiting for other jobs to finish...
error: build failed

我們無法修改 MockMessenger 來記錄消息,因爲 send 方法獲取的是 self 的不可變引用。我們也不能參考錯誤提示的建議使用 &mut self ,因爲這不符合 Messenger trait 中 send 方法的定義(大家可以試着按照錯誤提示修改一下,看看會報什麼錯誤)。這時候內部可變性就可以派上用場了!我們通過 RefCell 來儲存消息,這樣我們就可以在 send 中修改 sent_messages 了。參考下面的例子:

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

在上面的例子中,我們將 sent_messages 字段的類型定義爲  RefCell<Vec> 用來替換 Vec,並在 new 函數中創建了一個包含空 vector 的 RefCell 實例。對於 send 方法的實現來說,第一個參數仍然是 self 的不可變借用,這與其在 Messenger trait 中的定義是一致的。我們可以通過調用 RefCell<Vec> 的 borrow_mut 方法來獲取其包含的 vector 的可變引用,接着就可以調用 push 方法存儲測試過程中發送的消息。最後在斷言中我們通過調用 RefCell<Vec> 的 borrow 方法獲取 vector 的不可變引用來獲得存儲的消息數量。

下面讓我們研究一下 RefCell 是怎樣工作的!

當創建不可變和可變引用時,我們分別使用 & 和 &mut 語法,在 RefCell 中與之對應的是 borrow 和 borrow_mut 方法,它們都屬於 RefCell 的安全 API。borrow 方法返回 Ref 類型的智能指針,borrow_mut 方法返回 RefMut 類型的智能指針,這兩種類型都實現了 Deref trait,可以看作常規引用。

RefCell 會記錄當前正在使用的 Ref 和 RefMut 數量。以 Ref 爲例,每次調用 borrow,不可變借用計數加一;當 Ref 類型的值離開作用域時,不可變借用計數減一。和編譯時的借用規則一樣,RefCell 在任何時刻只允許存在多個不可變借用或一個可變借用。

如果我們違反了 RefCell 的借用規則,Rust 不會在編譯時報錯,而是在運行時 panic!。參考下面的例子:

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

上面的例子中我們通過兩次調用 borrow_mut 創建了 one_borrow 和 two_borrow 兩個可變引用,這違反了借用規則,但是在編譯時不會產生任何錯誤,不過運行測試時會產生類似下面的錯誤:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
    Finished test [unoptimized + debuginfo] target(s) in 0.91s
     Running target/debug/deps/limit_tracker-d1b2637139dca6ca
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  在運行時違反了借用規則的報錯。

在運行時而不是編譯時捕獲借用錯誤會導致我們可能在開發過程的後期纔會發現錯誤,甚至有可能在部署到生產環境以後才發現;此外還會帶來少量的運行時性能損耗。但是,RefCell 使我們可以在只允許不可變值的情況下可以編寫修改自身的模擬對象成爲可能。是否選擇使用 RefCell 來獲得相對常規引用來說更多的功能,這是需要我們根據實際場景進行權衡的。

03

結合 Rc 和 RefCell 實現多個可變數據所有者

RefCell 的一個常見用法是與 Rc 結合使用。Rc 使數據可以有多個所有者,但是隻能讀取數據。如果把 RefCell 類型的數據存儲到 Rc 中的話,那麼數據就可以有多個所有者並且還可以對其修改!下面讓我修改介紹 Rc 時的例子,加入 RefCell 來使列表中的值可以被修改:

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

在上面的例子中我們創建了一個 Rc<RefCell> 實例並賦值給變量 value ,以便之後使用。後續列表的創建過程與之前的例子類似,但是注意,我們把 i32 類型的數據替換爲 RefCell (如,3 替換爲 RefCell::new(3) )。在創建了列表 a、b 和 以後,我們通過調用 borrow_mut 方法將 value 的值加 10。這裏使用了前面討論過的自動解引用功能來解引用 Rc ,從而獲得其內部 RefCell 類型的值,而 borrow_mut 方法則會返回 RefMut 類型的智能指針,可以對其使用解引用運算符並修改其存儲的值。

嘗試運行上面的例子,我們會得到類似下面的結果:

$ 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,我們可以獲得一個表面上不可變的 List,不過利用 RefCell 提供的內部可變性,我們可以在需要時修改數據。RefCell 的運行時借用規則檢查將保護我們避免出現數據競爭,並且在有些場景下犧牲一些性能而獲得更多的靈活性是值得的。

標準庫中還提供了其它具有內部可變性的類型,如 Cell,它和 RefCell 類似,不過額外提供了拷貝的功能;還有 Mutex,它提供了線程安全的內部可變性,我們將在後面討論併發的時候介紹它。大家可以通過查看標準庫來文檔來了解更多細節以及它們之間的區別。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/NIo1TH1meMfQNqO4WtHrsg