返回文章列表

Rust 記憶體管理與智慧指標應用

Rust 的所有權和借用機制確保記憶體安全,避免懸空指標和資料競爭。本文探討 Rust 的記憶體管理,包含所有權、移動語義、深複製、借用與參照,以及 Box、Rc 和 Arc 等智慧指標的應用場景。文章以單連結串列例項說明 Box 的使用,並解釋 Rc 和 Arc

程式語言 系統程式設計

Rust 的核心特性之一,就是其獨特的所有權系統,賦予了編譯器在編譯時期檢查記憶體安全的能力,避免了常見的記憶體錯誤,例如懸空指標和資料競爭。所有權規則確保每個值都只有一個所有者,並在所有者超出作用域時自動釋放資源。Rust 的移動語義不同於複製,它會轉移所有權而非複製資料,除非是基本型別。深複製則需要明確使用 clone() 方法。借用和參照允許在不取得所有權的前提下存取資料,並區分可變和不可變借用,進一步提升了記憶體安全性。智慧指標 Box 則用於在堆積上分配記憶體,搭配 Option 使用能有效處理空值情況,避免潛在的空指標錯誤。try_new() 方法則提供更安全的記憶體分配方式,能有效處理記憶體不足的狀況。

Rust 記憶體管理與所有權機制

Rust 的所有權機制是其安全保證的核心,編譯器的借用檢查器負責強制執行一組簡單的所有權規則。每個值都有一個所有者,一次只能有一個所有者;當所有者超出範圍時,該值將被丟棄。

瞭解所有權:複製、借用、參照和移動

Rust 的所有權語義在某些方面與 C、C++ 和 Java 相似,但 Rust 沒有複製建構函式的概念,且很少與原始指標互動。當你將一個變數的值賦給另一個變數(即 let a = b;)時,這被稱為移動,即所有權的轉移(且一個值只能有一個所有者)。移動不會建立副本,除非你正在指定基本型別(即將整數指定給另一個值會建立副本)。

fn main() {
    let mut top_grossing_films = vec!["Avatar", "Avengers: Endgame", "Titanic"];
    let top_grossing_films_mutable_reference = &mut top_grossing_films;
    top_grossing_films_mutable_reference.push("Star Wars: The Force Awakens");
    let top_grossing_films_reference = &top_grossing_films;
    println!("Printed using immutable reference: {:#?}", top_grossing_films_reference);
    let top_grossing_films_moved = top_grossing_films;
    println!("Printed after moving: {:#?}", top_grossing_films_moved);
}

內容解密:

  1. let mut top_grossing_films = vec!["Avatar", "Avengers: Endgame", "Titanic"];:建立一個可變的 Vec 並填充一些值。
  2. let top_grossing_films_mutable_reference = &mut top_grossing_films;:借用 top_grossing_films 的可變參照。
  3. top_grossing_films_mutable_reference.push("Star Wars: The Force Awakens");:使用可變參照修改 Vec 的內容。
  4. let top_grossing_films_reference = &top_grossing_films;:取得 top_grossing_films 的不可變參照,使先前的可變參照失效。
  5. let top_grossing_films_moved = top_grossing_films;:將 top_grossing_films 的所有權轉移到 top_grossing_films_moved

深複製

在 Rust 中,沒有淺複製的概念,而是透過借用和參照來實作。需要明確指示編譯器要執行的操作。

為什麼需要深複製?

在某些語言中,如 Python 或 Ruby,為了防止複製資料,通常會使用指標、參照和複製寫入語義等最佳化手段。因此,在這些語言中,有時需要執行顯式的深複製。

Rust 中的深複製

Rust 不假設任何關於你的意圖,因此你總是需要明確指示編譯器要做什麼。Rust 提供了 clone() 方法來實作深複製。

let original_vec = vec![1, 2, 3];
let cloned_vec = original_vec.clone();
println!("Original Vec: {:?}", original_vec);
println!("Cloned Vec: {:?}", cloned_vec);

內容解密:

  1. let original_vec = vec![1, 2, 3];:建立一個原始的 Vec
  2. let cloned_vec = original_vec.clone();:對 original_vec 執行深複製,建立一個新的 Vec
  3. println!:列印原始和複製的 Vec

借用和參照的重要性

在 Rust 中,借用和參照是用於在不取得所有權的情況下存取資料的主要手段。資料可以透過值(即移動)或參照傳遞給函式。借用的資料可以是不可變或可變的,預設情況下,借用是不可變的。

fn print_vec(vec_ref: &Vec<i32>) {
    println!("Vec contents: {:?}", vec_ref);
}

