學 Rust 要有大局觀 -三- 最痛就這麼痛了

導語

讀過上一篇 (學 Rust 要有大局觀 (二) Rust 的精髓) 的同學直接給我反饋的問題主要是: 爲什麼 Rust 要有move sematic這個神奇設定,你說的都懂,但是好處在哪裏呢?(歡迎大家有問題直接在公衆號 "Rust 工程實踐" 留言提問, 讀者的反饋真的是寫作的原始反饋和動力). 所以在開始今天的reference & lifetime主題之前,我們簡單回顧一下轉移語義到底的好壞之處有哪些:

move sematic 的好壞

好處(rust 的承諾)

  1. 方便編譯器編譯階段跟蹤內存值的使用情況,可以讓編譯器無比強大地分析堆棧狀態.2. 編譯階段排除了非常多內存不安全的內存 (代碼寫法), 不會存在懸空指針 (dangling pointer)

壞處

• 學習曲線陡峭的重要原因之一,會進一步引出引用,生命週期等概念,容易讓初學者遭遇挫敗感 • 連一個賦值都 TM 編譯不過!?• 爲什麼一個 print 打印之後,變量就沒了!?

rust 痛點排行

根據Rust Survey 2020 Results[1] 調查結果顯示,rust 最難學的部分以lifetime排第一位. 全局觀很重要,今天我們就開始帶大家看一看最難的部分到底有多難,最痛也就這麼痛了. 可以看到生存週期,所有權,還有 trait,是大家掌握起來比較棘手主題.

rating-of-topics-difficulties

rust reference

上一篇的末尾我們提到過一個簡單的思考,基於move sematic編寫代碼的時候,一個簡單的 for 循環 print 語句就會導致一個數組變量被使用後釋放掉,這其中的核心原因就是 for 循環語句理論上應該是租借使用權, 而不應該取得所有權; 爲了解決這個問題,rust 提供了reference類型的可copy變量;

作爲一個 c/c++ 程序員的你, 請思考,把上面 for 循環和打印語句作爲函數體的情況下 (僅有外部變量的讀取需求),如果在其他語言中,如此簡單的函數有可能造成程序崩潰嗎?或者會有什麼陷阱?

你的答案可能是這樣的; 函數的參數應該是const &, 常量引用,這樣我即避免了外部變量的拷貝,節省了內存和調用開銷,同時通過 const 保證了不改變使用的變量內容.

我想說的是,這都是沒問題的,但是這樣並不能保證你的完美print函數 core 掉,原因是,你通過const &對編譯器承諾了自己不改變變量,僅僅是使用,也不做 copy,然而gcc編譯器並不給你任何承諾,所以:

  1. 變量其他地方被改掉了. 你讀取到了奇怪的內容.2. 變量被銷燬了,你讀取到了不應該操作的內存,程序崩掉了.

爲什麼可以這樣?因爲程序員承諾我不修改我使用的內容,但 c++ 語言本身,編譯器並不承諾這個變量它自己不會變(這種承諾不是相互的);

在 Rust 中, 程序員通過borrow得到一個reference來承諾僅讀取,或者肯定會修改一個變量

borrow的承諾寫法是在變量前增加一個&, 比如

struct Point {
    x: i32,
    y: i32 
}
let point = Point { x: 1000, y: 729 };
let r: &Point = &point;     // r現在是point的`只讀使用權`
let rr: &&Point = &r;        // rr現在是`只讀使用權``只讀使用權`
let rrr: &&&Point = &rr;    // rr現在是`只讀使用權``只讀使用權``只讀使用權`

上面的代碼的內存模型是這樣的:

A-chain-of-references-to-references

so~ 很明顯,如果使用r來訪問point的話,並不會影響 point 的所有權關係,即使r被閱後即焚了也不影響point, 內存上它倆使用的棧資源沒關係.

於此同時,編譯器同時許諾你在你使用這個 reference 期間, 任何對原有變量所有權的變動的代碼,及修改,我都拒絕編譯! 編譯器通過如下圖所示的規則來判斷是否拒絕代碼的編譯, 詳細來說,rustc 通過兩條規則來兌現它的承諾 (下圖的中間和最右則情況):

