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

所有權與移動語意

在 C 裡,是你決定每塊區塊由誰釋放,而你把這個決定記在腦中;弄錯了就洩漏或重複釋放。Rust 把這個決定變成一條由編譯器追蹤的規則:每個值剛好有一個擁有者,而指派或傳遞它會「移動」所有權,而不是悄悄造出第二個擁有者。一旦你看清移動是怎麼運作的,「沒有 free() 卻自動 free()」就不再像魔法了。

C 從不回答的問題:這塊由誰釋放?

上一篇許下了承諾:Rust 在編譯期就防止釋放後使用、重複釋放與洩漏,而且不靠垃圾回收。本篇要兌現這個承諾,方法是解釋其餘一切所建立其上的那個唯一概念。先從一個你在 C 裡回答過上百次、卻總是非正式回答的問題開始。當你呼叫 malloc() 拿回一塊區塊時,誰負責對它呼叫 free()?C 語言並不記錄答案。那塊區塊不過是一個位址;釋放它的責任住在你腦中、住在一行註解裡、也許住在某個函式的文件裡——就是不住在型別裡。如果程式裡有兩個部分都認為自己擁有這塊區塊,它們就都去釋放它,於是你有了重複釋放;如果兩邊都不這麼認為,就沒人去釋放它,於是你有了記憶體洩漏

優秀的 C 程式設計師靠紀律處理這件事。他們對誰擁有一份配置維持著堅定的慣例:一個叫 `make_widget` 的函式名在說「我回傳一塊你現在擁有、且必須釋放的區塊」;一個叫 `widget_size` 的函式名在說「我只是借用你的指標,我不會釋放它」。這份紀律是真實的,而且管用——直到某次重構把一個 free() 搬到了邊界的錯誤一側、或某條提早的 `return` 跳過了清理、或兩條錯誤路徑都決定要釋放同一個緩衝區。那份慣例從來沒有被任何東西檢查過。它是人與人之間的承諾,而人會忘。

Rust 的做法,就是把這個非正式的觀念——「誰負責釋放這塊」——從一個慣例提升為一條編譯器在每一行都追蹤的規則。這條規則叫做所有權(ownership),而它刻意地狹窄:它不規定誰「可以讀取」一個值(許多地方都可以,下一篇會說明),只規定誰負責「清理它」。從一開始就把這個區分劃清楚,因為這正是初學者最常糊在一起的地方。

三行說完的所有權規則

整條規則塞得進三句話。第一,每個值剛好有一個擁有它的變數——也就是這個值所綁定到的變數。第二,任一時刻只能有一個擁有者;你絕不可能有兩個變數同時擁有同一個值。第三,當擁有者離開範圍——它所宣告於其中的那個區塊結束時——Rust 會自動清理這個值。這個清理叫做 drop,而對於像 `String` 這種背後有堆積的值,它做的事正是你在 C 裡會親手寫的:釋放堆積記憶體。你從來不必打出 free 那個字;擁有者範圍的結束「就是」那次釋放。

看看這替你買到了多少。因為剛好只有一個變數擁有這個值,記憶體被釋放的地點與時機也剛好只有一處——於是重複釋放在結構上就不可能:只有擁有者釋放,而且只在範圍結束時釋放一次。而因為每當一個擁有者離開範圍,編譯器就替你插入那次釋放,你也不可能因為純粹忘記而洩漏;「忘記」已不再是你做得到的事。這正是那位有紀律的 C 程式設計師靠命名慣例和小心的 `goto cleanup` 階梯所追求的同一個結果,只是現在由編譯器來保證,而不是託付給你。這套同樣的機制,就是決定性 drop的主題——它在程式碼中一個已知的點執行清理,而不是像垃圾回收那樣「之後某個時候」才做。

