內部可變性

Cell 類型

聰明的讀者朋友們,大家應該還記得我們前面的「懸空指針」的例子。我們在C++中使用一個指針指向了動態數組內部,然後再改變動態數組使其觸發重新內存分配邏輯,來說明「mutation+alias」分析的必要性。然而,假如我們把動態數組類型換成簡單類型呢,假設現在有一個指針指向了一個i32類型的整數,我們同時修改這個整數類型,然後列印這個指針所指向的內存空間的值:

fn main() { let mut data = 100_i32; let p : &i32 = &data; data = 10; println!("{}", *p);}

編譯,出現意料之中的編譯錯誤:

error: cannot assign to `data` because it is borrowed

因為Rust的設計思路是,alias和mutation不能共存。這段代碼中,我們可以通過兩個變數綁定 data和p 訪問同一塊內存區域,這就存在 alias,所以我們不能同時提供mutation,使用data來改變變數是有可能出問題的。如果 data 是Vec類型,這個內存不安全的現象我們在前面的示例中已經可以看到。

但是!這一次,我們現在的這個程序實際上是「內存安全」的。它跟動態數組的情況不同,此例不涉及內存的分配和釋放,這裡的讀寫操作都在我們的控制之中,我們希望有一個指針指向我們關心的內容,而這個內容可能發生變化,Rust編譯器卻不讓我們通過。

面對這樣的情況,Rust提出了「內部可變性(internal mutability)」的概念,與「承襲可變性(inherited mutability)」相對應。

大家應該注意到了,Rust中的mut關鍵字不能在聲明類型的時候使用,只能跟變數一起使用。類型本身不能規定自己是否是可變的。一個變數是否是可變的,取決於是否用mut修飾那個變數綁定。可變還是不可變取決於變數的使用方式。這就叫做「承襲可變性」。如果我們用let var : T;聲明,那麼var是不可變的,而且,var內部所有的成員也都是不可變的;如果我們用let mut var : T;聲明,那麼var是可變的,相應的它的內部所有成員也都是可變的。我們不能在類型聲明的時候指定可變性,比如在struct中對某部分成員使用mut修飾,這是不合法的。我們只能在變數聲明的時候指定可變性。我們也不能針對變數的某一部分成員指定可變性,其它部分保持不變性。

而只有「承襲可變性」是不夠的,於是,又引入了「內部可變性」的概念。意思是,即使用共享引用&也能改變變數的值。標準庫中有許多類型都提供了內部可變性。比如Cell和RefCell這兩個類型就是最基本的具備內部可變性的類型。什麼是內部可變性呢,我們繼續用示例來講解。使用Cell類型來改寫上例:

use std::cell::Cell;fn main() { let data : Cell<i32> = Cell::new(100); let p = &data; data.set(10); println!("{}", p.get()); p.set(20); println!("{:?}", data);}

這次編譯通過,執行,結果是符合我們的預期的:

10Cell { value: 20 }

請注意這個例子與上一個版本的對比,除了把data的類型改為Cell<i32>,讀寫操作換成了get set函數之外,還有更重要的區別。需要注意的是這裡的「可變性」問題,跟我們前面見到的情況不一樣了。data這個變數綁定,沒有用mut修飾,p這個指針,也沒有用&mut修飾,然而不可變引用竟然可以調用set函數,改變了變數的值,而且還沒有出現編譯錯誤。

雖然粗略一看,Cell類型似乎違反了Rust的「唯一修改權」原則。我們可以存在多個指向Cell類型的不可變引用。同時我們還能利用不可變引用改變Cell內部的值。

但實際上,這個類型是完全符合「內存安全」的。我們再想想,為什麼Rust要儘力避免 alias 和 mutation 同時存在?因為假如說,我們同時有可變指針和不可變指針指向同一塊內存,有可能出現,通過一個可變指針修改內存的過程中,數據結構處於被破壞狀態的情況下,被其它的指針觀測到了。Cell類型是不會出現這樣的情況的。因為Cell類型包裹的數據必須具有Copy屬性,這意味著每次get set函數的調用都是執行的一次完整的數據拷貝操作。每次get set函數調用之後,Cell類型的內部都處於一個正確的狀態。我們不可能在set執行了一半的時候,再同時調用get。

由此可見,Cell類型的行為,和Rust中我們以前見到的其它類型的行為是不一致的。但這個不一致是可以容許的。因為我們會發現,Cell類型的行為是完全符合「內存安全」的,只是它不符合Rust基本的「alias+mutation」規則限制,它是通過API設計來保證的內存安全。它是對「alias+mutation」規則的有益補充,而非完全顛覆。

另外,Cell類型在執行階段也沒有額外開銷。因為對於Copy類型,每次的讀/寫本來就是類似 memcpy 的內存拷貝,而Cell在執行階段調用get/set方法執行的是同樣的操作,沒有任何額外性能損失。Cell類型的存在,僅僅是為了避開編譯器限制。

Cell 的用處