fn main() {
    let my_vec = vec![1, 2, 3];
    print_vec(&my_vec);
    println!("Vec after function call: {:?}", my_vec);
}

內容解密:

  1. fn print_vec(vec_ref: &Vec<i32>):定義一個函式,接受一個 Vec<i32> 的不可變參照。
  2. print_vec(&my_vec);:將 my_vec 的不可變參照傳遞給 print_vec 函式。
  3. 資料在函式內部被存取,但未被修改或移動,因此在函式呼叫後仍然可用。

使用記憶體的智慧

避免不必要的複製

在Rust程式設計中,資料結構的複製可能導致效能問題,尤其是在處理大型資料集時。瞭解何時會發生複製以及如何避免,是寫出高效能Rust程式的關鍵。

Clone Trait的運作機制

Rust中的Clone trait用於建立資料結構的深度複製。當對一個資料結構呼叫clone()方法時,會遞迴地複製其所有內容,只要這些內容也實作了Clone trait。這使得複製複雜的巢狀結構變得簡單。

fn main() {
    let mut most_populous_us_cities =
        vec!["New York City", "Los Angeles", "Chicago", "Houston"];
    let most_populous_us_cities_cloned = most_populous_us_cities.clone();
    most_populous_us_cities.push("Phoenix");
    println!("most_populous_us_cities = {:#?}", most_populous_us_cities);
    println!(
        "most_populous_us_cities_cloned = {:#?}",
        most_populous_us_cities_cloned
    );
}

內容解密:

  • 在這段程式碼中,我們首先建立了一個包含美國人口最多的城市的向量most_populous_us_cities
  • 我們使用clone()方法建立了這個向量的深度複製,儲存在most_populous_us_cities_cloned中。
  • 之後,我們向原始向量中新增了一個城市"Phoenix"。
  • 列印結果顯示,原始向量和複製的向量是兩個獨立的結構,對原始向量的修改不會影響到複製的向量。

辨識和避免不必要的複製

Rust的核心函式庫函式通常傳回物件的複製,而不是直接修改原始物件。這種設計有助於保持資料的不變性,但也可能導致不必要的記憶體複製。瞭解函式的行為對於避免不必要的複製至關重要。

Rust核心字串函式的行為分析

函式名稱描述是否複製辨識方法
replace取代字串中的模式傳回新的String,原始字串不變
to_lowercase將字串轉換為小寫傳回新的String,原始字串不變
make_ascii_lowercase將ASCII字串轉換為小寫直接修改原始字串
trim移除字串的首尾空白傳回原始字串的切片

內容解密:

  • 表格中的函式展示了Rust中常見的字串操作及其是否會導致複製。
  • replaceto_lowercase會傳回新的字串,是因為它們需要建立新的記憶體空間來儲存結果。
  • make_ascii_lowercase直接修改原始字串,因此不會導致複製。
  • trim傳回的是原始字串的切片,也不會導致複製。

智慧指標:Box的使用時機

Rust中的Box是一種智慧指標,主要用於在堆積上分配記憶體。瞭解何時使用Box,對於有效管理記憶體非常重要。

Box的基本用途

  • Box主要用於在堆積上分配資料。
  • Vec不同,Box主要用於單一物件的記憶體管理。
// 使用Box在堆積上分配一個i32
let b = Box::new(5);
println!("b = {}", b);

內容解密:

  • 這段程式碼展示瞭如何使用Box::new在堆積上分配一個i32型別的變數。
  • Box自動管理它所指向的記憶體,當Box離開作用域時,它所指向的記憶體會被釋放。

智慧指標:Box 與 Option 的結合使用

在 Rust 中,Box 是一種智慧指標,用於在堆積疊上分配記憶體。然而,Box 本身不能為空,除非在某些特殊情況下。因此,當被 Box 的資料可能不存在時,我們通常會將 Box 包裝在 Option 中。

使用 Option 包裝 Box

選項型別(Optional types)並不是 Rust 獨有的,其他語言如 Ada、Haskell、Scala 和 Swift 等也都有類別似的概念。選項型別是一種函式式設計模式,將不應直接存取的值包裝在一個函式中。Rust 提供了一些語法糖,使操作選項型別更加方便。

為何使用 Option 和 Box

Option 在 Rust 中被廣泛使用。如果你之前沒接觸過選項型別,可以將其視為安全處理空值(例如:指標)的一種方式。Rust 不支援空指標(除了在 unsafe 程式碼中),但它有 None,功能上等同於空指標,卻沒有安全問題。

Box 和 Option 的結合