把它弄具體。在一個函式裡寫 `let s = String::from("hello");`,s 現在就擁有一個背後有堆積的字串;你可以自由地透過它讀取,例如用 `s.len()`。當函式的區塊在它的右大括號處結束時,s 離開範圍,編譯器執行 `drop(s)`,堆積記憶體被釋放——就在那個確切的大括號處、每一次都如此、而且任何地方都沒打出 `free()`。擁有者範圍的右大括號「就是」那次釋放。這就是 Rust 裡清理的全貌,而下一個概念——當你在那個大括號之前把 s 移到別處時會發生什麼——正是它變得有趣的地方。

指派是移動,不是複製

「唯一擁有者」規則聽起來很乾淨,直到你問出那個顯而易見的問題:程式無時無刻不在傳遞值。你做指派 `let b = a`、你傳引數、你回傳結果。如果每個值都必須剛好有一個擁有者,那麼當你寫 `let b = a` 的那一刻,所有權會怎樣?在 C 裡,複製一個指標只是造出第二個指向同一塊區塊的指標——於是兩個變數都相信自己在當家,而這正是孳生重複釋放的那個情境。Rust 不能在不打破自己規則的前提下允許這種事。它的答案是移動(move)。

這裡有個讓它豁然開朗的心智圖像。一個 `String` 是分處兩地的兩樣東西:一筆坐在堆疊上、大小固定的小記錄——存著指向字元的指標、一個長度、一個容量——以及真正活在堆積上的那些字元。當你寫 `let b = a` 時,Rust 把那筆「小小的堆疊記錄」複製進 b,但它不碰堆積上的字元,而且關鍵地,它把變數 a 標記為不再有效。現在 b 持有指向堆積資料的唯一指標——b 是唯一的擁有者——而 a 已死。再碰 a 一次,編譯器就冷冷地以 `error[E0382]: borrow of moved value: 'a'` 攔下你。所有權從 a「移動」到了 b。仍然剛好只有一個擁有者,所以堆積記憶體仍會在 b 的範圍結束時剛好被釋放一次。

the stack record copied; the heap NOT copied; source invalidated

  before  let b = a;            after  let b = a;
  -------------------            ----------------------
  a: [ ptr | len | cap ]         a: (invalidated -- use is an error)
        |                        b: [ ptr | len | cap ]
        v                              |
  heap: "hi"                           v
                                 heap: "hi"   (same bytes, one owner)

// MOVE = copy the small stack record + mark the source dead.
// Result: still exactly one owner, so still exactly one free.
移動只複製那筆小小的堆疊記錄並作廢來源;堆積位元組從不被複製、也不會被重複釋放。

傳入函式也會移動——以及如何把它要回來

把一個值傳入函式,和 `let b = a` 是同一件事,只是目的地換成了函式的參數。如果你呼叫 `greet(s)`、而 greet 以值(by value)接收一個 `String`,所有權就從 s 移出、移進 greet 的參數。當 greet 返回時,那個參數離開範圍、drop 執行、字串被釋放——所以呼叫之後,呼叫端的 s 就無效了。這幾乎讓每個人第一次都嚇一跳:你把你的 `String` 交給一個函式、本以為之後還能用它,編譯器卻告訴你它已經被移走了。這是規則誠實的後果,不是什麼怪癖:以值傳遞真的就是「你把它讓出去」。

你有三條誠實的出路,而在它們之間做選擇,佔了日常 Rust 的一大半。其一:如果這個函式真的應該消耗掉這個值(接收它並了結它),那就讓它移動進去,這是正確的。其二:如果這個函式應該把值還回來,就讓它回傳那個 `String`,於是所有權移出、再移回——笨拙,但管用、而且明確。其三,也是壓倒性常見的選擇:借出這個值、而不是給出它,方法是用 `&` 傳一個參考。那就是借用(borrowing),正是下一篇的主題,而它的存在,正是為了讓「讀取一個值」不必消耗掉它。

