JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

借用與借用檢查器

如果每次想用一個值都得把它「搬走」,那會累死人;借用讓你改成把它「借出去」。本篇要說明 Rust 的參考如何運作、借用檢查器只執行的那一條規則,以及為什麼這條規則能攔下那些 C 會放任你出貨的 use-after-free 與懸置指標臭蟲。

為什麼把一切都搬走會累死人

在上一篇你學到,Rust 的值有單一的所有者,而把一個值交給函式或另一個變數會把它搬走——搬走之後,原本的名字就死了,再用它就是編譯錯誤。這條規則正是讓搬移語意安全的原因:絕不會有第二個名字偷偷共享同一塊堆積記憶體,所以絕不會有 double free。但若照字面執行,這樣寫程式會痛苦不堪。如果把一個大字串傳給一個只想數它有幾個字元的函式,竟然會吃掉這個字串,那你事後還得把它「還回來」才能繼續用它。每一次「讀取」一個值,都得付出它的所有權當代價。

Rust 的答案是借用。你不把值本身交給函式,而是借給它一個參考——寫法就是你在 C 裡早已認識的那個 `&`,在 C 裡 `&x` 取的是 `x` 的位址。一個參考在機器層次上不過就是一個指標:它存著它所指資料的位址,而你透過它去取得資料。與 C 真正的差別不在指標裡的那些位元;而在於編譯器圍繞著它所做的記帳。當你傳 `&s` 而非 `s`,函式拿到的是這個字串的一次借用、做完它的工作、借用就結束了——而 `s` 仍是你的、仍然活著、從未被搬走。你把書借出去了;你並沒有把它送掉。

兩種借用:共享與可變

借用恰好分成兩種,而這個分別就是整盤棋的關鍵。共享借用寫成 `&s`,給的是唯讀的存取。你可以一次取得同一個值的任意多個共享借用——它們全都能看,沒有一個能動。可變借用寫成 `&mut s`,給的是可讀可寫的存取,而且它是獨占的:當一個 `&mut s` 還活著時,`s` 的任何其他借用——無論共享或可變——都不得存在。這正是共享與可變借用的核心,一句話就能概括:你可以有許多讀者,或一個寫者,但永遠不能兩者同時存在。

為什麼偏偏是這條規則,而不是更鬆的?因為它正好是那條讓資料無法在你眼皮底下被毀壞的規則。如果你程式裡有兩個部分同時持有一個值、其中一個又能寫,那麼另一個就可能正在讀一個字串,而寫者卻在此時重新配置它的緩衝區、釋放掉舊的那塊——這就是一場現場直播的 use-after-free。C 讓你一個下午就能寫出這個臭蟲,而且從不警告你。Rust 的「多讀者,或單一寫者,二擇一」規則,禁止的正是讓這件事成為可能的那個組態,所以這個臭蟲在安全的程式碼裡根本無從表達。這個限制在你看出它其實就是「把那個 C 臭蟲的形狀,寫成一條禁令」之前,會一直感覺很武斷。

shared borrows: many readers, all fine
    let s = String::from("hi");
    let a = &s;            // shared borrow
    let b = &s;            // another shared borrow, OK
    println!("{} {}", a, b);   // both read, no problem

mutable borrow: exclusive, so this is REJECTED
    let mut s = String::from("hi");
    let w = &mut s;        // exclusive mutable borrow
    let r = &s;            // ERROR: cannot also borrow s as shared
    println!("{} {}", w, r);   //   while w is still in use
多個共享借用可以並存;一個可變借用拒絕共享。第二段是編譯錯誤,而不是執行期崩潰。

借用檢查器:一個在編譯期完成的證明

編譯器裡負責執行這一切的部分,就是借用檢查器,而它究竟是什麼,值得說精確。它不是一個在你程式執行時盯著它看的執行期守衛——沒有垃圾回收器、沒有參考計數、最終的二進位檔裡也沒有留下任何檢查。它是一種靜態分析:在產生任何程式碼之前,編譯器會為每一個參考,追蹤這個參考被使用的那段程式碼範圍,並證明任何兩個借用都不曾違反「多讀者,或單一寫者」這條規則,也證明沒有任何參考活得比它所指的值更久。若它無法證明你的程式安全,它就拒絕編譯。一旦通過檢查,產生的機器碼會精瘦得正好和等價的 C 一樣——安全的代價完全是在編譯期付清的。