BoxOption 一起使用時,幾乎不可能出現因無效、未初始化或重複釋放記憶體而導致的執行階段錯誤(例如:空指標例外)。然而,有一個需要注意的地方:堆積疊分配可能會失敗。處理這種情況相當棘手,不在本文的討論範圍內,且在一定程度上取決於作業系統及其設定。

記憶體分配失敗的處理

記憶體分配失敗的一個常見原因是系統記憶體耗盡。處理這種情況(如果你選擇處理的話)很大程度上取決於應用程式。大多數時候,處理記憶體分配失敗的預期結果是程式「快速失敗」並離開,出現記憶體不足的錯誤(OOM)。這幾乎總是預設行為(也就是說,如果開發者不處理 OOM 錯誤時會發生的情況)。你可能已經遇到過這種情況。一些值得注意的應用程式提供了自己的記憶體管理功能,例如網頁瀏覽器,它們通常有自己的內建任務管理器和記憶體管理,就像作業系統一樣。如果你正在編寫關鍵任務軟體,例如資料函式庫或線上交易處理系統,你可能希望優雅地處理記憶體分配失敗。

使用 try_new() 方法

在懷疑分配可能會失敗的情況下,Box 提供了 try_new() 方法,該方法傳回一個 Result。這與 Option 類別似,可能處於成功或失敗狀態。Box 的預設 new() 方法在分配失敗時會產生 panic,導致程式當機。在大多數情況下,當機是處理分配失敗的最佳方式。另外,你可以在自訂分配器中捕捉分配失敗(本章稍後會討論)。

使用 Box 實作單連結串列

為了說明 Box 的使用,考慮 Rust 中的一個基本單連結串列。

struct ListItem<T> {
    data: Box<T>,
    next: Option<Box<ListItem<T>>>,
}

struct SinglyLinkedList<T> {
    head: ListItem<T>,
}

impl<T> ListItem<T> {
    fn new(data: T) -> Self {
        ListItem {
            data: Box::new(data),
            next: None,
        }
    }

    fn next(&self) -> Option<&Self> {
        if let Some(next) = &self.next {
            Some(next.as_ref())
        } else {
            None
        }
    }

    fn mut_tail(&mut self) -> &mut Self {
        if self.next.is_some() {
            self.next.as_mut().unwrap().mut_tail()
        } else {
            self
        }
    }

    fn data(&self) -> &T {
        self.data.as_ref()
    }
}

impl<T> SinglyLinkedList<T> {
    fn new(data: T) -> Self {
        SinglyLinkedList {
            head: ListItem::new(data),
        }
    }

    fn append(&mut self, data: T) {
        let mut tail = self.head.mut_tail();
        tail.next = Some(Box::new(ListItem::new(data)));
    }

    fn head(&self) -> &ListItem<T> {
        &self.head
    }
}

內容解密:

  1. ListItem 結構定義:每個連結串列節點包含一個被 Box 包裝的資料和一個可選的指向下一個節點的 next 指標。
  2. SinglyLinkedList 結構定義:單鏈表本身只包含頭節點。
  3. ListItem 的方法實作
    • new:建立一個新的連結串列節點,將資料包裝在 Box 中,並將 next 初始化為 None
    • next:傳回當前節點的下一個節點的參考,如果存在的話。
    • mut_tail:遞迴地找到連結串列的最後一個節點,並傳回其可變參考。
    • data:傳回當前節點資料的參考。
  4. SinglyLinkedList 的方法實作
    • new:建立一個新的單連結串列,需要提供初始資料。
    • append:在連結串列的末尾新增新的節點。
    • head:傳回連結串列頭節點的參考。

圖示說明:

此圖示展示了一個單連結串列的結構,其中每個節點包含資料和指向下一個節點的指標。頭節點是連結串列的入口點,透過它可以遍歷整個連結串列。

深入理解 Rust 的記憶體管理:參考計數

在前面的章節中,我們討論了 Box,這是一種實用但功能有限的智慧指標。在 Rust 程式中,Box 無法被共用,也就是說,你不能讓兩個不同的 Box 指向相同的資料,因為 Box 擁有其資料,並且不允許多個借用同時存在。這種設計在大多數情況下是一種優勢,但有些情況下,我們確實需要共用資料,例如跨執行緒或將相同資料儲存在多個結構中以便不同方式存取(如 VecHashMap)。

參考計數的原理

Box 無法滿足需求的情況下,參考計數智慧指標(reference-counted smart pointers)就派上用場了。參考計數是一種常見的記憶體管理技術,用於避免追蹤指標的複製次數,並在沒有複製時釋放記憶體。實作上通常依賴於維護一個靜態計數器,每當建立新的複製時計數器就會遞增,而當複製被銷毀時,計數器就會遞減。如果計數器歸零,表示沒有任何複製存在,記憶體就可以被釋放,因為它不再被使用或存取。