到目前為止,我們接觸到的示例中,一塊內存總是只有唯一的一個所有者。當這個變數綁定自身消亡的時候,這塊內存就會被釋放。引用計數智能指針,給我們提供了另外一種選擇:一塊不可變內存可以有多個所有者,當所有的所有者都消亡後,這塊內存才會被釋放。Rust中提供的引用計數指針有std::rc::Rc<T>類型和Arc<T>類型。Rc類型和Arc類型的主要區別是,Rc類型只能用在單線程中,Arc類型可以用在多線程中,這一點是通過編譯器靜態檢查保證的。Arc類型等到後面講多線程的時候再說,本文關注 Rc 類型。

首先,Rc類型和 & 型引用一樣,提供了「共享性」。它可以允許多個指針指向同一塊內存。因此它必然不能提供「可變性」。它沒有違反我們前面講的「內存安全」原則,它沒有設計直接修改內部數據的成員方法,每個所有者對內部數據只有隻讀功能,因此,它是安全的。其次,它是「線程局部」(Thread Local)的,我們不可能創造兩個分屬於不同線程的Rc指針指向同一個內存區域,具體原理後面分析。下面我們繼續用示例展示Rc智能指針的用法:

use std::rc::Rc;struct SharedValue { value : i32}fn main() { let shared_value : Rc<SharedValue> = Rc::new(SharedValue { value : 42 }); let owner1 = shared_value.clone(); let owner2 = shared_value.clone(); println!("value : {} {}", owner1.value, owner2.value); println!("address : {:p} {:p}", &owner1.value, &owner2.value);}

編譯運行,結果顯示,owner1 owner2裡面包含的數據value,不僅值是相同的,而且地址也是相同的。這正是Rc的意義所在。

從示例中可以看到,Rc指針的創建,是調用Rc::new靜態函數。如果要創建指向同樣內存區域的多個Rc指針,需要顯式調用clone函數。

Rc指針是沒有實現Copy trait的,如果使用直接賦值方式,那麼會執行move語義,導致前一個指針從此失效,後一個指針開始起作用,而且引用計數值不變。如果需要創造新的Rc指針,必須手工調用clone()函數,此時引用計數值才會加1。當某個Rc指針失效時,會導致引用計數值減一。當引用計數值減到0的時候,共享內存空間才會被釋放。

為什麼我們這裡要提到引用計數智能指針呢?因為這個 Rc 類型,它的實現原理,必須基於「內部可變性」。

大家可以想像一下,這個 Rc<T> 指針的原理是什麼。它指向的內容,不僅包括了在堆上分配的 T 類型的變數,而且還(至少)包括一個整數類型的引用計數值。標準庫中的實現方式,其實包括了兩個整數值,一個表示強引用的個數,一個表示弱引用的個數。每次調用 Rc::clone(&self) 方法的時候,我們不僅要把指針本身複製一份,還要把指向的計數值加一。但是,Rc指針並未提供可變性,我們怎麼才能使用一個沒有寫許可權的指針,修改它所指向的值呢?所以,這個計數值,本身必須具備「內部可變性」,此時,使用 Cell<usize> 類型就是非常合適的。

在標準庫中,Rc的實現如下所示:

pub struct Rc<T: ?Sized> { _ptr: Shared<RcBox<T>>,}

其中 Shared 類型就是包裝了一個裸指針,另外一個成員是 PhantomData 類型,主要影響編譯器的靜態分析,此處暫且不論:

pub struct Shared<T: ?Sized> { pointer: NonZero<*const T>, _marker: PhantomData<T>,}

其中 RcBox 是這麼定義的:

struct RcBox<T: ?Sized> { strong: Cell<usize>, weak: Cell<usize>, value: T,}

可以看到,標準庫中的引用計數值,就是使用的 Cell 類型。同理,在某些場景下,我們需要為了生命周期管理的方便,選擇具有「共享性」特點的指針,同時又需要通過這樣的指針修改所指向的內容。那麼,我們就需要用 &/Rc/Arc 指向一個具備「內部可變性」的類型。示例如下:

use std::rc::Rc;use std::cell::Cell;fn increase(arg: Rc<Cell<isize>>) { let temp = arg.get() + 1; arg.set(temp);}fn main() { let r = Rc::new(Cell::new(1_isize)); increase(r.clone()); println!("{}", r.get());}

Cell 類型在許多情況下都非常有用。但它有一個重要限制:它裡面只能包含具有 Copy 屬性的類型。它的對外方法 get/set 都是基於這個前提設計的。那麼對於不具備 Copy 屬性的類型,我們需要內部可變性怎麼辦?下面我們介紹RefCell 類型。

內部可變性之 RefCell

Cell類型的使用是有一定局限性的。因為它要求內部數據類型必須具有Copy屬性。如果我們希望給不具備Copy屬性的內部成員變數提供「內部可變性」,那就可以使用RefCell。

比如說,我們使用多個Rc指向了同一個Vec類型變數,Rc指針默認是沒有能力修改內部數據的,那我們如果需要在某些時候通過Rc指針修改內部數據,就需要使用 RefCell:

