跳轉到

1.2 所有權、借用與生命週期#

三條規則#

Rust 的記憶體模型只靠三條編譯期規則:

  1. 每個值有唯一的所有者(owner)。
  2. 同一時刻,值要嘛有一個可變借用 &mut T,要嘛有任意多個不可變借用 &T,不能同時。
  3. 所有者離開作用域時,值被丟棄(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 邊界會再談)。

習題#

  1. 寫一個持有 Vec<String> 的結構體,實驗:把元素 drain 出來之後再 push,觀察借用檢查器的錯誤並解釋。
  2. 解釋為什麼 &mut T 不實作 Copy,而 &T 可以實作 Copy。
  3. 把上面的 Fd 改成包裝 File(std 已內建 Drop),並驗證關檔時機(加 eprintln 或 strace)。