實作參考計數智慧指標

實作參考計數智慧指標是一個有趣的練習,但它在 Rust 中有點棘手,需要使用原始(即不安全的)指標。如果你覺得實作鏈結串列太簡單,可以嘗試建立自己的參考計數智慧指標。

Rust 中的參考計數智慧指標

Rust 提供了兩種不同的參考計數指標:

  • Rc:單執行緒的參考計數智慧指標,允許物件的共用所有權
  • Arc:多執行緒的參考計數智慧指標,允許跨執行緒共用物件的所有權

要有效地使用參考計數指標,我們還需要介紹 Rust 中的另一個概念:內部可變性(interior mutability)。內部可變性是你在某些情況下可能需要的技術。

單執行緒與多執行緒物件

許多程式語言區分可以在執行緒間安全使用的函式或物件(執行緒安全)和不安全的函式或物件。在 Rust 中,這種區分並不直接適用,因為預設情況下一切都是安全的。相反,有些物件可以跨執行緒移動或同步,而其他物件則不能。這種行為取決於物件是否實作了 SendSync 特徵,我們將在第 6 章中更詳細地討論。

對於 RcArcRc 沒有提供 SendSync(事實上,Rc 明確標記為未實作這些特徵),因此 Rc 只能在單一執行緒中使用。另一方面,Arc 實作了 SendSync,因此可以在多執行緒程式碼中使用。

特別是,Arc 使用原子計數器,這些計數器是平台相關的,通常由作業系統或 CPU 層級實作。原子操作比常規算術運算更昂貴,因此只有在需要原子性時才使用 Arc

值得注意的是,只要你不使用 unsafe 關鍵字繞過語言規則,Rust 程式碼總是安全的。然而,當你不瞭解 Rust 獨特的模式和術語時,要讓程式碼編譯透過可能會相當具有挑戰性。

鏈結串列的重溫

在探討參考計數之前,我們先來看看前面提到的鏈結串列範例。這個範例展示了 Rust 獨特的功能,並且是安全的。鏈結串列永遠不會是空的、無效的或包含空指標。我們可以使用以下程式碼測試剛建立的鏈結串列:

fn main() {
    let mut list = SinglyLinkedList::new("head");
    list.append("middle");
    list.append("tail");
    let mut item = list.head();
    loop {
        println!("item: {}", item.data());
        if let Some(next_item) = item.next() {
            item = next_item;
        } else {
            break;
        }
    }
}

執行上述程式碼會產生以下輸出:

item: head
item: middle
item: tail

程式碼解析

  1. 建立一個新的鏈結串列,並新增 “head”、“middle” 和 “tail” 三個元素。
  2. 取得鏈結串列的頭部參考。
  3. 使用迴圈遍歷鏈結串列中的每個元素,並印出其值。
  4. 使用 if let 陳述式取得下一個元素,如果下一個元素存在,則繼續迴圈;否則,跳出迴圈。

Plantuml 圖解:鏈結串列結構

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title Rust 記憶體管理與智慧指標應用

package "安全架構" {
    package "網路安全" {
        component [防火牆] as firewall
        component [WAF] as waf
        component [DDoS 防護] as ddos
    }

    package "身份認證" {
        component [OAuth 2.0] as oauth
        component [JWT Token] as jwt
        component [MFA] as mfa
    }

    package "資料安全" {
        component [加密傳輸 TLS] as tls
        component [資料加密] as encrypt
        component [金鑰管理] as kms
    }

    package "監控審計" {
        component [日誌收集] as log
        component [威脅偵測] as threat
        component [合規審計] as audit
    }
}

firewall --> waf : 過濾流量
waf --> oauth : 驗證身份
oauth --> jwt : 簽發憑證
jwt --> tls : 加密傳輸
tls --> encrypt : 資料保護
log --> threat : 異常分析
threat --> audit : 報告生成

@enduml

此圖示展示了鏈結串列的結構,每個節點指向下一個節點,最後一個節點指向 None

在接下來的章節中,我們將探討更進階的鏈結串列實作。如果你想更好地理解 Rust 的記憶體管理,請嘗試從頭開始實作鏈結串列,並在需要時參考提供的範例。實際上,在 Rust 中,你很可能永遠不需要實作自己的鏈結串列,因為 Rust 的核心函式庫提供了 std::collections::LinkedList。然而,瞭解其背後的原理仍然是非常有價值的。