前往
大廳
主題 達人專欄

搞懂 Rust 的所有權機制(上)

解凍豬腳 | 2024-08-30 19:00:08 | 巴幣 338 | 人氣 890

 
本篇文章有不少程式碼,如果這裡的排版讓你感到閱讀困難,請服用 HackMD 好讀版



有了前面關於字串、可變性、記憶體操作的前置知識,終於可以來講 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 學習者的第一個課題:所有權應該被轉移嗎?

學習 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:?}"); // 這句可以被編譯

從這個例子就可以很清晰地看見字串是如何被借用、傳進去、丟出來了。



HackMD 好讀版:https://hackmd.io/@upk1997/rust-ownership-1

縮圖素材原作者:Karen Rustad Tölva(CC0 1.0)
送禮物贊助創作者 !
0
留言
追蹤 創作集

作者相關創作

更多創作