iT邦幫忙

2024 iThome 鐵人賽

DAY 5
0
自我挑戰組

從 Python 開發者的角度學習 Rust —— 從語法基礎到實戰應用系列 第 5

[Day 5] 深入理解所有權與借用:Rust 的記憶體安全之鑰

  • 分享至 

  • xImage
  •  

Rust 之所以受到開發者歡迎,其中一個核心原因就是它獨特的「所有權」與「借用」系統,這套機制讓程式在沒有垃圾回收(Garbage Collection)的情況下依然能夠保證記憶體安全,並且能夠避免常見的記憶體洩漏與競態條件(Race Condition)問題。

在這篇文章中,我們將深入探討 Rust 的所有權與借用,並通過具體範例來說明如何正確地使用它們,避免常見的陷阱。


什麼是所有權?

Rust 的所有權系統可以被看作是一種記憶體管理規則,每一個資料都被一個「變數」所擁有,而這個變數負責在其生命週期結束時自動釋放記憶體。這樣的設計不僅消除了手動管理記憶體的麻煩,還大大提高了程式的安全性。

Rust 中的每一個資料都有三個與所有權相關的重要規則:

  1. 每一個值都有一個變數「擁有」它。
  2. 每個資料在同一時間只能被一個變數「擁有」。
  3. 當「所有者」變數離開作用域時,資料將被釋放。

這意味著當我們將一個變數賦值給另一個變數時,會發生所有權的「轉移」,稱之為「移動」(Move),而這個操作是 Rust 保持記憶體安全的關鍵之一。

移動範例

我們先來看一個簡單的範例,展示如何發生所有權的轉移:

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

    // println!("{}", s1); // 這行會報錯,因為 s1 的所有權已經轉移給 s2
    println!("{}", s2); // s2 擁有 s1 的所有權,可以使用
}

在這個範例中,我們定義了一個字串 s1,並將它的值賦給 s2。但在 Rust 中,這不是單純的值拷貝操作,而是「移動」:s1 的所有權轉移到 s2,因此 s1 無法再被使用。這樣的機制保證了記憶體不會被重複釋放。

https://ithelp.ithome.com.tw/upload/images/20240918/20121176oGlPyqESd2.png

深入了解移動與複製

並不是所有變數的賦值都會發生「移動」。在 Rust 中,像整數、布林值這樣的小型基本類型屬於「Copy」類型,這意味著它們可以在賦值時進行值的複製,而不會發生所有權轉移。

fn main() {
    let x = 5;
    let y = x;  // 這是值的複製,不會發生所有權轉移
    println!("x = {}, y = {}", x, y);  // x 和 y 都可以被正常使用
}

對於「Copy」類型來說,變數賦值是輕量的,因為它們在記憶體中佔用空間小,賦值後仍然可以同時使用。對於較大的資料結構,如 String,則會發生「移動」。

以下表格是一些判斷的參考

如何判斷變數是否會發生移動?

  1. 是否實現了 Copy 特徵?

    • 如果一個類型實現了 Copy 特徵,那麼它進行賦值時會發生「複製」而不是「移動」。
    • Rust 中的基本類型如整數 (i32, u32)、浮點數 (f64)、布林值 (bool)、以及較小的複合類型如元組(所有元素都是 Copy 類型時)都是 Copy 類型。
  2. 是否涉及堆記憶體?

    • Copy 類型通常涉及堆記憶體的操作,比如 StringVec<T>,它們在賦值時會發生「移動」,而不是複製。這是因為這些類型包含指向堆記憶體的指標,Rust 無法自動進行深層次的複製。

判斷標準範例

類型 會發生移動? 原因
i32 基本類型,實現了 Copy 特徵
f64 基本類型,實現了 Copy 特徵
bool 基本類型,實現了 Copy 特徵
char 基本類型,實現了 Copy 特徵
String Copy 類型,包含堆記憶體指標
Vec<T> Copy 類型,涉及動態記憶體分配
(i32, f64) 所有元素都是 Copy 類型,因此元組也是 Copy 類型
(i32, String) 包含非 Copy 類型 String,因此整體會移動

要實現非Copy類型的複製,你可以使用 clone() 方法,這會複製整個 String(包括它指向的堆記憶體中的內容)。以下是 String 複製的範例:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();  // 進行複製

    println!("s1 = {}, s2 = {}", s1, s2);  // s1 和 s2 都可以正常使用
}

說明:

  • 在這個範例中,s1.clone() 創建了一個 s1 的複製,s2 就是這個新的字串變數,它和 s1 完全獨立存在。
  • 複製後,s1s2 都指向不同的堆內存位置,所以修改 s2 不會影響 s1,它們各自擁有自己的記憶體。

修改後的範例:

fn main() {
    let s1 = String::from("hello");
    let mut s2 = s1.clone();  // 進行複製

    s2.push_str(", world");  // 修改 s2

    println!("s1 = {}", s1);  // s1 還是原來的 "hello"
    println!("s2 = {}", s2);  // s2 是 "hello, world"
}