這裡是本階段答應要說實話的部分:借用檢查器會駁回一些你用自己的眼睛就看得出來、明明完全安全的程式碼。這個分析是保守的——只要它無法構造出一個證明,它就必須說不,即使存在一個它不夠聰明、找不到的證明。一個把參考回傳到區域變數裡的函式、一對檢查器以為重疊、其實並不重疊的借用、一個指向自己的資料結構——這些都會引發一些感覺像是編譯器在裝糊塗的錯誤。這不是你撞上的一個臭蟲;這就是這筆交易。你拿一些表達上的自由,去換一個保證,而學 Rust 在很大程度上,就是學著用一種檢查器能驗證的方式去表述你的意圖。

生命週期:檢查器如何知道一次借用已經結束

為了證明一個參考絕不會活得比它的值更久,檢查器需要推理出每個參考活多久。一個參考有效的那段程式碼範圍,就是它的生命週期,而生命週期正是你目前見過的每一條借用規則背後的機制。大多數時候你根本不必把生命週期寫出來——編譯器會從參考被建立的地方、以及它最後一次被使用的地方推斷出來。生命週期所禁止的那個經典 C 臭蟲,是回傳一個區域變數的位址:在 C 裡,`int *f(void) { int x = 0; return &x; }` 能編譯通過,而呼叫者拿到的是一個指向「已經被回收的堆疊格子」的懸置指標。同樣形狀的東西在 Rust 裡是一個生命週期錯誤,在程式跑起來之前就被攔下。

生命週期所編碼的規則,說出來很簡單:一個參考絕不能活得比它所借用的值更久。一次借用是一個承諾,意思是「只要我還握著這個參考,這份資料就會還在這裡」,而檢查器拒絕讓你做出一個所有者守不住的承諾。當一個值的所有者離開了它的作用域、這個值被丟棄時,它的每一個借用都必須早已死去。這正是檢查器之所以要追蹤生命週期的原因——不是為了煩你,而是為了對你程式碼裡每一條路徑上的每一個參考,機械地驗證那個承諾。偶爾,當編譯器確實無法推斷出這層關係時——典型情況是一個函式收下好幾個參考、又回傳其中一個——你會寫一個明確的生命週期標註,告訴它輸出借的是哪一個輸入。這已經是日常的 Rust 最深需要踏進的地方了。

這把你帶到了哪裡

退一步看,整幅圖是融貫的。所有權決定由誰釋放一個值;借用讓別人能暫時讀或寫它、卻不必接下那份義務;生命週期證明每一次借用都在它的值之前結束;而借用檢查器在編譯期執行這全部三件事,到了執行期則不花任何代價。它們合起來,在安全的程式碼裡,封住了你在 C 的那幾個階段裡學會去畏懼的那些傷口——use-after-free、double free、以及懸置指標——既不用垃圾回收器,也不用任何執行期檢查。這就是 Rust 所提出的交換,而現在你看得見它的形狀,不必只憑信仰接受它。

不過,關於這條界線要說實話。借用檢查器保證了很多,但它並不是「你的程式正確」或「所有臭蟲都不見了」的承諾——邏輯錯誤、死結、以及算錯的答案,都能在「借用安全」的程式碼裡活得好好的。而當你確實需要一個檢查器無法驗證的模式時,一個 `unsafe` 區塊能讓你跨出它的保護之外,在那裡,那些舊式 C 風格的責任會原封不動地回來。接下來兩篇直接建立在這塊根基上:Rust 如何用你以值回傳的 `Option` 與 `Result` 型別,取代空指標與錯誤碼,以及最後,Cargo 與工具鏈的其餘部分,如何對應到你早已熟悉的 C 建構世界。