Rust 課程 – 所有權與借用
主題:所有權規則總結
簡介
在 Rust 中,所有權(Ownership) 是語言最核心的概念之一,也是保證記憶體安全、避免資料競爭的根本機制。相較於傳統的手動記憶體管理(如 C/C++ 的 malloc/free)或是垃圾回收(GC)語言,Rust 透過編譯期的所有權檢查,讓程式在執行前就能捕捉大多數的記憶體錯誤,幾乎不會在執行時產生 segmentation fault。
對於剛踏入 Rust 的開發者來說,所有權規則看起來有點抽象,但只要把它拆解成 三條簡單的規則,再配合實際的程式碼範例,就能快速掌握並在日常開發中活用。本文將以易懂的語言、完整的範例,一步步說明所有權的運作方式,並提供常見陷阱、最佳實踐與實務應用情境,幫助讀者從「概念」走向「實踐」。
核心概念
1. 所有權的三條基本規則
| 規則 | 說明 |
|---|---|
| 1️⃣ 每個值都有唯一的所有者 | 變數在宣告時會取得一個值的所有權,該值只能有 一個 活躍的所有者。 |
| 2️⃣ 所有者在離開作用域時自動釋放資源 | 當變數超出其作用域({})時,編譯器會自動呼叫 drop,釋放其所持有的記憶體。 |
| 3️⃣ 同時只能有一個可變借用或多個不可變借用 | 透過 借用(Borrowing),可以暫時取得所有權的參考。可變借用(&mut)只能有 一個,而不可變借用(&)則可有 多個,但兩者不能同時存在。 |
小結:只要遵守上述三條規則,編譯器就能保證程式不會出現「使用已釋放的記憶體」或「資料競爭」等問題。
2. 所有權的移動(Move)與複製(Copy)
2.1 移動(Move)
當一個值被賦給另一個變數時,所有權會被移動,原變數不再有效。以下範例展示 String(非 Copy 類型)的移動行為:
fn main() {
let s1 = String::from("Rust"); // s1 擁有 heap 上的字串資料
let s2 = s1; // 所有權從 s1 移到 s2
// println!("{}", s1); // 編譯錯誤:s1 已被移走
println!("{}", s2); // 正常印出 "Rust"
}
為什麼會錯?
String在記憶體上包含指向 heap 的指標、長度與容量。若編譯器同時保留兩個指標,drop時會嘗試釋放同一塊記憶體兩次,造成 double‑free 錯誤。Rust 透過「移動」避免此情況。
2.2 複製(Copy)
對於 位元長度固定且不涉及 heap 的類型(如整數、布林、字元),Rust 會自動實作 Copy trait,賦值時會 複製 而非移動:
fn main() {
let a: i32 = 42;
let b = a; // a 仍然有效,因為 i32 是 Copy 類型
println!("a = {}, b = {}", a, b); // 皆可使用
}
注意:如果自訂結構想要支援
Copy,必須確保其所有欄位皆實作了Copy,且手動加上#[derive(Copy, Clone)]。
3. 借用(Borrowing)與引用(Reference)
3.1 不可變借用
fn print_len(s: &String) {
// 只讀取,不會改變 s
println!("長度是 {}", s.len());
}
fn main() {
let text = String::from("所有權");
print_len(&text); // 傳遞不可變引用
// 仍然可以在此處使用 text
println!("原始字串:{}", text);
}
&String為 不可變引用,允許同時存在多個。- 只要沒有可變借用,原所有者仍可讀取或傳遞其他不可變引用。
3.2 可變借用
fn add_exclamation(s: &mut String) {
s.push('!');
}
fn main() {
let mut msg = String::from("Hello");
add_exclamation(&mut msg); // 可變借用
println!("{}", msg); // => "Hello!"
}
&mut String為 唯一的可變引用,在其作用域內 不能 同時存在其他任何引用(可變或不可變)。- 必須把變數宣告為
mut,才能取得可變借用。
3.3 同時不可變與可變借用的錯誤
fn main() {
let mut data = vec![1, 2, 3];
let r1 = &data; // 不可變借用
// let r2 = &mut data; // 編譯錯誤:同時存在不可變與可變借用
println!("{:?}", r1);
}
編譯器會給出類似 cannot borrow data as mutable because it is also borrowed as immutable 的錯誤訊息,提醒開發者違反規則。
4. 作用域(Scope)與 drop
Rust 會在變數離開作用域時自動呼叫 drop 釋放資源。下面的範例示範了何時會觸發 drop:
fn main() {
{
let s = String::from("短暫的字串");
println!("{}", s);
} // <-- s 在此離開作用域,記憶體被釋放
// 這裡已無法使用 s
}
如果想要提前釋放,可以手動呼叫 std::mem::drop:
use std::mem;
fn main() {
let big = String::from("大量資料");
// 先使用 big
println!("使用前長度 {}", big.len());
// 提前釋放
mem::drop(big);
// println!("{}", big); // 編譯錯誤:已被 drop
}
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
忘記 mut |
想要可變借用卻忘記在變數前加 mut,編譯錯誤訊息往往不直觀。 |
先檢查變數宣告,確保需要修改的變數使用 let mut。 |
| 同時持有可變與不可變引用 | 在同一作用域內混用 & 與 &mut,會觸發編譯錯誤。 |
縮小借用範圍:使用額外的區塊 {} 或將程式碼拆成函式,使借用在不同階段結束。 |
將 String 移動後仍想使用 |
移動 (let b = a;) 後仍使用 a,會產生「use of moved value」錯誤。 |
若需要保留原值,使用 clone:let b = a.clone();(注意成本)。 |
| 過度 clone | 為了避免所有權問題而大量使用 clone(),會造成不必要的記憶體與效能開銷。 |
優先考慮借用,僅在確實需要擁有所有權且成本可接受時才 clone。 |
| 長時間持有可變借用 | 可變借用若跨越太大範圍,會阻止其他不可變借用,導致編譯失敗。 | 使用作用域縮小或 RefCell(在需要動態檢查的情況下)。 |
最佳實踐小結
- 先借後所有:盡量以不可變借用 (
&) 讀取資料,只有在真的需要修改時才使用可變借用 (&mut)。 - 縮小借用範圍:利用
{}區塊或函式把借用的生命週期限制在最小範圍內,讓編譯器更容易驗證。 - 避免不必要的 clone:
clone()會深拷貝 heap 資料,成本高。若只是暫時需要讀取,使用引用即可。 - 利用編譯器訊息:Rust 的錯誤訊息非常詳細,遇到所有權相關錯誤時,先閱讀訊息再調整程式碼。
實際應用場景
1. 文字處理與檔案 I/O
在讀取檔案內容後,通常會把資料放入 String 或 Vec<u8>。若只需要檢查或搜尋關鍵字,使用不可變借用 能避免不必要的所有權轉移:
use std::fs;
fn contains_keyword(content: &str, kw: &str) -> bool {
content.contains(kw)
}
fn main() -> std::io::Result<()> {
let data = fs::read_to_string("log.txt")?; // data 擁有所有權
if contains_keyword(&data, "ERROR") {
println!("發現錯誤訊息");
}
// data 仍然可以在此使用,例如寫入其他檔案
Ok(())
}
2. 多執行緒共享資料
在多執行緒環境下,所有權無法直接跨執行緒傳遞(除非使用 Send)。最常見的做法是把資料放入 Arc<T>(原子參考計數)並配合 Mutex<T> 取得可變借用:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let cnt = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = cnt.lock().unwrap(); // 取得可變借用
*num += 1;
});
handles.push(handle);
}
for h in handles {
h.join().unwrap();
}
println!("最終計數 = {}", *counter.lock().unwrap());
}
此模式遵循所有權規則:Arc::clone 產生 引用計數的所有權,而 Mutex::lock 在執行期間提供唯一的 可變借用。
3. API 設計:傳入所有權 vs. 傳入引用
- 傳入所有權:適合需要 消費(consume)參數、或在函式內部建立長期資源的情況。例如
fn into_bytes(self) -> Vec<u8>。 - 傳入引用:適合 只讀 或 短暫修改,不想讓呼叫端失去所有權。例如
fn print_summary(&self)。
合理的 API 設計能讓使用者更清楚何時需要 clone()、何時只需要 &,從而提升效能與可讀性。
總結
- 所有權 是 Rust 記憶體安全的根本,遵守「唯一所有者」與「作用域自動釋放」兩大規則即可避免大多數的記憶體錯誤。
- 借用 讓我們在不取得所有權的前提下安全地讀取或修改資料;不可變借用 可多個、可變借用 必須唯一。
- 移動 與 複製 的差異決定了資料在賦值時是「轉移」還是「拷貝」;了解何時會自動實作
Copy,何時需要手動clone(),是寫出高效程式的關鍵。 - 常見的所有權陷阱往往來自於 作用域、mut 宣告與 借用衝突,透過縮小借用範圍、善用編譯器訊息與遵守最佳實踐,可大幅降低錯誤率。
- 在實務開發中,從 文字處理、檔案 I/O、到 多執行緒共享,所有權與借用的概念無所不在,正確運用能讓程式更安全、效能更好、維護成本更低。
掌握了上述的所有權規則與借用模型,你就能在 Rust 的世界裡寫出 安全且高效 的程式碼,從基礎走向進階,開發出可靠的系統與應用。祝你在 Rust 的旅程中玩得開心、寫得順手! 🚀