替它配上具體情節。假設 `greet` 宣告為 `fn greet(name: String)`——它以值接收一個 `String`——而你在 `let s = String::from("ada");` 之後呼叫 `greet(s)`。這次呼叫把所有權移動進 greet 的參數 name;當 greet 的本體結束時,name 被 drop、字串被釋放;而回到呼叫端,稍後一句 `println!("{s}")` 會以 `error[E0382]: borrow of moved value: 's'` 編譯失敗。把簽章改成 `fn greet(name: &String)`、改呼叫 `greet(&s)`,你就是借出了這個值、而不是交出它——s 仍是你的、呼叫之後還能用。從 `String` 到 `&String` 這一個小小的改動,正是通往下一篇的門。

Copy 型別:那個小小的、誠實的例外

如果 `let b = a` 永遠都移動並作廢 a,那麼對一個普通整數做 `let y = x` 就會讓 x 變得不能用——那將既荒謬又痛苦。所以有一個經過仔細劃界的例外。完全住在堆疊上、不擁有任何堆積資源的小型樸素值——像 `i32` 這樣的整數、浮點數、`bool`、`char`、以及只由這些東西組成的元組——實作了一個標記叫 Copy。對一個 Copy 型別而言,`let b = a` 會複製那些位元、並讓 a 完好可用,因為複製幾個堆疊位元組很便宜,也沒有單一的堆積資源會讓兩個擁有者去爭。沒有作廢、沒有移動;你就只是有了兩個各自獨立的值。

所以拇指法則是:若一個值擁有資源(一個 `String`、一個 `Vec`、一個檔案描述符),指派或傳遞它會「移動」它並作廢來源;若它是簡單的 Copy 值(一個 `i32`),它會被「複製」而來源繼續存活。誠實的微妙之處——而這會絆倒來自每一種其他語言的新手——在於:移動是預設,Copy 才是特例。多數語言在每次指派時悄悄複製、或悄悄共享;Rust 預設兩者都不做。它轉移的是責任,而只有那些便宜、不持有資源的型別才選擇退出、改採複製。

並排來看,這兩種行為對比鮮明。對 `let a = String::from("hi"); let b = a;` 而言,指派會移動:現在 b 擁有它、a 被作廢,而稍後一句 `println!("{a}")` 就是那個熟悉的 `error[E0382]: borrow of moved value: 'a'`。對 `let x = 5; let y = x;` 而言,指派會複製,因為 `i32` 是 Copy:位元被複製、x 仍可用,而 `println!("{x} {y}")` 開開心心地印出 `5 5`。同樣的語法 `let _ = _`、兩種相反的結果——而其差別完全在於這個型別是否擁有一份堆積資源。

誠實地說,為什麼這是地基

退一步,看看你現在握住的東西是什麼形狀。所有權指出誰對一個值負責;移動讓這份責任在值從變數傳到變數、再傳進函式的過程中始終保持單一;drop 則花掉這份責任——剛好一次、在一個已知的點——藉由釋放記憶體。它們合起來讓重複釋放在結構上不可能(一個擁有者、一次釋放)、讓「忘記造成的洩漏」不可能(範圍的結束總會釋放),並安排好了唯一剩下的問題:你要怎麼「讀取」一個值卻不消耗它?那個問題正是借用借用檢查器所要解決的,也就是接下來的兩篇。所有權是其餘一切賴以生長的土壤。

現在來談誠實的提醒,因為少了它們這篇就不誠實了。所有權對程式設計師而言並非免費——它是人們一開始抗拒得最厲害的功能,那份摩擦是真實的。在 C 裡能順利編譯(運氣好時還能順利執行)的程式碼,在這裡會被拒絕,而解法通常是重新組織「誰擁有什麼」,而不是跟編譯器爭論。所有權也並非防止每一個錯誤:它阻止釋放後使用、重複釋放、與忘記釋放,但它對邏輯錯誤無能為力,而且你仍然可以「故意」洩漏(一個參考環、或一次明確的洩漏)——Rust 防的是意外的洩漏,不是所有洩漏。而且這份保證只涵蓋安全的 Rust;在一個 `unsafe` 區塊內,你就回到了 C 式的責任。正如第一篇所堅持的,這是一組不同的取捨,而不是所有錯誤的終結。