reference-and-ownership

詳細來說就是,編譯器做代碼靜態分析的時候,僅通過閱讀文本符號就知道是否要編譯此代碼還是直接拒絕繼續編譯代碼,因爲borrow語意的語法是精確的,就是程序員給出的承諾. 以一個簡單的結構體來說,內部還有一個其他結構體,那麼它的內存模型大致就是上圖最左邊的樣子 (棧上是變量本身的空間,內容是指向堆內的資源的樹形結構). 下面以程序員要操作結構體內部的結構體子元素爲例:

  1. 當程序員承諾對一個子成員變量僅讀取的時候 (中圖),編譯器承諾的兌現動作就是 • 對象樹 root 所有權變動的代碼編譯不可以通過 • 修改子成員內容的代碼編譯不可以通過 • 值 owner 也最多做讀取不能再多了 2. 當程序員承諾對一個子成員變量要修改的時候 (右圖),編譯器承諾的兌現動作就是 •非承諾的引用`之外的所有試圖接觸這個值的代碼都不可以通過 • 即使是通過值的 owner 也不行讀取

基於以上兩條規則,請自己編寫 rust 的編譯器,給出下面代碼是否違反給程序員的承諾?(注意同樣的代碼寫法,若是 c++ 肯定是可以通過編譯的)

fn main() {
    let mut v = 10;    // v是一個可以改變的變量
    let r = &v;    // `&v` 就是程序員的承諾: 用r(reference)來借用變量v的使用權(讀取)
    let rr = &r;    // 程序員二次承諾還要用rr也讀取v, 並且
    v += 10        // 要改變v了,編譯過還是不過? 
    myprint(r);    // 打印函數, 使用r來訪問 
    myprint(rr);    // 打印函數, 使用rr來訪問
}

你是否疑惑上面的文字裏,梁小孩反覆地寫程序員得到一個引用是承諾對一個變量做只讀操作? 我就是讀取一下,有什麼要承諾的?

現在我要告訴你,rust 的語法規則之精確,讓你寫任何一句話都是在給編譯器訴說自己的承諾. 比如當你寫一個任意函數的時候,函數的聲明形式就是你給編譯器的承諾, 承諾對變量是如何使用的, 當你承諾只讀,但是函數體內部出現了寫操作,編譯器有權根據你先前的承諾拒絕編譯你的代碼.

再思考另外一種情況,多個變量的函數,一個只讀承諾,一個寫引用的承諾,還有一個是直接 move 語意的所有權取得;這就是你給編譯器的承諾,變量會被函數形參拿去值的所有權,如果使用不當被銷燬,是程序員自己一開始就不應該做出的此函數對所有權負責的承諾. 所以程序員一定要編碼之前想好頂層架構,因爲一旦有變動,可能很多函數需要重寫.

生命週期

爲了適應move sematic而引入的borrow & reference會帶來的新的問題是, 讓所有權和使用權發生了切割分離之後,引用和原始值的內存空間獨立,但是二者的邏輯關係要求: 原始值必須存在的前提下,引用纔有存在的意義. 如果遇到返回值引用了的函數這樣的代碼是否要停止編譯? 很明顯函數返回了使用權, 如果此時不發生move需要有其他維度幫忙判斷代碼的邏輯合理性. 答案就是需要考慮lifetime生命週期.

從最原始的疑問開始: 你編寫了一個函數,並且返回了一個reference, 假如被引用的值是函數內部 local 變量,那麼基於安全考慮我們要拒絕它,如果引用的傳入參數的某個子元素,那麼我們要知道傳入參數的存活時間能否支撐這個引用是有效的. 所以編譯器一定跟蹤引用和對應變量是否存在衝突的讀寫情況,還要跟蹤每個變量的有效範圍,發現程序員讀寫違反承諾, 或者引用的生命週期不是被引用值生命週期的子集的時候,合情合理地拒絕編譯.

reference-with-a-lifetime

說了這麼多,其實只有圖中表示的一個核心原理,那就是 rust 編譯代碼要檢查是否存在像這樣的合理的嵌套 (cover) 關係.

如果事情到此爲止的話一切完美,不過有很多中情況作用域的嵌套比上面的例子要更加隱晦, 比如一個結構體的成員borrow的外部資源, 比如一個函數調用其實就是變量進入新的作用域, 這種情況可能還會產生組合: 你得到一個內部包含了reference的結構體作爲函數參數, 這時候資源跟蹤情況就很複雜了。此時,rust 編譯器要求你給出明確的關於資源存活時間的承諾. 這種承諾的表現方式就是讓很多同學看不懂的生命週期語法. 我們不關心語法,僅僅是回到原始問題上來,不管什麼樣的語法,我們應該通過這個語法給編譯器傳遞什麼信息呢? 程序員做的任何承諾,rust 編譯器都會仔細檢查,針對這種情況,程序要要做出的承諾無非就是類似我絕對不會胡亂引用這樣的信息,比如我不會引用比結構體本身存活時間還短的變量.

宣稱使用範圍

生命週期不是作用域, 是變量被使用的那段時間, 一個結構體有兩個引用類型的成員, 其中一個引用失效時,只要可以保證它也永遠不再被用到,那也是 OK 的. 所以我建議大家將生命週期理解成爲程序員宣稱的引用的合理使用範圍;

struct S {
    r32: &i32;
    r64: &i64;
}
let s;

給s.r32承諾範圍1

1.s.r32存活時間 (reference) 一定要小於等於被引用值的存活時間(所有人都是必死的)2.s.r32存活時間和s一樣 (蘇格拉底是人)3. 所以s的存活時間必須小於等於s.r32引用的值的存活時間 (蘇格拉底是必死的)

給s.r64承諾範圍2

1.s.r64存活時間 (reference) 一定要小於等於被引用值的存活時間(所有人都是必死的)2.s.r64存活時間和s一樣 (蘇格拉底是人)3. 所以s的存活時間必須小於等於s.r64引用的值的存活時間 (蘇格拉底是必死的)

三段論裏蘇格拉底是人這個特殊陳述應該是問題的核心,因爲當有多個成員變量的時候,被引用的具體值的生命週期可能一樣, r32 和 r64 原始值作用域會不同,但是無論如何s都是兩者之中更小的那一個.

編譯器處理上面的代碼的時候需要程序員承諾: 到底s.r32s.64的範圍一樣還是不一樣,你若是宣稱一樣,那麼我檢查s的存在多久就可以了,你若是宣稱不一樣, 那麼我就得按照相對小的那個來判斷s的使用範圍是否合理.

OK, 是時候看一下實際代碼了~

struct A<'a> {
    r32: &'a i32;
    r64: &'a i64;
}
struct S<'a, 'b> {
    r32: &'a i32;
    r64: &'b i64;
}

這就是添加了lifetime聲明的結構體,其中A宣稱A.r32A.r64預期使用範圍一樣, 此時 rustc 編譯器按照 A 的實例存活時間判斷就可以了跟蹤實際引用的值是否滿足要求, 但是S現在宣稱S.r32S.r64是兩個不同的使用範圍,此時 rustc 將分別跟蹤被引用的兩個值的存活時間是否都比S要大.

重新回到返回引用的函數這個原始問題上, 怎麼寫才合適?

// 程序員宣稱函數使用的時候, 返回值使用範圍和入參肯定一樣(或更小), rust會檢查確認是否真的這樣
fn smallest<'a>(v1: &'a [i32], v2:&'a [i32]) -> &'a i32 { ... }

到這裏你應該明白了,lifetime真的是一個編譯期概念,是程序員做出的承諾,rustc 會根據你的承諾檢查代碼是否是你宣稱的那樣,被引用的值是不是一直比引用時間更久.

到此爲止,我們應該可以更清楚地理解一下move sematicborrow & reference再到liftime的整個邏輯鏈條。他們到底都在解決什麼問題, 這正學習的時候需要大量的例子加深細節把握. 我們僅關心概念和概念提出的場景,解決的問題.

以上是我自己對這些概念的理解和思考,難免會有重大錯誤,但是應該能幫到大家. 下一篇咱們看trait是個什麼東西, 再會~~

引用鏈接

[1] Rust Survey 2020 Resultshttps://blog.rust-lang.org/2020/12/16/rust-survey-2020.html

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