use std::cell::RefCell;use std::rc::Rc;fn main() { let shared_vec: Rc<RefCell<isize>> = Rc::new(RefCell::new(vec![1, 2, 3])); let shared1 = shared_vec.clone(); let shared2 = shared1.clone(); shared1.borrow_mut().push(4); shared2.borrow_mut().push(5); println!("{:?}", shared_vec);}

我們可以看到,如果我們直接使用Rc<Vec<_>>類型,我們是沒辦法修改Vec內部數據的。原因是,Rc提供了多個alias,指向同一個變數,它必然不能同時提供 mutation 能力。所以我們需要Cell或者RefCell來對這個Vec進行一個包裝。因為Vec是不具備Copy屬性的,那我們只能選擇RefCell。所以,一個線程局部同時具備共享的、可變的、動態數組類型就是Rc<RefCell<Vec<_>>>。

RefCell修改內部數據的api要比Cell麻煩。因為Cell內部是Copy類型,這種類型必定是POD類型,它非常簡單,內部一定不存在指針,一定不存在析構函數等,對於這樣的類型可以簡單的執行按位元組拷貝即可,這個過程非常安全。RefCell就不能這麼簡單處理了,它必須自行動態維護一個「借用檢查器」,來保證內部的數據不被濫用。如果我們需要一個可變借用指針,就必須調用borrow_mut()函數;如果我們只需共享借用指針,可以調用borrow()函數。

請大家注意,使用RefCell是有可能引發 panic 的情況的,如果出現了這種情況,Rust程序會在執行階段panic!。我們把以上程序稍做修改:

use std::cell::RefCell;use std::rc::Rc;fn main() { let shared_vec: Rc<RefCell<_>> = Rc::new(RefCell::new(vec![1, 2, 3])); let shared1 = shared_vec.clone(); let shared2 = shared1.clone(); let mut mut_borrow1 = shared1.borrow_mut(); mut_borrow1.push(4); let mut mut_borrow2 = shared2.borrow_mut(); mut_borrow2.push(5); println!("{:?}", shared_vec);}

編譯執行,結果為:thread "<main>" panicked at "RefCell<T> already borrowed"。這個示例與前一個的最大區別是,我們把調用borrow_mut()函數獲得的「可變借用指針」緩存起來了,它不再是一個臨時變數,即用即銷毀,它被提升成了局部變數,它的生命周期因此變長了。那麼在調用第二次的borrow_mut()的時候,程序會發現同時存在了兩個「可變借用指針」,違反了Rust的原則,這種情況下沒什麼好的恢復錯誤的辦法,只好觸發panic錯誤算了。

從原理上來說,Rust的默認的「借用規則檢查器」,它的邏輯非常像是一個在編譯階段執行的「讀寫鎖(read-write-locker)」。如果同時存在多個「讀」的鎖,是沒問題的;如果同時存在「讀」和「寫」的鎖,或者同時存在多個「寫」的鎖,就會發生錯誤。我們的RefCell類型並沒有打破這個規則,只不過,它把這個檢查邏輯從編譯階段,移到了執行階段。RefCell讓我們可以通過共享引用&修改內部數據,是逃過編譯器的靜態檢查。但是它依然在兢兢業業地儘可能保證「內存安全」,我們需要的借用指針,必須通過它提供的api borrow() borrow_mut()來獲得,它實際上是在執行階段在內部維護了一套「讀寫鎖」檢查機制。調用 borrow()或者 borrow_mut() 方法,相當於開啟了一個事務 transaction,多個 transaction 是不能混在一起的,每次只能執行一個 transaction,保證每次的讀操作或者寫操作都完全完成之後,才能執行下一個操作,用這種辦法來保證寫數據時候的執行過程中的內部狀態不會被觀測到,任何時候,開始讀或者開始寫操作開始的時候,共享的變數都處於一個合法狀態。因此在執行階段,RefCell是有少量的開銷的,它需要維護一個借用計數器,來保證內存安全。

所以說,我們一定不要過於濫用RefCell這樣的類型。如果確有必要使用,請一定規劃好動態借用出來的指針存活時間,否則會有問題。

Cell和RefCell用得最多的場景是和多個只讀引用相配合。比如說,多個&引用或者Rc引用指向同一個變數的時候。我們不能直接通過這些只讀引用修改變數,因為既然存在 alias,就不能提供 mutation。為了讓存在多個alias共享的變數,也可以被修改,那我們就需要使用內部可變性。Rust中提供了只讀引用的類型有這幾種指針&、Rc、Arc等,它們可以提供alias。Rust中提供了內部可變性的類型有Cell、RefCell、Mutex、RwLock以及Atomic*系列類型等。這兩類類型經常需要配合使用。

當我們執行讀寫的時候,就需要保證這個讀寫過程是一個完整的 transaction,不可把它們混起來。在Cell類型中,我們通過成員方法get/set來實現,在RefCell類型中,我們通過成員方法borrow/borrow_mut來實現。

本文同步發布在微信公眾號:Rust編程,歡迎關注。


推薦閱讀:

驀然回首萬事空
Clone VS Copy
地獄裡的Rust (一)struct 設計機巧初步

TAG:Rust编程语言 | 编程学习 | 编程语言 |