從一句口號到編譯器能檢查的規則
在 Rust 的入門階段你學過那些口號:每個值有一個擁有者,你可以交出一個借用而不必移動它,而一個借用要嘛是一個共享參照、要嘛是一個獨佔參照。這些口號是對的,但它們還不是一條機器能驗證的規則。要深入,你需要精確的版本,而它建立在一個定義上:生命週期(lifetime)不是以秒計的一段時間——它是你程式碼裡的一段區域,是某個特定參照被要求保持有效的那組程式點。請再讀一次,因為幾乎每一個令人困惑的借用檢查器錯誤,都會在你停止思考「時間上多久」、改成思考「跨越哪幾行」的那一刻溶解。
有了這個定義,整個借用檢查器就變成一件工作:在編譯期證明程式裡每一個參照都滿足兩個性質。第一,沒有任何參照會在它所指資料的生命週期之外被使用——參照絕不可活得比它的指涉對象久,這正是 C 任你隨意寫出的懸置指標臭蟲。第二,任一時刻在範圍內的那些借用,永不違反別名互斥於可變(aliasing XOR mutability):你可以有許多共享的 `&T` 讀者,或恰好一個獨佔的 `&mut T` 寫者,但絕不能兩者同時存在。檢查器是一種靜態分析,它要嘛找到這樣一份證明、要嘛退回你的程式。它不是執行期的防護,也不會多加任何指令;它是一位裁判,在你的程式碼還沒變成機器碼之前就先跑完。
為什麼「活得比較久」就是整場遊戲
我們來看看檢查器專門要擋下的那個臭蟲,先在 C、再在 Rust。在 C 裡你可以回傳一個區域變數的位址:函式回傳後,它的堆疊框被回收,呼叫者手上就留著一個指向「不再屬於任何人」的記憶體的指標。那是一個懸置指標,對它解參照就是未定義行為——在 -O0 它可能印出垃圾,在 -O2 卻可能被最佳化器整段刪掉。C 裡沒有任何東西替你檢查這件事;那份紀律只活在你的腦袋裡。Rust 則在編譯期捕捉到一模一樣的形狀,做法是拒絕讓一個參照逃出它的指涉對象還活著的那段區域。
想像最小的版本。一個 Rust 函式 `fn dangle() -> &i32` 宣告一個區域變數 `let x = 7`,然後試圖回傳 `&x`。但 `x` 由 `dangle` 擁有,所以它在右大括號處被丟棄——回傳的參照會指向一個不再存在的框。編譯器以「此函式的回傳型別含有一個借來的值,但沒有任何值可供它借用」拒絕。它推理了 `x` 存活的那段區域(僅僅是函式本體),看出這個參照被要求在一段嚴格更大的區域(呼叫者那邊)內有效,於是包含關係不成立。
留意檢查器腦中真正在做的事:它替每個值指派一段它存活的區域(擁有者的範圍),替每個參照指派一段它必須保持有效的區域(之後用到它的每一處),然後要求第二段區域被包含在第一段之內。借用的區域必須是指涉對象區域的子集——這一個包含關係,就是「參照不可活得比它的資料久」的形式核心。這和 C 的釋放後使用是同一個危害,只是從一個你祈禱永不發生的執行期當機,變成了一個你無法忽視的編譯錯誤。
為區域命名:生命週期參數與省略
當一個函式收下幾個參照、又回傳一個參照時,編譯器必須知道輸出是從哪一個輸入借來的,才能要求正確的包含關係。你用一個生命週期參數把這件事講明白——一個像 `'a` 的名字,代表「某段由呼叫者挑選的區域」。在 `fn longest<'a>(x: &'a str, y: &'a str) -> &'a str` 裡,`'a` 是說:回傳的參照在兩個輸入共同的那段區域內有效,不會更久。生命週期不是你去計算或配置的東西;它是檢查器要去求解的一個約束變數,就跟型別參數一模一樣,只是它的範圍跑過的是程式碼的區域、而非型別。
如果每個會借用的函式都逼你親手寫 `'a`,Rust 會讓人受不了。多虧了生命週期省略(lifetime elision)它不必如此——這是編譯器套用的一小組機械規則,讓你通常可以省略標註。這些規則故意很死板:每個被省略的輸入參照各拿到一個全新的生命週期;如果恰好只有一個輸入生命週期,輸出就從它借;如果有 `&self`,輸出就從 `self` 借。這就是為什麼 `fn first(s: &str) -> &str` 根本不需要標註。省略純粹是語法糖——它從不改變什麼是合法的,只是讓你略過寫那些顯而易見的情況。當規則猜不出來時(兩個輸入參照、又沒有 `self`),編譯器就會停下來要你自己為區域命名,而這正是 `longest` 撞上的那種情況。
別名互斥於可變:更深的不變式
活得比較久只是證明的一半。另一半,也是讓 Rust 在安全語言中與眾不同的那部分,是別名互斥於可變:在任一程式點,一塊資料可以有許多共享讀者(`&T`)或恰好一個獨佔寫者(`&mut T`),但絕不能在有寫者的同時還有任何其他參照。「互斥(XOR)」就是整個重點——別名與修改各自被允許,合在一起則被禁止。這就是消滅迭代器失效那一類臭蟲的東西:你不能一邊握著對一個向量的 `&mut`(要往裡 push)、一邊又握著對它某個元素的 `&`,因為 push 可能重新配置緩衝區,使你的元素參照懸空。C 和 C++ 讓你正是這樣做;Rust 的檢查器則證明你絕無可能。
這裡藏著一個美妙的回報,也是這條規則之所以如此嚴格的真正原因。如果編譯器已證明某個 `&mut T` 是唯一能觸及那塊資料的參照,那它就知道沒有別的東西能觀察或改變它背後的值。這正是 C 只能靠灑上 `restrict` 關鍵字、再祈禱程式設計師說了實話才能拿到的「無別名」保證——而在 Rust 裡它是天生就有、免費的、處處成立的。所以別名互斥於可變不是官僚式的吹毛求疵;它同時是一個安全性質(沒有資料競爭、沒有迭代器失效)也是一張最佳化許可證(編譯器可以把值留在暫存器裡、自由地重排讀取)。那條感覺一直在跟你作對的規則,同時也是替你掙得 C 級速度的那條規則。
非詞法生命週期:檢查器長大了
早期的 Rust 把一個借用的區域綁在它被宣告的文字範圍——它的詞法區塊——上,這退回了很多明顯沒問題的程式碼。現代的檢查器改用非詞法生命週期(NLL):一個借用只從它被建立的地方活到它最後一次實際使用,而不是一路活到右大括號。這就是為什麼下面這個例子現在能編譯。對 `v` 的共享讀取,在它最後一次使用的那一刻就結束,騰出位子讓獨佔的 `&mut` push 開始——這兩個借用在程式裡其實並未重疊,即使它們變數的詞法範圍重疊了。
let mut v = vec![10, 20, 30];
let first = &v[0]; // a shared borrow of v begins
println!("{first}"); // ... its LAST use is right here
// NLL: the shared borrow ENDS here, not at the brace
v.push(40); // now the exclusive &mut borrow is allowed -- OK
// Reorder so the shared borrow is still live across the push:
let first = &v[0];
v.push(40); // error: cannot borrow `v` as mutable
println!("{first}"); // because it is also borrowed as immutable hereNLL 之所以重要,是因為它讓檢查器更接近於「只退回那些真正不健全的程式」、並接受那些細心的人看得出沒問題的程式。不過,誠實地說,檢查器仍然是保守的:它是一種靜態近似,所以仍有一些正確的程式它無法證明安全、因而會退回——自我參照的結構與某些圖形狀就是經典例子。這是真實的代價,不是你想像出來的瑕疵。當你撞上它時,正確的動作不是去跟借用檢查器硬幹,而是改變資料結構(用索引代替參照),或主動採用一個有檢查的執行期工具,而這正是後面講內部可變性與 unsafe 的那幾篇所要談的。
變異性,以及一個公允的總結
最後一塊讓這個模型完整,而它是微妙的那一塊:變異性(variance)——它規範了什麼時候一個生命週期較長的參照可以用在預期較短的地方。對讀取而言這直覺上沒問題:一個 `&'long T` 可以頂替一個 `&'short T`,因為在一段長區域內有效的東西,在被它包含的較短區域內當然也有效(我們說它在生命週期上是協變的(covariant))。陷阱在 `&mut T`,它是不變的(invariant):你完全不可在它身上替換生命週期,因為透過它做的修改,可能把一個太短的參照存進太長的儲存裡,重新打開檢查器剛剛關上的那個懸置指標破洞。你很少會親手寫下變異性,但它正是 `&mut` 比 `&` 更挑剔的原因,而知道這個詞,能把一則令人費解的錯誤訊息變成一個可解的問題。
所以這就是整台機器,公允地陳述。借用檢查器是一份對兩個不變式的編譯期證明——沒有參照活得比它的指涉對象久,以及別名互斥於可變——它建立在被理解為程式碼區域的生命週期之上,由 NLL 精煉成追蹤最後使用而非範圍,並由變異性在邊界處保證其健全。當它接受你的程式時,你拿到 C 的速度卻沒有那一類記憶體臭蟲——沒有釋放後使用、沒有重複釋放、沒有資料競爭——而你為這份保證付出零執行期成本。這是誠實的交易,而且是一筆真正划算的交易。