你現在已經知道怎麼製造的那個臭蟲
走到這一階時,你已經能做一件多數初學者做不到的事:你能精確說出一支 C 程式是「怎麼」搞壞自己的。你知道呼叫了 free() 之後又透過同一個指標讀取,是釋放後使用;忘了 free() 是記憶體洩漏;寫過了陣列尾端是緩衝區溢位;而這些全都是未定義行為,最佳化器大可把它變成任何東西。你也知道上一階那個令人不安的真相——這些不是什麼罕見的失誤,它們是仔細、有經驗的人寫普通程式碼時的「預設」失敗模式。
這些臭蟲共有一個根源,值得把它直白地說出來。在 C 裡,「這塊記憶體由誰負責、而它此刻是否仍然有效?」這個問題,沒有任何由「語言」去追蹤的答案。它活在你的腦袋裡、活在註解裡、活在團隊間的慣例裡。編譯器會樂呵呵地讓你把一塊記憶體釋放兩次、把一個指向已經離開作用域之區域變數的指標交出去、或用一個你從沒檢查過的數字去索引陣列。`char *s` 的型別裡,沒有任何東西記載 s 是否仍指向一個活著的東西。那一塊缺失的資訊,正是每一個記憶體臭蟲掉下去的那道縫。
兩個舊答案,以及各自的代價
在 Rust 之前,業界對記憶體臭蟲主要有兩個答案,而每個都用真實的代價來換取安全。第一個答案是「人工紀律」——C 與 C++——你這位程式設計師把規則記在腦中,而語言徹底信任你。代價就是這座梯子的整個上一階:一次失手,便成了無聲的毀壞。回報是完全的掌控、沒有隱藏的開銷;沒有任何你沒寫的東西在跑。
第二個答案是垃圾回收器——Java、Go、Python、C#、JavaScript。在這裡,語言在「執行期」追蹤哪些記憶體區塊仍然可達,並自動釋放其餘的。這正是 C 刻意沒有的替代方案,而它確實廢除了釋放後使用與重複釋放:你根本不呼叫 free(),所以也就無從弄錯。代價是用另一種貨幣付的。垃圾回收器需要一個會週期性掃描存活記憶體的執行期,這要花 CPU 時間、還可能造成停頓;它通常會讓比嚴格必要更多的記憶體保持存活;而你失去了那種確定、可預測的清理——系統程式(一個核心、一個資料庫、一個嵌入式控制器)往往少不了它。
於是幾十年來,這個選擇彷彿是被逼的:要嘛人工掌控配人工的危險,要嘛自動安全配一份你無法退出的執行期稅。系統程式設計師——他們在乎的正是垃圾回收器奪走的那份可預測性——大多留在 C 並與臭蟲共處。Rust 存在的全部理由——它的動機——就是宣稱這是個假選擇,宣稱你可以「同時」擁有記憶體安全、沒有垃圾回收器、以及 C 等級的掌控。這個宣稱聽起來好得不像真的,所以誠實的問題是:陷阱在哪裡?
陷阱在哪:編譯器替你記帳
Rust 的把戲,是把你過去靠腦袋做的記帳,搬進「型別系統」,並且完全在編譯期完成。前面那塊缺失的資訊——這塊記憶體誰擁有、它是否仍有效?——變成了一個編譯器真的會追蹤的性質。核心想法是所有權:每個值恰有一個擁有者,當擁有者離開作用域時,那個值就自動被釋放。沒有任何垃圾回收器在執行期掃描;編譯器只是在你寫下程式碼的那一刻就「知道」每個值的生命在哪裡結束,並替你在那裡插入相當於 free() 的動作。
另外兩條規則,補上所有權單獨留下的縫。移動語意(移動)說:當你指派或傳遞一個值時,所有權「移動」到新地方,而舊名字再也不能用了——於是兩個變數絕不會都以為自己擁有同一塊記憶體,而那正是 C 裡重複釋放之所以可能的原因。而借用(借用)讓你出借臨時的存取權——一個參照——而不必交出所有權,並受借用檢查器所強制的規則約束:你可以有許多唯讀借用、或恰好一個可寫借用,但絕不能同時兩者。這一條在編譯期檢查的規則,正是讓資料競爭與釋放後使用在安全 Rust 裡「無法被表達出來」的關鍵。接下來兩篇,我們會把全部篇幅給所有權/移動與借用;這裡你只需要抓住輪廓。
C: who frees this? you must remember.
char *s = malloc(64); // you own it... by convention
use(s);
free(s); // forget this -> leak
use(s); // do this after free -> use-after-free (UB)
Rust: the compiler tracks the owner and frees at end of scope.
let s = String::from("hi"); // s owns the heap buffer
use_it(s); // ownership MOVES into use_it
// s is no longer usable here -- the compiler rejects use(s)
// when the owner goes out of scope, the buffer is freed: no free() call,
// no GC, no double-free, no use-after-free -- all proven at compile time參照與生命週期,以及咬不到你的空值
在機器層面,一個 Rust 參照不過就是個指標——你一直熟悉的、暫存器裡的那個位址。不同的是,每個參照都帶著一個編譯器會去推理的、看不見的第二項事實:它的生命週期(lifetime),也就是它所指之物保證仍然存活的那段期間。借用檢查器用生命週期來「證明」沒有任何參照活得比它的目標久——而那正是你在 C 裡學會害怕的迷途指標。如果你試圖回傳一個指向即將被摧毀之區域變數的參照,這支程式根本編譯不過。指標是同一個;差別在於編譯器拒絕讓它變成迷途的。
Rust 也補上了 C 的另一道大傷口,那道根本與記憶體區塊無關的傷:空指標與未經檢查的錯誤。安全 Rust 裡沒有空值。C 用 NULL 表示「這裡什麼都沒有」的地方,Rust 用 Option——一個「要嘛是真值、要嘛是明確的 None」的型別——而編譯器不會讓你在還沒先檢查它是哪一種之前就用那個值。C 回傳 -1 並設定 errno、然後祈禱你會去檢查的地方,Rust 回傳一個 Result——「要嘛成功、要嘛是錯誤」——同樣逼你把兩種都處理掉。本階第四篇專講這兩者。模式和你現在已經看過兩次的一樣:在 C 裡是慣例的資訊,到了 Rust 變成型別系統會強制的事實。
請留意,這一切都不需要執行期。沒有掃描器、沒有停頓、沒有隱藏的執行緒。這些檢查每一個都只發生一次、發生在編譯器裡,然後就消失了:Rust 產出的二進位檔是赤裸的機器碼,開銷不比等價的 C 多。這就是人們說「零成本」時的意思——安全是用編譯時間、以及你自己跟借用檢查器搏鬥的力氣付的,而不是用執行期的速度付的。這才是 Rust 那筆交易的真實形狀,而它和垃圾回收器的形狀,是真的不一樣。
誠實的陷阱:一道曲線、一個逃生口,而非魔法
現在來到行銷會跳過的部分。借用檢查器很嚴格,而「嚴格」意味著它會有「偽陽性」:它會駁回一段你一看就知道完全安全的程式碼。一個對人類來說明明沒問題的寫法,可能違反了「一個寫者或多個讀者」這條規則——以檢查器理解它的方式——於是你會被打回票。這不是臭蟲;這是一個「永遠不放真正違規過關」之檢查器的代價。每一位有經驗的 Rust 程式設計師,都為了看起來正確的程式碼跟借用檢查器吵過架;學 Rust 的頭幾個月,大半就是把「它會接受什麼、不接受什麼」內化的那幾個月。任何人若告訴你這道曲線很平緩,那他在賣東西。
還有一個逃生口,而它必須被誠實地點名:unsafe 關鍵字。有些真實的事情——解參考一個原始指標、呼叫進一個 C 函式庫、寫一個借用檢查器無法證明的資料結構——確實沒法在安全 Rust 裡表達,所以 Rust 給你 unsafe 區塊,在其中允許那些操作,而編譯器不再替它們擔保。在 unsafe 之內,你回到了 C 等級的責任;你又能造成未定義行為了。關鍵的設計重點是:unsafe 是「有界的」——它是一小塊、grep 得到、明確標記的區域,於是當一個記憶體臭蟲真的冒出來時,嫌疑犯是寥寥幾個 unsafe 區塊,而不是整支程式。比起 C,這是一個截然不同的除錯故事,但它並不等於危險的消失。
你的 C 知識換到了什麼,以及接下來往哪走
這裡有一個鼓舞人的真相,值得你帶著爬完這一階剩下的路:學 C 並不是一條你現在得反過來忘掉的繞路。Rust 跑在完全相同的機器上——同樣的堆疊與堆積、參照底下同樣的指標、同樣那個「值佔據著某位址上的位元組」的概念。你建立起來關於記憶體佈局、資源所有權、以及一支程式究竟如何執行的一切,正是 Rust 假設你已經具備的那副心智模型。C 是用「讓你打破規則」來教你「為什麼這些規則重要」;Rust 拿同一套規則,請一個編譯器來強制它們。你不是從頭來過;你是替你早已在實踐的紀律,配上了一個檢查器。
有一個鋒利的方式能看清整個轉變:在 C 裡,記憶體安全與型別安全是你「靠小心去設法維持」的東西;在 Rust 裡,它們是編譯器在安全程式碼中「拒絕讓你違反」的東西。代價是一個更嚴格的編譯器與一道真實的學習曲線;收穫是一整類凌晨三點的線上崩潰,在你程式的安全部分根本不可能發生。這筆交易值不值,取決於你在打造什麼——而現在,你已經握有足夠的真實圖像,能自己判斷,而不是靠炒作。
從這裡起,這一階變得具體。下一篇會把所有權與移動語意完整拆開——「移動」對位元組究竟做了什麼、為什麼一個被移走的值不能再用、以及擁有者的離場如何觸發清理。其後是借用與借用檢查器的深入講解,再來是用 Option 與 Result 處理空值與「以值傳遞的錯誤」,最後是 Cargo,連同一張把每個 C 概念對應到其 Rust 對手的對照表。你來到這一階時,已經懂了這個病;剩下的,是對解藥及其副作用的一趟仔細巡禮。