目前各種程式語言常見的記憶體管理機制主要有兩大類:手動或是垃圾回收機制(Garbage Collector)。
例如 C / C++ 就是用手動的方式分配 (malloc) 和釋放 (free) 記憶體。對工程師來說可以彈性控制的同時,也考驗開發者的實力,忘記釋放記憶體會造成記憶體洩漏(Memory leak),重複釋放可能會把正在其他地方使用的資料弄亂,如果已經釋放了卻又誤用原本的指標可能會引發空指標錯誤(Null Pointer Error),造成程式崩潰。
JavaScript / Go 就是用 Garbage Collector(GC) 的機制,各自有不同的實作。因為記憶體的管理變成像是自動化,開發者可以更專注在程式邏輯,不過普遍來說無法避免影響性能和空間使用效率,在即時性要求高的系統中,如遊戲或金融交易系統,或是在某些需要精細記憶體管理的應用中,比如在嵌入式系統或其他資源受限的環境,會被凸顯出來。
為了解決手動管理記憶體時常見的記憶體洩漏、空指標錯誤等問題,並避免 GC 帶來的性能損耗,Rust 創造了一套全新的記憶體管理機制:所有權(Ownership)。
所有權可以理解為 Rust 用來管理程式記憶體的一系列規則,編譯器會在編譯階段檢查程式碼是否符合這些規則。如果違反規則,程式將無法通過編譯。由於這些檢查僅在編譯時進行,不會影響程式執行時的效能,這也正是 Rust 所強調的零成本抽象。
在介紹所有權之前,需要先簡單知道一下 Stack 和 Heap,它們是不同的記憶體區域提供給程式碼在執行的時候使用,在 Rust 各自有不同的效能和行為,關係到我們選擇使用哪種型別、資料結構等。
Box
、Vec
、String
等,但因為管理比較複雜就容易導致記憶體碎片化。所以像u32
這樣的基本型別,因為大小固定,可以存放在 Stack 上,而像 String
這樣可變大小的資料,則必須存放在 Heap。
對 Stack 和 Heap 有基本認識後,我們繼續回到所有權。
首先像玩桌遊一樣我們先看所有權的規則:
前兩個舉一個例子一起看。
首先看正常的程式碼,宣告一個變數 x 賦值 0,這時候數值是存在 Stack 上,後面再把 x 賦值給 y 的時候其實是複製一份資料,所以後面 y 在做操作的時候就不會影響到 x 原本的數值。
最後把 x 和 y 印出來。
fn main() {
let x = 0;
let mut y = x;
y += 1;
println!("x: {}, y: {}", x, y);
}
再來我們用另外一個型別 String
,之前有提到它是可變的,可以動態地增長或縮減字符串內容。
fn main() {
let mut s = String::from("hello");
s.push_str(", world!"); // 將字面值加到字串後面
println!("{s}"); // hello, world!
}
然後我們做和上面類似的操作,編譯器就報錯了。
fn main() {
let s = String::from("hello");
let mut w = s;
w.push_str(", world!");
println!("s: {s}, w: {w}");
}
$ cargo run
error[E0382]: borrow of moved value: `s`
--> src/main.rs:6:18
|
2 | let s = String::from("hello");
| - move occurs because `s` has type `String`, which does not implement the `Copy` trait
3 | let mut w = s;
| - value moved here
...
6 | println!("s: {s}, w: {w}");
| ^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let mut w = s.clone();
| ++++++++
For more information about this error, try `rustc --explain E0382`.
訊息量很大,我們先整理幾個關鍵字:move、borrow、clone。
再來解釋一下發生了什麼事。
關鍵在於String
的字串資料不是存在 Stack 而是存在 Heap 上,Stack 上會存元數據(指標、長度、容量),實際的字串 hello
是存在 Heap 上,指標會指向這個位置,如果複製指標資料的話,這幾個指標都是字串資料的擁有者,就違反所有權同時間只能有一個擁有者的規則了,因此,當我們將 String
賦值給另一個變數時,實際會發生所有權的轉移(move)。而 i32
等基本型別則完全存放在 Stack 上,其拷貝開銷極小,Rust 會自動複製它們,每個變數擁有自己一份資料,因此不需要進行所有權轉移。
s 賦值給 w 的時候會把擁有者從 s 換成 w,這句之後 s 就變成是不可用的了,後面 println 的時候它想借用的 s 已經沒有所有權,所以這是一個無效的借用(borrow),Rust 禁止這樣的操作。
修改原本的程式碼把 s 從 println 拿掉,就可以正常執行。
fn main() {
let s = String::from("hello");
let mut w = s; // s 所有權給 w 了,後續無法再使用
w.push_str(", world!");
println!("w: {w}");
}
錯誤訊息的另外一個提示是可以用 clone。
fn main() {
let s = String::from("hello");
let mut w = s.clone();
w.push_str(", world!");
println!("s: {s}, w: {w}"); // s: hello, w: hello, world!
}
這樣也可以編譯成功,clone 如它字面意思,包含 Stack 和 Heap 的資料都複製一份獨立的資料,可以想成是 Javascript 的深拷貝(deep copy),因此兩個變數 s 和 w 各自有不同資料的所有權,在最後就都可以合法的 println 出來了。不過這樣做的代價就是根據 Heap 上的資料大小,多複製一份的動作可能會影響效率和效能,尤其資料越大影響越顯著。所以應該只在必要的地方使用,才能發揮出 Rust 的優勢。
最後舉一個例子來觀察所謂 擁有者離開作用域時,數值就會被丟棄
這句話。
在 Rust 中特徵(trait
) 是一種用來定義共同行為的抽象概念,可以被視為一組方法的集合,定義了一個型別必須實作的行為,但不提供具體的實作細節,目前理解到這樣即可。
首先定義一個結構體(struct),並實作 Drop
,Drop
特徵是 Rust 的一個預定義特徵,允許自定義在變數超出作用域時要執行的清理操作,所以我們可以把 drop
裡 println!
當成是釋放記憶體來觀察。實際 Rust 的資源管理模型和這個特徵無關,這個特徵只是記憶體釋放前的最後一步,所以不會影響到記憶體釋放機制本身。
struct MyStruct {
name: String,
}
impl Drop for MyStruct {
fn drop(&mut self) {
println!("Dropping MyStruct with name: {}", self.name);
}
}
fn main() {
let my_struct = MyStruct { // my_struct 在此開始視為有效,my_struct 是擁有者
name: String::from("Rust"),
};
println!("MyStruct created.");
// 在這裡,my_struct 仍然在作用域內,仍然有效
println!("MyStruct's name: {}", my_struct.name);
println!("main scope end");
// 當它超出作用域時,Drop trait 會被自動調用
} // 這裡 my_struct 超出作用域,會觸發 Drop trait
$ cargo run
MyStruct created.
MyStruct's name: Rust
main scope end
Dropping MyStruct with name: Rust
可以觀察到,drop
一直到大括號前的最後一行執行完才執行。
接著我們把上面的程式碼調整一下,把 my_struct 的部分另外用一組 {}
包起來再執行。
fn main() {
{
let my_struct = MyStruct {
name: String::from("Rust"),
};
println!("MyStruct created.");
println!("MyStruct's name: {}", my_struct.name);
} // my_struct 超出作用域
println!("main scope end");
}
$ cargo run
MyStruct created.
MyStruct's name: Rust
Dropping MyStruct with name: Rust
main scope end
會看到 drop
變成在 main scope end 之前執行了,因為它的作用域在那之前就已經結束了。
如以上例子,當變數超出作用域時,Rust 會自動調用 Drop 來釋放記憶體。這個機制讓我們不需要手動管理記憶體,並能保證不會發生記憶體洩漏。
Rust 的所有權機制不需要手動管理記憶體,同時也避免了垃圾回收的性能負擔。它透過編譯階段的檢查確保記憶體安全,讓開發者既能寫出高效的代碼,也不必擔心記憶體洩漏或其他相關錯誤。另外從上面的規則和例子可以看出 Rust 的一個重要設計決策:永遠不會自動將資料建立「深拷貝」。因此任何自動的拷貝動作都可以被視為是對執行效能影響很小的,這也是 Rust 高效能的基礎之一。
到這邊應該對所有權有基本的認識了,接下來進一步介紹所有權參考與借用(borrow)。