地獄裡的Rust (一)struct 設計機巧初步
(blog address: Chaos Dong)
Introduction
本篇文章旨在記錄並討論一下自己在最近 Rust 試水學習中所踩的坑以及 挖掘出的一些用來規避 memory system writing hell 的一些常見套路。
我相信不止我一個人有這樣的錯覺,即儘管Rust 的文檔不可謂不齊全,然而真正開始在生產環境使用的時候就會發現Rust 只有兩處不太好用: 這也不好用,那也不好用。
然而你還不能抱怨什麼,在你嘗試搜索 rust document 以及 stackoverflow 後,你恰好總能找到你問題的解決方案,儘管有些確實不那麼漂亮。彷彿 Rust 語言的開發者始終將自己處在萬人所指的被告席上,背後有強大的律師團為其背書:「xx你看,你覺得xx在Rust 不好實現並不是因為 Rust 本身的問題,而是你沒有使用 / 嘗試 Rust 的 xx 功能」。 儘管 Rust 完善的文檔能夠使她的開發者巧妙地逃脫每一次初學者的苛責,但是, 總有一些文檔之外的問題是Rust初學者都會碰到,而這些問題儘管都可以在文檔中找到答案,但是這些問題產生的根源以及為了解決問題所必須進行的思維方法的轉換,Rust的開發者對此好像諱莫如深。也就是說,Rust 的文檔僅僅能夠起到頭痛醫頭腳痛醫腳的作用。 比如, Rust 提供了多種 mutable 的表達方法,包括 mut, &mut, *mut, Rc, RefCell, Arc 等多個關鍵字供你使用。並且,Rust團隊也貼心地準備了 "choose your guarantee" 這篇文章來詳細描述到底這些關鍵字該怎麼用。 然而諸如 &mut self 傳染, multivarible RefCell borrow, 以及 RefCell 的 borrow() 的 lifetime 衝突 等常見的初學者都會面臨的困惑,你幾乎很難找到任何一篇官方文檔去指導你避免在這些坑中浪費時間。下面我就從 struct 結構設計, &mut self 傳染, Option / Result 的 match hell 等 topic 具體闡述一下這些坑是怎麼來的,以及一些避過這些坑的探索方法。
struct 設計
如果你是擁有 python / Java / Haskell 經驗的程序員,你一定不會為設計一個複雜 struct 擁有過多煩惱。比如 你有 struct A {name: String, id: usize} , 然後你想設計一個結構體 B , 這個 B 不僅包含 id,name 等簡單類型,也包含A, 那麼,寫成 struct B {a: A, name: String, id: usize} 是十分符合直覺的。 然而,如果你在 Rust 中這麼設計,你就會在接下來的使用A B 的過程中遭了殃。 為什麼 生成一個 A實例a,再將其賦予B的一個實例 b後, a 就不能用了? 查查文檔發現是 move 問題。於是你借這個機會開始對 Rust 的內存模型有了一個粗淺的認識。 那麼對B的定義中,不將 A 的實體賦給B, 而是將引用賦給B可以嗎? 你很容易就寫出一個 Struct B {a: &"a A, ...} 的設計來,其中 "a 表示生命周期。看起來問題似乎解決了。可是當你接到一個需求是 impl struct A 的某些 update 方法,其中涉及到對A 的改變操作時,你也許就會發現一些拆東牆補西牆的問題。將A的地址賦予B,意味著你接下來很難有機會直接操作A, 因為對一塊資源的操作的合法操作有兩類,要麼是多個不可變的引用,要麼是單個可變引用。也就是,如果你在項目中一旦有了對某個已有結構體的一個小小 mutable 功能擴展,你可能要付出將該結構體定義甚至所有依賴該結構體的子結構體定義全部重寫的巨大勞動。而這些勞動,說的好聽是因為 Rust 將 reference 和 mut 不同的選項全部塞到了類型,參數和介面定義中,說的不好聽()是開發者在定義 struct 的時候壓根就沒想好,到底哪些欄位是允許更改的,允許怎樣的更改。哪些欄位是允許 Clone / Copy 的,而這些細節是從 Python / Java 過來的程序員很難考慮到的。
下面是一些設計依賴關係的struct 時,能夠避免返工重新定義的一些可能有用的經驗。
先形式化問題: 當我們嘗試定義 struct B {a: foobar<A>...} 時 這裡的 foobar 到底是怎樣才合適。 可選的答案有 A / &"A / Rc<A> / Rc<RefCell<A>> / Option<Rc<RefCell<<A>>>> / *mut A / Option<*mut A> ... 等等。
1、一個原則是盡量避免 consume 風格的欄位定義和 struct 方法定義。
你可能在Rust的很多官方類型中看到 consume 風格的方法。比如 Option 的 unwrap, take, RefCell 的 into_inner 。 這些方法都有著一個統一的特徵,就是他們都是一次性把外面的殼(Option, RefCell 之類的)扔掉,把裡面的值取出來。你再想放回殼裡就放不回原殼裡了(殼子的地址沒了),即便放回去那也是個新殼。 然而在 struct 中我們往往有欄位數據在內存中持久化的需要,我們不希望一個 實例其中一個 field, 我們用了一次它就廢掉了。 類似的思想也包括不支持Copy/Clone 的結構體傳遞。我們在定義和使用 struct 時,往往要小心翼翼(嚴格使用 match, if let 等套路),輕拿輕放(通過 Some(ref xx) 的方法以引用風格提取裡面的值,確保裡面的值不被move , 以及外面的wrapper 持久存在)。 因此盡量避免 a: A 這樣直截了當的定義,除非 A 與 B 具有某種強相關, 對A的操作會且僅會通過B.A 的方式來實現。否則還是傳遞某種引用吧。至於倒底使用哪類引用,會在下面繼續討論。
2、 一個大結構體里的 非 Clone / Copy primitive欄位盡量使用 RefCell 等支持 interior mutable 的殼子包裹起來。
這裡也是一個比較慘痛的教訓。什麼叫大結構體呢? 舉個 golang 的例子:
`
// DB is a LevelDB database.type DB struct {
// Need 64-bit alignment.seq uint64// Session.s *session// MemDB.memMu sync.RWMutexmemPool chan *memdb.DBmem, frozenMem *memDBjournal *journal.WriterjournalWriter storage.WriterjournalFd storage.FileDescfrozenJournalFd storage.FileDescfrozenSeq uint64// Snapshot.snapsMu sync.MutexsnapsList *list.List....
`
裡面的欄位還沒定義完..
一般來說,一個結構體里包含超過2個子struct 時, 這樣的結構體我們就可以稱之為大結構體了。
由於 Rust 的 mutable 特性, 一個結構體在使用的時候,要麼全體 fields 全部 immutable, 要麼全部 mutable, 也就是不支持 partial mutable field (這裡個人認為是某種缺陷)。因此,考慮到生產環境功能迭代,為了不出現因為一個小小欄位添加了mutable update operation 就將過程中的 所有 struct 實例,以及包裹這類 struct 的實例全部變成 mut 的慘狀, 請善加運用 interior mutable 特性。哪怕是一些 id: usize 這樣的小欄位,保險起見也用 id: RefCell<usize> 這樣支持可變的殼包起來,省得之後類型定義大返工。(這裡實際上涉及到了兩個之後想說明的問題,一個是 方法定義時的 &mut self 污染, 另一個是 RefCell, Cell, Rc 這幾類 pointer 的選取問題。)
傾向使用 RefCell 實際上也有謹慎使用 exterior mutable 的意思在裡面。
3、RefCell, Rc, Cell 等 pointer 的選擇問題
在這裡首先還是要推薦官方的文檔:choosing your guarantee 算是比較詳細地描述了各類 wrapper type 的特點和選擇。簡單來說,這些 wrapper type 根據是否支持 mutable, 其 wrapper 類型是否一定要Copy/Clone 做了限定區分,這裡暫時先不考慮多線程的問題。
個人認為其中最常用的還是 RefCell<T>, 因為其支持 非 Copy/Clone 的 mutable 的特性。充其量再加上 Rc<RefCell<T>> 的添加引用計數的特性,尤其是在 struct 定義中。為什麼呢?因為 你並不能保證在 struct 中的任意一個欄位永遠都是不可變的,或者 支持 Copy / Clone 與否,除非是那些實現底層數據結構的,演算法幾十年不會變的 struct 。如果從這個角度出發, 那麼Cell直接被淘汰。Rc往往不直接使用,而是與 RefCell 一起配合使用。*mut 這個 raw pointer 在實現一些底層演算法時也經常用,不過 unsafe {*mut wi? fe} 一時爽, invalid memory reference 火葬場,個人慘痛教訓,引以為戒。Box<T> 這個類型往往不參與結構體的定義(linkedlist 實現 啪啪打臉,但我還是堅持自己的觀點, 除非你的數據結構只 new 不 update),而是在程序的 logic flow 中充當 trait object wrapper 使用。
&mut self 污染
還是沿用之前的 Struct A, B 例子。考慮如下情況:
pub struct A<T> { id: String, value: T, };pub struct B<T> { a: A, id: String, value: T}impl<T> A<T> { pub fn foo(&self) -> T { ... }}impl<T> B<T> { pub fn foo(&self) -> T { let temp_v = self.a.foo(); ... }}
在這一節,我們從 &mut self 污染擴散問題,側面討論一下 struct 設計的另一個小原則: 結構體的方法定義盡量不包含 exterior mutable.
看上面的例子。我們知道,在為結構體設計方法的時候,如果該方法不涉及到 mut 操作, 那麼 getter類方法 使用 &self 而不是 &mut self 表示本身是符合常規的。例子中的結構體 A 和 B 的 foo 方法都是使用的 &self。這時,如果我們接到了某個PM的新需求,在實現的過程中我們需要在 a.foo() 中對 a 做一些 mutable 改變。最簡單的方法是 直接將 struct A 裡面的 foo 方法裡面的參數 &self 改為 &mut self。
以為一切都結束了嗎?編譯程序會發現此時 B.foo() 方法會報 immutable borrow 衝突問題。 為什麼一個對 A.foo() 的小小改變會影響到 B? 看代碼我們會發現 B.foo 中有這麼一句: let temp_v = self.a.foo() 這裡由於 self 是 immutable 的,因此 self.a 也是immutable 的。在原來的程序中不會出問題。可是現在我們將 A.foo() 里的 self 定義成 mut , 因此為了編譯通過, mut 需要傳遞給她的上級, 也就是說, 為了使 B.a 是 mut 的, B 本身也得是 mut 的。於是我們只得把 B.foo() 的參數定義也改成 &mut self 。
看到了嗎?一個 struct 的方法參數的改變,竟然影響到了該 struct 的上級而迫使其發生改變。這是一個十分糟糕的情況。理論來說,不同結構體的方法定義不應該有強關聯,而應該是解藕的。 而在該例子中, 由於 Rust 對 mut 特性的定義,出現了反常的實現情況。最糟糕的情況,在一個大型項目中,比方說有 db.skiplist.skipnode.wife 這樣的屬性。上述的實現就有可能出現 由於 wife 欄位的 mutable 改變迫使上面三層 struct 的介面全部重新定義的慘烈情況。
上述問題我個人稱作是 &mut self 污染傳遞。解決它的方案也很簡單,就是在結構體的getter類方法定義時,儘力使用 interior mutable, 將所有可能 mut 的欄位用 wrapper 包起來,在外層借口參數定義時一律使用 &self。 這也是鼓勵使用 RefCell 等 wrapper type 的另一個佐證。
conclusion
本文重點討論了 Rust 中 開發人員定義 struct 的常見問題。其核心就是: 1、盡量使用 interior mutable 代替 exterior mutable 2、使用 可變+引用計數的 wrapper 包裹自定義的 struct 。 這樣雖然在使用的時候需要一層一層往外拆十分麻煩,但是保證在功能迭代時不會出現因為 mutable 的變化而迫使類型欄位和方法參數重新定義的情況。
但是,這樣做也是就代價的,其中之一就是 match / if let hell。考慮一個中等複雜的結構體,由於 RefCell 的使用常常伴隨著 try_borrow_mut() 等需要 unwrap 的方法。 每一次 unwrap, 常規的寫法都是 使用 match { if let Some(xxx) / Ok(xx) = foobar {} } 這種多層嵌套的方式。 當出現需要同時對多個 RefCell 進行 unwrap 的時候,由於不同的 wrapper 彼此獨立,unwrap 需要線性先後執行,就會出現多層較深的 match / if let 嵌套。 這給代碼的整潔以及邏輯的理解帶來的較大的問題。此外,當 RefCell 的解構 與循環結構 結合起來的時候,就會出現「需要特別的姿勢才能實現某些特定功能」的情況。這些在之後的文章會深入探討,如果我找到了解決方案的話。
reference
[1]、 Mutability
[2]、 Choosing your guarantee
[3]、Interior mutability in Rust: what, why, how? (強烈推薦這個系列)
[4]、Pointers in Rust: a guide (注意這篇文章的 @, ~ 指針已經過時了,被 Box<T> 代替。但是仍然不失為一篇好文章)
[5]、an linkedlist implementation by Rust
場外應援
Shared<T> 是啥指針? 好吃嗎?
推薦閱讀: