iT邦幫忙

2024 iThome 鐵人賽

DAY 29
0
Modern Web

Rust 的戰國時代:探索網頁前端工具的前世今生系列 第 29

Day 29:Rust 中的所有權 (Ownership) 是什麼?(2)

  • 分享至 

  • xImage
  •  

前言

昨天理解了其他程式語言的記憶體管理方式後,今天來聊聊 Rust 如何利用所有權系統來達到安全地使用記憶體 (Memory Safety)。

Rust 的資料如何放在記憶體中?

先簡單用個範例了解一下 Rust 怎麼將資料放在記憶體中,另外其實 Rust 會自動做型別推斷,這裡寫出來是更容易看懂:

fn main() {
    fn a() {
        let x: &str = "hello";
        let y: i32 = 22;
        b();
        println!("x in a function is {}, y in a function is {}", x, y);
    }

    fn b() {
        let z: String = String::from("world");
        println!("z in b function is {}", z);
    }

    a();
}

在看這段程式之前,要先能理解兩種字串型別 (ref):

  • &str 是一種被稱為 string literal 的字串型別,是指固定大小且不可變的字串值,就像上面的 x
  • String 的字串型別指的是大小可以動態改變的字串,在初始化之後還能繼續用 push_str 來將新的字串接在後面,就像上面的 z

rust stack heap

參考上圖,當這段程式被執行時,會依序將函式的呼叫資訊放進 stack 記憶體中。main 中呼叫 a 函式,而 a 函式裡會去宣告兩個 local 變數 xy,因為這兩個變數都是定值,所以也都被存在 stack 裡面。

接著 a 會再去呼叫 b 函式,此時 b 函式的呼叫資訊會繼續堆疊在 maina 之上,而 b 中宣告了一個動態的字串 z,因為這個 String 是大小可以動態改變的字串,所以會需要配置一段記憶體在 heap 中,並只在 stack 上存著指到這塊記憶體位址的指標。

從上面這個範例可以看到,在 Rust 中要配製一塊動態記憶體相當平易近人,你不需要像 C++ 一樣需要去用 newdelete 分配與釋放記憶體,Rust 都幫你做好了,但方便歸方便,要能達到 memory safety 還需要仰賴這個所謂的所有權系統。

那 Rust 的所有權是怎麼回事呢?

先來看一個例子:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{}, world!", s1);
}

這段程式如果套到類似的 JavaScript 的語法來理解乍看之下沒什麼問題,但如果今天去 cargo run 執行看看後,會得到這樣的錯誤:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move
  |

從錯誤訊息中會看到幾個關鍵字 moveCopy traitborrowclone,以下我們就一個一個來理解這些觀念。

所有權系統

關於所有權的鐵則

文件最開頭提到所有權系統會遵循三個鐵則,可以先記下來這些特性,下面會逐一看例子來理解:

  • Rust 中每個數值都有個擁有者 (owner)
  • 同時間只能有一個擁有者
  • 當擁有者離開作用域時,數值就會被丟棄

變數的作用域

這裡看個例子,Rust 可以用一個 { } 直接去建立出一個 block scope:

fn main() {
    // s 還沒被宣告,這裡還讀取不到
    {
        // 宣告 s
        let s = String::from("hello");
        // 在這個作用域裡面可以存取到 s
        println!("{}", s);
    }// 作用域結束,s 這塊記憶體已經被自動釋放

    // 印出這行時會報錯,因為 s 在離開作用域後就已經消失
    println!("{}", s);
}

Copy 與 Move

再來看另一個例子:

fn main() {
    let x: i32 = 5;
    let y: i32 = x; // Copy
    println!("x 的值是 {}, y 的值是 {}", x, y);

    let s1: String = String::from("hello");
    let s2: String = s1; // move
    println!("s1 的值是 {}, s2 的值是 {}", s1, s2); // 出錯
}

在 Rust 中,簡單型別像是整數、浮點數、boolean、char 等,會具備 Copy 的特性,在傳遞變數時,會直接複製一份值過去。

move

但對動態大小的型別像是 StringVec、自定義的 Struct 等,在傳遞變數時,參考上圖左邊,會將指標或稱記憶體位置複製一份給新變數。但如果像是上圖中間一樣也直接連在 heap 中的資料都複製一份的話,這樣會造成記憶體的使用成本太高,因此實際 Rust 會執行的是做記憶體所有權的轉移 (Move) ,並將 s1 給無效化,這個動作就稱為 move

另外筆記下覺得 The book 中有個比喻也蠻不錯的,如果上面看不懂也可以參考看看 (ref):

📝 如果你在其他語言聽過淺拷貝(shallow copy)和深拷貝(deep copy)這樣的詞,拷貝指標、長度和容量而沒有拷貝實際內容這樣的概念應該就相近於淺拷貝。但因為 Rust 同時又無效化第一個變數,我們不會叫此為淺拷貝,而是稱此動作為移動(move)。

Clone

再來看另一個例子,這段程式中想要利用 calculate_length 這個函式來幫忙根據傳入的字串算長度:

fn calculate_length(s: String) -> usize {
    let length = s.len();

    length
}

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(s1);

    println!("'{}' 的長度為 {}。", s1, len);
}

因為 s1 的所有權已經 move 給 calculate_length 了,所以當在 main 中最後想再印出 s1 會出錯,那如果想修正這段程式該怎麼做,有個最簡單的方式就是用 clone

fn calculate_length(s: String) -> usize {
    let length = s.len();

    length
}

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(s1.clone());

    println!("'{}' 的長度為 {}。", s1, len);
}

其實這就是在做上圖中間的那個操作,clone 這個行為讓開發者有意識地知道這裡進行了一個昂貴的記憶體複製。但只是為了將資料傳進去算長度就複製一份實在偏浪費,還有另一種修正方式就是將所有權用 tuple 的方式傳回來:

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();

    // 將原本傳入的 s 的所有權再傳回去
    (s, length)
}

fn main() {
    let s1 = String::from("hello");

    // 用 s2 拿回原本 s1 的所有權
    let (s2, len) = calculate_length(s1);

    println!("'{}' 的長度為 {}。", s2, len);
}

但這樣寫實在偏麻煩,如果不想要 clone 也不想將所有權丟來丟去的話,是不是還有更好的解法?

還真有,就是不要把所有權讓出去,只是稍微借 (borrow) 出去就好。但因為再不出去吃飯餐廳要關門了,所以容我明天會再從 borrow 的觀念繼續講完來個精彩大完結,順便對這個系列收個尾。

參考資料與延伸閱讀


上一篇
Day 28:Rust 中的所有權 (Ownership) 是什麼?(1)
下一篇
Day 30:Rust 中的所有權 (Ownership) 是什麼?(3)、系列文總結、完賽心得
系列文
Rust 的戰國時代:探索網頁前端工具的前世今生30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言