1.2 所有權、借用與生命週期#
三條規則#
Rust 的記憶體模型只靠三條編譯期規則:
- 每個值有唯一的所有者(owner)。
- 同一時刻,值要嘛有一個可變借用
&mut T,要嘛有任意多個不可變借用&T,不能同時。 - 所有者離開作用域時,值被丟棄(drop),資源立刻釋放(RAII)。
這三條規則分別消滅了 C 的三大問題:規則 1+3 消滅重複釋放與遺漏釋放(誰擁有誰釋放,編譯器自動插入 drop,等價於編譯期展開的 C++ destructor,但強制不可遺漏);規則 2 消滅 use-after-free(借用失效後再用會編譯失敗)與資料競爭的候選(兩個寫入者不能同時存在)。
歷史上,規則 3 就是 C++ 的 RAII(Bjarne Stroustrup,1980 年代中期,為了解決例外拋出時資源外洩而設計的「Resource Acquisition Is Initialization」);規則 1 的「移動語意」在 C++11(2011)也成為一等公民,但 C++ 移動之後原物件仍可使用(moved-from state),Rust 則讓被移動的值直接從型別系統上消失——這是關鍵差異。
移動(move)與複製(copy)#
fn main() {
let s1 = String::from("linux");
let s2 = s1; // 移動:堆積記憶體不複製,s1 失效
// println!("{s1}"); // 編譯錯誤 E0382
let n1: i32 = 42;
let n2 = n1; // i32 實作 Copy,逐位元複製,n1 仍可用
println!("{n1} {n2}");
let a = [1, 2, 3];
let b = a; // [i32; 3] 也是 Copy
println!("{a:?} {b:?}");
}
String 內含指標指向堆積,逐位元複製會造成兩個指標指向同一塊、解構時 double free——所以 Rust 定義:凡需要釋放資源的型別,賦值即移動。i32 這種純堆疊值實作 Copy trait,表示「複製是安全的且沒有 destructor」。
借用:不取得所有權的使用#
每次傳參數都移動太麻煩,於是有借用:
fn longest(s1: &str, s2: &str) -> &str {
if s1.len() >= s2.len() { s1 } else { s2 }
}
fn main() {
let s = String::from("kernel");
let r = longest(&s, "user");
println!("{r}"); // s 依然可用
}
借用檢查器(borrow checker,NLL「非詞法作用域生命週期」版本於 2018 年 Rust 1.31 落地,由 Niko Matsakis 主導)在編譯期畫出每個引用的存活區間,確保:
- 引用存活期間,被引用的值沒有被移動或丟棄;
- 沒有同時存在
&mut與其他借用(aliasing XOR mutability)。
第二條不只是防 use-after-free,更是給最佳化器極強的保證:只要有一個 &T,編譯器知道沒有程式碼能同時寫入,可以放心做 C 編譯器因 restrict 缺失而不敢做的最佳化。這是 Rust 能「零成本抽象」的原因之一。
生命週期標註#
當函式回傳引用,編譯器需要知道回傳值受哪些輸入生命週期約束:
// 'a 表示:回傳引用的存活期 = 兩個輸入生命週期的交集
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() >= s2.len() { s1 } else { s2 }
}
結構體持有引用時也一樣:
struct Reader<'fd> {
fd: std::os::unix::io::RawFd,
_marker: std::marker::PhantomData<&'fd ()>,
}
本書後面會大量看到這種寫法:生命週期把「這個 fd/借用有效期有多長」寫進型別。例如 libc 的 epoll_event 內含使用者資料指標,自己包裝時用生命週期與所有權設計,就能保證「註冊到 epoll 的緩衝區在事件回來之前不會被釋放」——這正是 C 的 epoll 程式最常出事的地方(第 3.2 節實作)。
Drop:編譯期的系統呼叫排程器#
Drop trait 讓所有權結束時自動執行清理,這對系統程式意義重大——fd 不會漏關、鎖不會漏解:
struct Fd(i32);
impl Drop for Fd {
fn drop(&mut self) {
unsafe { libc::close(self.0) };
eprintln!("fd {} closed", self.0);
}
}
fn main() {
let f = Fd(unsafe { libc::open("/etc/hostname\0".as_ptr() as *const _, 0) });
// ...中間任何 early return / panic / ? 錯誤傳播
// fd 都會在作用域結束時關閉
}
對照 C 的經典 bug:open 之後在中途 return -1 忘記 close,長時間執行的 daemon 最後 fd 用盡(EMFILE)。Rust 用型別系統把「資源取得」與「資源釋放」綁定成同一個物件的生命週期。
編譯器怎麼做到不花執行期成本#
所有權檢查完全發生在編譯期,執行檔裡沒有「所有權檢查器」。移動就是 memcpy(或根本被最佳化掉),drop 就是內聯的解構呼叫。因此 Rust 產生的機器碼與等價 C 相當——#[no_std] 的環境下甚至能寫作業系統核心(第 1.3 節的 unsafe 邊界會再談)。
習題#
- 寫一個持有
Vec<String>的結構體,實驗:把元素drain出來之後再 push,觀察借用檢查器的錯誤並解釋。 - 解釋為什麼
&mut T不實作Copy,而&T可以實作Copy。 - 把上面的
Fd改成包裝File(std 已內建 Drop),並驗證關檔時機(加 eprintln 或 strace)。