4.2 所有權的三大規則#
所有權(ownership)是 Rust 記憶體安全模型的核心概念。它不是一條又一套孤立的規則,而是三條規則構成的一個完整系統;理解這三條規則,等於理解 Rust 與其他語言在資源管理上的根本分歧。所有權的問題起源於 4.1 節留下的那個難題:堆積上的記憶體必須有人負責釋放,而「有人」必須在編譯期就能確定。
Rust 的答案非常簡潔:每個值都有一個擁有者,擁有者就是一個變數。變數離開作用域時,它擁有的值就會被丟棄(drop)。這個設計把「資源釋放」這件事從執行期的隱性行為,徹底搬到了編譯期就能推理的靜態結構上。
規則一:每個值都有一個擁有者#
第一條規則宣告了值的歸屬關係。當你用 let 宣告一個變數時,那個變數就成為該值的擁有者。這個歸屬是唯一的:同一個值在同一時間只會有一個擁有者,不存在「多個擁有者共同承擔釋放責任」的情況。
fn main() {
let s1 = String::from("hello");
println!("s1 的擁有者是 s1,長度為 {}", s1.len());
}
這裡 s1 是 String 值的擁有者。當 s1 離開 main 的作用域時,它所擁有的 String 會被丟棄,其堆積緩衝區被歸還給配置器。整個過程不需要呼叫 free,也不需要任何垃圾收集器的介入。
這條規則看似平淡,卻是三條規則的基石。因為擁有者是唯一的,Rust 才能在編譯期斷言「這個值只會被釋放一次」,從而排除重複釋放(double free)與 use-after-free。所謂「唯一擁有者」並不限制值被使用的方式,你可以把它借給別人、可以把它放進集合,甚至可以把它送到另一個執行緒,唯一不能做的就是讓兩個變數同時以擁有者的身分存在。
規則二:同一時間只能有一個擁有者#
第二條規則規定,在同一時間點,一個值只能被一個擁有者持有。這條規則的實際效果,是在所有權發生轉移(move)時,讓原本的擁有者立刻失效。
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("s2 的長度是 {}", s2.len());
// println!("{}", s1); // 取消註解後編譯失敗:E0382,值已被移動
}
let s2 = s1; 這一行執行的是「移動」而非「複製」。堆積上的緩衝區只有一份,所有權從 s1 轉移到 s2,而 s1 從這一行之後就失效了。若嘗試再讀取 s1,編譯器會報出 error[E0382]: borrow of moved value: 's1',並在註解中指出值被移動到的位置。
這個設計解決了 4.1 節提出的那個兩難。如果允許兩個擁有者,那麼 s1 與 s2 離開作用域時都會嘗試釋放同一塊緩衝區,造成重複釋放;如果允許「只有一個會釋放」,那麼必須在執行期記錄是誰,而這正是垃圾收集器要解決的問題。Rust 選擇了第三條路:讓所有權在編譯期就沿著一條單向的路徑流動,讓釋放責任永遠只落在路徑的末端。
值得注意的是,這條規則並不只影響堆積資料。Copy 型別如 i32 在賦值時同樣遵循「轉移所有權」的語法模型,只是因為它們的 Copy 實作讓編譯器在複製之後仍保留原變數的有效性,從使用者的觀點看就像單純的複製。
規則三:擁有者離開作用域時值被丟棄#
第三條規則描述了釋放的時機。當擁有者變數離開它所宣告的作用域時,編譯器會在該處插入對 drop 的呼叫,值隨之被丟棄。對於堆疊上的型別,這個動作幾乎是免費的;對於 String 這類持有堆積資源的型別,drop 會一路呼叫到配置的釋放邏輯。
fn main() {
{
let inner = String::from("inner");
println!("inner 的長度是 {}", inner.len());
}
println!("inner 已經離開作用域,其緩衝區已被釋放");
let outer = String::from("outer");
std::mem::drop(outer);
println!("outer 已被提早釋放");
}
這條規則的重要性在於「確定性」。Java 的 finalize 可能在任何不可預期的時機被呼叫,Python 的垃圾收集時機取決於參考計數與循環偵測器的週期,而 Rust 的 drop 一定發生在離開作用域的那一刻,而且是精確到陳述句的位置。這種確定性讓 Rust 能夠安全地管理檔案句柄、資料庫連線與鎖,而不必依賴「最終一定會被釋放」的僥倖。
值得留意的是,變數被遮蔽(shadowing)並不會觸發 drop。下面這段程式碼中,第二個 x 是同一個作用域內的新變數,第一個 x 會在遮蔽生效的當下被丟棄,其堆積緩衝區立刻被釋放。
fn main() {
let x = String::from("first");
println!("第一次宣告的 x:{}", x);
let x = String::from("second");
println!("遮蔽後的 x:{}", x);
}
String 與 &str 的轉移語意#
理解 String 與 &str 的差異,是理解所有權操作的前提。String 是擁有者,持有堆積緩衝區並負責釋放;&str 是借用者,只是指向某處的一段 UTF-8 文字,不負責釋放,也無法比它所借用的資料活得更久。
兩者的互相轉換,就是所有權與借用的轉換。呼叫 &s 會把一個 &String 自動轉型成 &str,這個過程是零成本的,只是把「指標加長度」的描述改成同樣形狀的另一個描述。反過來,s.clone().as_str() 則是一次明確的深複製。
fn main() {
let owned = String::from("hello");
let borrowed: &str = &owned;
println!("borrowed 的長度是 {}", borrowed.len());
println!("owned 仍然可用,長度是 {}", owned.len());
}
這段程式碼展示了借用的兩個性質:被借用的值仍然完整可用,而借用者不承擔釋放責任。這與 let s2 = s1; 形成鮮明對比——在那個案例中 s1 失效了,在這個案例中 owned 依然有效。
規則的直覺:把釋放責任綁到作用域#
三條規則的背後,其實只有一個設計意圖:把「資源的釋放責任」綁定到一個變數的作用域上。當變數離開作用域,資源必然被釋放,中間沒有任何分支、沒有任何例外路徑可以繞過。這等於在程式碼裡畫出一張靜態的資源生命週期圖:每一個值從被配置到被釋放之間的經過的路徑,在編譯期就完全確定。
這張圖是單向的。值可以從一個擁有者流向另一個擁有者,但不會分岔成兩條並行的持有路徑,也不會繞成環。這與垃圾收集器所面對的困難形成鮮明對比:GC 必須在執行期維持一張可達性圖,處理循環參考、處理弱參考的時機、處理回收時機與程式碼中其他邏輯的交互。Rust 把這些問題全部從執行期移除了,代價是程式碼必須服從所有权的形狀。
值得注意的是,Rust 並不禁止「同一份資料被多人看到」,它只是禁止「同一份資料被多人擁有」。多人讀取是安全的,多人同時寫入則是不安全的,而所有權系統只把「寫入」這件事收斂成獨佔的樣態。這樣的分工讓規則既嚴格又實用:常見的「呼叫端把資料借給幾個函式讀一讀」寫法完全不受阻礙,只有真正需要修改的場合才需要付出獨佔的代價。
Copy 與 Clone 的分工#
Copy 與 Clone 兩個特性經常被混淆,但它們處理的問題不同。Copy 表示「這個型別的複製是廉價且無副作用的」,因此編譯器在賦值與傳參時自動做位元複製,並允許原變數繼續使用。Clone 則是顯式的深複製,呼叫者必須主動要求。
Copy 只能套用在「沒有堆積資源」的型別上。判斷準則很簡單:型別的複製如果只是把一段位元組搬到別處,就應該是 Copy;如果複製會產生第二份需要獨立釋放的資源,那它就必須是移動而非複製。i32、f64、char、bool 與固定長度陣列屬於前者,String、Vec<T> 與 Box<T> 屬於後者。
#[derive(Clone, Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let a = Point { x: 1, y: 2 };
let b = a; // Point 沒有實作 Copy,這是移動
// println!("{:?}", a); // 取消註解後編譯失敗:E0382
let c = b.clone(); // 明確的深複製,b 仍然可用
println!("{:?} {:?} {} {}", b, c, b.x, b.y);
}
如果一個結構體裡含有 String 欄位,它就無法實作 Copy,因為複製它會導致兩個結構體都想釋放同一塊緩衝區。反過來,若一個結構體的所有欄位都實作了 Copy,那麼它自身也可以實作 Copy。編譯器會用 E0184 拒絕那些「含有非 Copy 欄位卻宣告 Copy」的結構體,正是這條規則的體現。
函式參數的所有權轉移#
所有權在函式邊界上同樣遵循規則二。把 String 傳進函式就是轉移所有權,函式取得擁有者,並在函式返回時負責釋放。
fn consume(s: String) -> usize {
println!("consume 持有 s,長度是 {}", s.len());
s.len()
}
fn borrow(s: &str) -> usize {
println!("borrow 只是借用 s,長度是 {}", s.len());
s.len()
}
fn main() {
let s = String::from("hello");
let n = borrow(&s);
println!("borrow 之後 s 仍然可用,長度是 {}", s.len());
let m = consume(s);
println!("consume 之後 s 不可用,consume 回傳 {}", m);
println!("n = {}", n);
}
borrow 的簽章是 &str,因此傳入 &s 只是建立一個借用,s 在呼叫前後都保持有效。consume 的簽章是 String,因此傳入 s 是所有權轉移,呼叫之後 s 失效。實務上的通則是:若函式只讀取資料,簽章一律使用 &str 或 &[T];只有當函式需要修改並取走資料時,才使用 String 或 Vec<T>。
自訂 Drop:讓資源釋放可以客製#
規則三並不限定釋放的行為。Drop 特性允許型別自行定義「被丟棄時要做什麼」,而編譯器保證這個函式恰好被呼叫一次,且在變數離開作用域時自動執行。對於檔案、鎖與連線這類資源,這是讓 Rust 風格延伸到自訂型別上的機制。
struct Resource {
name: String,
}
impl Resource {
fn new(name: &str) -> Resource {
println!("取得資源:{}", name);
Resource { name: name.to_string() }
}
}
impl Drop for Resource {
fn drop(&mut self) {
println!("釋放資源:{}", self.name);
}
}
fn main() {
{
let _r = Resource::new("檔案句柄");
println!("正在使用資源");
}
println!("作用域結束,資源已釋放");
}
drop 的實作在這裡被呼叫的時機,完全由作用域決定。實務上應盡量不要在 drop 中呼叫 std::mem::drop 自身,那會造成遞迴;也不要在 drop 中把借用轉成擁有,這條規則會在編譯期被擋下。
把 String 從函式還原#
一個能把資源還原給呼叫者的完整範例,可以把所有權概念串在一起。這個函式接受一個 String 並產生出兩個:一個是新產生的字串,另一個是從原字串切出的片段。
fn split_prefix(s: String) -> (String, String) {
let bytes = s.as_bytes();
let mut cut = 0;
for (i, &b) in bytes.iter().enumerate() {
if b == b' ' {
cut = i;
break;
}
}
let rest = s[cut..].to_string();
let head = s[..cut].to_string();
(head, rest)
}
fn main() {
let original = String::from("hello world");
let (head, rest) = split_prefix(original);
println!("head = {}", head);
println!("rest = {}", rest);
}
這裡 split_prefix 以值接收 s,所以呼叫端傳入時所有權就轉移進去了,函式結束後原字串已經被釋放。函式內部則透過 to_string() 製造兩份新的堆積緩衝區,並以元組把它們一併還給呼叫端。此處的 s[cut..] 借用寫法,正是第 4.4 節要深入討論的切片語法。
記憶體配置與釋放的時機#
把配置與釋放放在一起觀察,可以得到一條清楚的時間線。String::from 呼叫配置器取得緩衝區,這是配置的那一刻;所有權沿著變數與函式呼叫一路流動,這是持有關係改變的過程;最後當擁有者離開作用域,drop 被呼叫,這是釋放的那一刻。
以一個完整的主函式為例,時間線是這樣的:進入 main 建立堆疊框架,String::from 在堆積配置緩衝區並把描述資訊放在堆疊上的區域變數中。變數宣告之後的程式碼都持有這個值,直到 main 結束。當 main 的作用域收尾,變數被丟棄,緩衝區回到配置器的空閒清單。整個過程中沒有任何一次執行期的「垃圾偵測」,也沒有任何一次由程式設計師手動呼叫的釋放。
fn main() {
let a = String::from("第一份"); // 配置
let b = a; // 轉移,不配置
let c = b.clone(); // 深複製,再配置一份
println!("{} {}", b.len(), c.len());
// 作用域結束:c 與 b 各釋放一份緩衝區
}
最後一行值得注意:因為 c 是獨立複製出來的緩衝區,b 與 c 各自擁有一份資源,離開作用域時各自釋放一次。若沒有 clone,整個過程只會有一份緩衝區,也就只需要釋放一次。所有權的設計讓「記憶體用了幾份」與「需要釋放幾次」這個問題永遠不需要人工追蹤。
常見的所有權陷阱#
初學者最常遇到的困惑,是把「擁有」與「使用」混為一談。以下幾種寫法在別的語言中很自然,在 Rust 中卻會被編譯器擋下。
第一種是「已經給別人用了,卻還想繼續用」。這正是 E0382 涵蓋的情況,修正方式不是硬加 clone,而是先想清楚語意上是否真的需要兩份資料。
第二種是「把借用當成擁有來傳遞」。當函式簽章是 String 而呼叫端手上只有 &str 時,編譯器會要求提供 String。這時可以讓簽章改成 &str,這通常也是更好的設計。
第三種是「在迴圈中反覆複製大資料」。for item in vec 會自動取得迭代器;而 for item in &vec 才是在借用。寫法上的細微差異,會決定是否產生額外的配置。
第四種是「在函式間傳遞時不必要地轉移」。如果一個函式只是要讀取內容,把它寫成 fn f(s: &str) 幾乎永遠比 fn f(s: String) 更好,因為前者允許呼叫端繼續保有所有權。
fn take_ownership(s: String) -> String {
println!("取得 {}", s);
s
}
fn main() {
let s = String::from("hello");
let s2 = take_ownership(s); // 轉移
let s3 = take_ownership(s2.clone()); // 複製後再轉移,s2 仍然可用
println!("{} {}", s2, s3);
}
所有權與多態#
所有權與型別系統並非互不相干。Box<dyn Trait> 是兩者結合的關鍵:它把一個胖指標放在堆疊上,指標指向堆積上的具體型別,而 dispatch 由 vtable 在執行期完成。擁有權讓這個值有明確的釋放者,動態派發讓呼叫端不必知道具體型別,兩者結合就得到了 Rust 的多型容器。
trait Shape {
fn area(&self) -> f64;
}
struct Circle(f64);
struct Square(f64);
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.0 * self.0
}
}
impl Shape for Square {
fn area(&self) -> f64 {
self.0 * self.0
}
}
fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle(1.0)), Box::new(Square(2.0))];
println!("總面積 = {}", total_area(&shapes));
}
這段程式碼中,Vec<Box<dyn Shape>> 這個值是唯一的擁有者,total_area 取得的 &[Box<dyn Shape>] 只是借用,因此在函式返回後形狀仍然存在並能被再次使用。Box<dyn Trait> 的細節會在第 10 章展開。
所有權與並行#
所有權與執行緒安全的關聯更為直接。Rust 的執行緒 API 刻意要求跨執行緒傳遞資料時轉移所有權:std::thread::spawn 接受一個 FnOnce(),捕獲的 String 會被移動到新執行緒中。舊執行緒因此不再持有該資源,新執行緒則獨佔它。這不是額外的規則,而是所有權規則在並行環境下的自然延伸——轉移所有權等同於完成一次安全的跨執行緒交接。
第 12 章會說明 Send 與 Sync 兩個標記 trait 如何判斷型別能否跨執行緒,以及當多個執行緒確實需要共用同一份資料時,應該如何選擇 Arc、Mutex 或 RwLock。核心原則不變:共享的代價是同步,而同步的前提是所有權不能獨占。
flowchart LR
A["s1: String(擁有者)"] -->|"let s2 = s1,轉移"| B["s2: String(擁有者)"]
A -.->|"此後失效,E0382"| X["不可再使用"]
B -->|"離開作用域,drop"| C["堆積緩衝區釋放"]
D["借用 s2:借用者"] -->|"只讀,不負責釋放"| B三大規則的整體圖景#
三條規則合起來構成一張靜態的資源生命週期圖。規則一保證每個資源都有歸屬,規則二保證歸屬不會分裂,規則三保證歸屬在離開作用域時一定收尾。在這張圖上,值的流向是一條有起點與終點的路徑,沒有分岔,也沒有迴圈。所有權之外的借用機制,則允許在不轉移歸屬的情況下暫時讀寫資源——下一節將完整討論它的規則。
| 規則 | 語意 | 對應的記憶體效果 | 常見錯誤碼 |
|---|---|---|---|
| 每個值有一個擁有者 | 歸屬唯一 | 釋放責任只有一份 | — |
| 同一時間只有一個擁有者 | 轉移使舊擁有者失效 | 排除重複釋放 | E0382 |
| 擁有者離開作用域時丟棄 | 確定性釋放 | 杜絕記憶體洩漏 | — |
把這三條規則記在心中,Rust 程式設計的許多選擇就會有跡可循。為什麼函式預設以引用接收參數?因為借用比轉移更靈活。為什麼 Vec 的 push 需要 &mut self?因為可變借用必須是唯一的。所有權不是一套額外的語法糖,它是整個 Rust 記憶體模型的組織原則。