在這裡,我們對 s2 進行了修改,但 s1 保持不變,因為兩者指向不同的記憶體,這就是clone的效果。


什麼是借用?

借用是 Rust 用來解決所有權問題的一種方式,允許變數將資料的「訪問權限」暫時借給其他變數,而不發生所有權的轉移。借用分為不可變借用可變借用兩種。

1. 不可變借用

不可變借用(&T)允許你在不改變資料的情況下借用資料,這意味著你可以擁有多個不可變借用,但不能同時有可變借用。

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

    println!("The length of '{}' is {}.", s1, len);  // s1 依然可以被使用
}

fn calculate_length(s: &String) -> usize {
    s.len()  // 我們可以讀取 s 的內容,但不能修改
}

在這裡,我們借用了 s1 的所有權,並且只讀取它的內容。這樣的設計讓我們可以安全地共享資料,而不用擔心資料會被修改。

2. 可變借用

可變借用(&mut T)允許你修改資料,但在同一時間只能有一個可變借用,並且在可變借用期間,不能存在任何不可變借用。這樣的限制避免了資料競爭。

fn main() {
    let mut s = String::from("hello");
    change(&mut s);  // 可變借用

    println!("{}", s);  // s 的內容已被修改
}

fn change(s: &mut String) {
    s.push_str(", world");
}

在這裡,s 被可變借用給 change 函數,並且在函數內修改了 s 的內容。Rust 的借用檢查器保證了在有可變借用的情況下,其他代碼不能同時借用這個資料,避免了潛在的競爭問題。


避免記憶體洩漏與資料競爭

Rust 的所有權與借用系統自動幫助你管理記憶體,避免了記憶體洩漏與資料競爭等常見問題。例如,在多執行緒程序中,Rust 會確保不同執行緒之間不會同時修改共享資料。

1. 範例:避免資料競爭

在傳統的多執行緒環境中,資料競爭是很常見的問題,而 Rust 的所有權與借用可以避免這樣的情況發生。例如,下面這段代碼展示了如何正確管理可變資料的共享:

use std::thread;

fn main() {
    let mut data = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        data.push(4);  // 這裡的所有權被移動到新的執行緒
    });

    handle.join().unwrap();
    println!("{:?}", data);  // 編譯錯誤:data 的所有權已經被移動
}

這段範例中的 thread::spawn 是 Rust 用來創建新執行緒的標準方法。當使用 move 關鍵字時,會將資料的所有權從主執行緒移動到新執行緒,這樣可以避免多個執行緒同時修改同一資料,進而避免資料競爭。

在範例中,data 是一個可變的 Vec,並且在 thread::spawn 中使用 move 來將 data 的所有權移動到新執行緒。這樣一來,新執行緒就可以修改 data,而主執行緒無法再訪問 data。當我們嘗試在主執行緒中使用 println! 打印 data 時,Rust 編譯器會報錯,提示 data 的所有權已經被移動到新執行緒,無法再使用。

這種所有權轉移的機制確保同一時間內只有一個執行緒可以修改 data,從而有效地避免了資料競爭的問題。


實戰範例:多變數管理

讓我們來實作一個多變數管理範例,通過不同的借用方式來解決變數的修改與共享問題。在這個範例中,我們將建立一個任務清單,並使用不可變借用來查詢任務,使用可變借用來修改任務狀態。

struct Task {
    title: String,
    is_completed: bool,
}

fn add_task(tasks: &mut Vec<Task>, title: String) {
    tasks.push(Task {
        title,
        is_completed: false,
    });
}

fn mark_complete(task: &mut Task) {
    task.is_completed = true;
}

fn main() {
    let mut task_list = Vec::new();

    // 新增任務
    add_task(&mut task_list, String::from("完成 Rust 教學"));
    add_task(&mut task_list, String::from("閱讀 Rust 文檔"));

    // 完成第一個任務
    if let Some(task) = task_list.get_mut(0) {
        mark_complete(task);
    }

    // 查詢任務清單
    for task in &task_list {
        println!("任務:{},完成狀態:{}", task.title, task.is_completed);
    }
}

這段程式展示了如何使用&mut task_list可變借用來修改任務狀態,並使用&task_list不可變借用來查詢任務列表。透過這樣的借用系統,我們可以避免不必要的所有權轉移與資料競爭,並且保持程式的記憶體安全。


總結

Rust 的所有權與借用系統是其保證記憶體安全的核心特點,這讓 Rust 在沒有垃圾回收機制的情況下,依然能避免常見的記憶體管理問題,如記憶體洩漏與競態條件。所有權的轉移和借用機制不僅有效避免了資料競爭問題,也提升了程式的可預測性和安全性。


上一篇
[Day 4] 建構任務管理工具:Rust 變數與資料類別的實戰應用
下一篇
[Day 6] Rust 的流程控制(if, loop, match):與 Python 的對比
系列文
從 Python 開發者的角度學習 Rust —— 從語法基礎到實戰應用30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言