有了前面關於字串、可變性、記憶體操作的前置知識,終於可以來講 Rust 的核心設計:所有權。
之前我們提到 C++ 使用 RAII 的機制來管理資源釋放問題,資源本身可以定義建構和解構函式,讓系統在變數離開作用域時自動呼叫其值定義的解構函式,讓工程師不必手動釋放資源。Rust 在記憶體管理的策略也是偏向 RAII,並且做得更嚴格。它除了把 RAII 做得更極致以外,還強調變數和值之間的擁有關係,藉此決定這些值的生命週期、何時該被釋放。
這說起來非常抽象,來看看具體的例子吧。我們先定義 Person
結構體,接著宣告一個 Person
並且賦值給 p1
:
#[derive(Debug)] // 給 Person 加上 Debug 特徵,使它能被 println! 印出
struct Person {
name: String,
height: f64,
weight: f64,
}
fn main() {
let p1 = Person {
name: String::from("Kyaru"),
height: 152.0,
weight: 39.0,
};
println!("{p1:?}");
}
這個時候我們可以說 p1
變數「擁有」這個 Person
的值。若我們接下來使用另一個變數 p2
,令其為 p1
的值,然後再試圖把 p1
印出:
let p1 = Person {
name: String::from("Kyaru"),
height: 152.0,
weight: 39.0,
}
let p2 = p1;
println!("{p1:?}");
你會馬上收到一個編譯錯誤:borrow of moved value: p1
。
實際上,我們在執行 p2
= p1
的時候,它真正的意思是「把 p1
擁有的值轉讓給 p2
」,這在 Rust 當中被稱為 move
。實際上,Person
的值沒有發生任何複製操作,它只是語義上被「移動」給了 p2
。當它被移動給 p2
以後,變數 p1
不再擁有任何值,你也就不能對 p1
做任何事了。
若你希望 p1
和 p2
各自擁有一個同樣值的 Person
,就必須給 Person
加上 Clone
特徵,呼叫 .clone()
方法複製出一個新的 Person
以後再交給 p2
:
#[derive(Debug, Clone)] // Clone 特徵使得 Person 能夠被複製
struct Person {
name: String,
height: f64,
weight: f64,
}
fn main() {
let p1 = Person {
name: String::from("Kyaru"),
height: 152.0,
weight: 39.0,
};
let p2 = p1.clone();
println!("{p1:?}");
println!("{p2:?}");
}
你會發現,無論是 p1
或 p2
的內容都能被印出來了。這裡的 clone()
其實就是把底下的 name
、height
、weight
各自複製一份,建成一個新的 Person
。現在,p1
和 p2
各自擁有的 Person
是相互獨立的個體,也就是說 p1
的 Person
被修改時,p2
的 Person
不會受到影響,反之亦然:
let mut p1 = Person {
name: String::from("Kyaru"),
height: 152.0,
weight: 39.0,
};
let mut p2 = p1.clone();
p1.weight = 50.5;
p2.weight = 60.7;
println!("{p1:?}"); // Person { name: "Kyaru", height: 152.0, weight: 50.5 }
println!("{p2:?}"); // Person { name: "Kyaru", height: 152.0, weight: 60.7 }
這個例子簡單易懂,但單單是這樣還不太能體現所有權和資源釋放之間的關聯。現在我們試著寫個函數,把字串傳進去:
fn say_hello(name_to_display: String) {
println!("Hello, {name_to_display}!");
}
fn main() {
let name = String::from("Kyaru");
say_hello(name);
println!("{name}");
}
若你想在呼叫完 say_hello
之後嘗試用 println!
把 name
的內容印出來,就又會遇到同樣的問題:borrow of move value name
。
沒有錯,這是因為在上面例子當中,呼叫函數的時候也發生了所有權的轉移:當我們呼叫 say_hello
並且把 name
傳進去的時候,這份字串的所有權也被轉給函數的參數 name_to_display
了。因為字串的所有權已經轉移到 say_hello
函數當中,所以根據 Rust 的機制,字串的生命週期會在 say_hello
函數執行完的時候一起結束(被系統自動釋放),那麼該字串當然就不再有效,也就不能再被後續的 println!
使用。
學習 Rust 之後,為了讓程式變得更嚴謹,你要思考的事情就不再那麼簡單了。
在上面的例子當中,我們把字串傳給函數之後,就不能再直接從 main
函數使用它了──那假如我希望字串後續能再被其他函數使用呢?
Rust 當然不可能沒有考慮到這點。為了讓開發者可以更精細地控制資源何時被釋放,你可以選擇轉移所有權,也可以選擇只是「借用」:
fn say_hello(name_to_display: &String) {
println!("Hello, {name_to_display}!");
}
fn main() {
let name = String::from("Kyaru");
say_hello(&name);
println!("{name}");
}
當我們使用 &String
的時候,表示的是一個對於字串的不可變引用,也就是說函數 say_hello
會暫時從 name
那邊把字串借過來。
我們也可以獲得它的「可變引用」(前提是變數本身也必須是可變的):
fn say_hello(name_to_display: &String) {
println!("Hello, {name_to_display}!");
}
fn upgrade(person_name: &mut String) { // 傳入可變引用
if !person_name.ends_with("EX") {
person_name.push_str("EX");
}
}
fn main() {
let mut name = String::from("Kyaru"); // 必須是可變變數,才能獲得變數的可變引用
upgrade(&mut name); // 將 name 以可變的形式借用給 upgrade 函數
say_hello(&name); // 將 name 以不可變的形式借用給 say_hello 函數
}
無論傳入的是可變引用或不可變引用,都不會發生所有權的轉移,這個字串仍然屬於外頭的變數 name
,且它的作用域、生命週期仍然在整個 main()
範圍。
這裡要補充一點:雖說 String
的不可變引用是 &String
,但依照慣例用 &str
取代 &String
的寫法會更好,參數型態設為 &str
的話也就能接受 name.as_ref()
的寫法了。
當然,所有權也可以傳進去以後再傳出來(函數的參數 name 前面必須加上 mut,才能修改):
fn with_type(mut name: String) -> String {
if !name.ends_with(" (cat)") {
name.push_str(" (cat)");
}
name // 當 return name; 在函數最後一行時,可以直接簡化為 name
}
fn main() {
let mut name = String::from("Kyaru");
upgrade(&mut name);
let new_name = with_type(name);
say_hello(&new_name);
}
在上面範例中,name
被傳入 with_type
以後,字串的內容被函數修改以後又被 return 回來,因此字串的所有權隨之被傳出來了。既然物件跟所有權被傳出來,我們自然就需要用變數 new_name
接住它,而原本的變數 name
因為不再擁有任何東西,自然就無效了:
let mut name = String::from("Kyaru");
upgrade(&mut name);
let new_name = with_type(name);
say_hello(&new_name);
println!("{name:?}"); // 這句不能被編譯,因為 name 擁有的字串最後交給 new_name 了,現在 name 什麼都沒有
或是你也可以選擇直接用原來的變數 name
接住:
let mut name = String::from("Kyaru");
upgrade(&mut name);
name = with_type(name);
say_hello(&name);
println!("{name:?}"); // 這句可以被編譯
從這個例子就可以很清晰地看見字串是如何被借用、傳進去、丟出來。
Rust
程式設計