所有權是 Rust 區別於其他語言的核心概念,也是它安全保證的基礎。讓我來解釋這個獨特的系統如何運作。

所有權的基本規則

Rust 的借用檢查器負責執行一組精簡的所有權規則:

  1. 每個值都有一個擁有者
  2. 同一時間只能有一個擁有者
  3. 當擁有者離開作用域,該值會被丟棄

移動與借用:資料傳遞的核心機制

在 Rust 中,當你將一個變數的值指派給另一個變數(例如 let a = b;)時,這被稱為「移動」(move),即所有權的轉移。每個值只能有一個擁有者,除非你指派的是基本類別(如整數),這種情況會建立一個副本。

不同於使用指標,Rust 通常透過參照傳遞資料。在 Rust 中,參照是透過「借用」(borrowing)建立的。資料可以透過值(移動)或參照傳遞給函式。

借用的資料(即參照)可以是不可變的或可變的:

  • 預設情況下,借用是不可變的(不能修改參照指向的資料)
  • 使用 mut 關鍵字可以獲得可變參照,允許修改資料
  • 你可以同時有多個不可變借用,但同一時間只能有一個可變借用

下面的例子展示了這些概念:

fn main() {
    // 建立一個可變的 Vec 並填充一些值
    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
    );
    
    // 這個指派是一個移動,轉移了 Vec 的所有權
    let top_grossing_films_moved = top_grossing_films;
    
    // 移動後印出內容
    println!("Printed after moving: {:#?}", top_grossing_films_moved);
    
    // 原始變數不再有效,因為它已經被移動,所以這段程式碼不會編譯
    // println!("Print using original value: {:#?}", top_grossing_films);
    
    // 這段程式碼也不會編譯,因為當我們建立不可變參照時,這個參照就失效了
    // println!(
    //    "Print using mutable reference: {:#?}",
    //    top_grossing_films_mutable_reference
    // );
}
  • 我們首先建立一個可變的向量 top_grossing_films,並填充了三個電影名稱。
  • 接著,我們用 &mut 運算元取得這個向量的可變參照,並使用這個參照增加第四部電影。
  • 然後,我們建立了一個不可變參照。根據 Rust 的借用規則,當建立不可變參照時,之前的可變參照就失效了。
  • 接下來,我們透過 let top_grossing_films_moved = top_grossing_films 將向量的所有權移動到新變數。
  • 移動後,原始變數 top_grossing_films 不再有效,嘗試使用它會導致編譯錯誤。
  • 同樣,嘗試使用已經失效的可變參照也會導致編譯錯誤。

這個例子展示了 Rust 所有權和借用系統的核心規則,以及它們如何防止常見的記憶體安全問題。

深度複製與記憶體處理

在某些語言中(如 Python、Ruby),你可能遇到過深度複製(deep copying)的概念。當語言或資料結構透過指標、參照或寫入時複製(copy-on-write)等最佳化避免複製資料時,就需要深度複製。

淺複製與深複製的區別

資料結構的複製可以是淺複製(複製指標或建立參照)或深複製(遞迴地複製或克隆結構中的所有值)。某些語言在指定或函式呼叫時預設執行淺複製。

Rust 的明確複製機制

Rust 不會對你的意圖做任何假設,因此你總是需要明確指示編譯器要做什麼。換句話說,Rust 中不存在淺複製的概念,取而代之的是借用和參照。

這種設計避免了許多語言中常見的問題:當你想要複製資料,但語言卻提供了參照,導致意外的副作用和難以發現的錯誤。Rust 不會進行任何隱含的資料參照,讓程式行為更加可預測。

Rust 記憶體管理的實際應用

理解了 Rust 的記憶體管理機制後,讓我分享一些實際應用這些概念的經驗:

選擇適當的記憶體設定策略

在實際開發中,選擇在堆積積還是堆積積上設定資料取決於多種因素:

  1. 資料大小:大型資料結構通常適合放在堆積積上,而小型資料適合堆積積。

  2. 生命週期:如果資料需要在多個函式間分享或其生命週期超出建立它的函式範圍,則堆積積設定更合適。

  3. 資料大小可變性:如果資料大小可能變化(如向量可能增長),則堆積積設定是必要的。

所有權設計模式

在設計 Rust 程式時,考慮所有權流通常能帶來更清晰的程式結構:

  1. 消費型 API:函式接收值的所有權並消費它,適用於資源轉換場景。

  2. 借用型 API:函式借用資料但不取得所有權,適用於需要讀取或暫時修改資料的場景。

  3. 生成型 API:函式建立並回傳新資源的所有權,適用於工廠模式。

效能最佳化巧

利用 Rust 的記憶體模型進行效能最佳化

  1. 優先使用堆積積:當可行時,優先在堆積積上設定資料以獲得更好的效能。

  2. 避免不必要的克隆:使用參照而非建立資料的副本,除非真的需要獨立的副本。

  3. 預先設定容量:對於可增長的集合類別(如 Vec),預先設定足夠的容量可以避免多次重新設定。

  4. 使用內部可變性:在需要時,可以考慮 RefCell 等提供內部可變性的類別,但要謹慎使用。

Rust 的記憶體管理機制——包括堆積積和堆積積設定、所有權系統和借用規則——構成了這門語言的核心優勢。它們使 Rust 能夠在無需垃圾回收的情況下提供記憶體安全保證,同時保持高效能和低資源消耗。

透過這些機制,Rust 創造了一個獨特的程式設計,使開發者能夠撰寫既安全又高效的系統程式。雖然學習曲線可能較陡,但掌握這些概念後,你將能夠利用 Rust 開發出健壯、高效與安全的應用程式。

隨著深入理解這些機制,你會發現 Rust 的所有權系統不僅是一種限制,更是一種強大的工具,能夠幫助你設計更好的程式架構和資料流程。這正是為何越來越多的開發者和企業轉向 Rust 進行系統程式設計的原

Rust中的克隆與複製機制:記憶體效率策略

克隆與複製的概念區分

在Rust中,克隆(cloning)指的是建立一個新的資料結構並將原有結構中的所有資料複製(或更準確地說,克隆)到新結構中。這個操作通常透過clone()方法實作,該方法來自於Clone特徵(trait)。對於許多資料類別,我們可以使用#[derive(Clone)]屬性來自動實作這個特徵。

克隆範例

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
    );
}

這段程式碼示範了克隆操作的核心特性。當我們呼叫clone()方法時,它建立了向量的完全獨立副本,包括其中所有元素。因此,當我們向原始向量增加"Phoenix"時,克隆版本保持不變。輸出結果清楚顯示原始向量包含五個城市,而克隆版本只含有初始的四個城市。

值得注意的是,Clone特徵在派生時會遞迴操作。這意味著呼叫頂層資料結構(如Vec)的clone()方法足以建立其內容的深度複製,前提是這些內容都實作了Clone特徵。這使得即使是深度巢狀的結構也能輕鬆克隆,而無需進行額外處理。

避免不必要的複製

在某些情況下,資料結構可能會被意外地過度克隆或複製。這種情況在字串處理中尤為常見,例如在掃描、修改或處理任意資料集的演算法中重複建立字串的多個副本。

Clone的一個缺點是它可能使資料結構過於容易被複製,如果使用不當,可能會導致同一資料的多個副本。對於大多數用途來說,這不是問題,但當開始處理非常大的資料集時,效能問題就會浮現。

Rust核心函式庫製模式

Rust的許多核心函式庫回傳物件的副本,而不是直接修改它們。這在大多數情況下是首選行為,它有助於維持資料的不可變性,使演算法行為更容易理解,代價是可能暫時地複製記憶體。

字串函式的複製行為分析
函式描述複製?演算法識別方式
replace<'a, P>(&'a self, from: P, to: &str) -> String將所有比對模式的內容替換為另一個字串引數為不可變參照;函式回傳擁有所有權的String
to_lowercase(&self) -> String回傳字串切片的小寫等效形式,作為新String引數為不可變參照;函式回傳擁有所有權的String
make_ascii_lowercase(&mut self)將此字串轉換為ASCII小寫等效形式函式接受可變self參照,直接修改記憶體
trim(&self) -> &str回傳去除前後空白的字串切片函式回傳參照,而非擁有所有權的字串

辨識函式是否建立複製的模式

fn lowercased(s: String) -> String {
    s.to_lowercase()
}

fn lowercased_ascii(mut s: String) -> String {
    s.make_ascii_lowercase();
    s
}

這兩個函式展示了不同的處理方式:

  • lowercased()接受一個擁有所有權的字串,但透過呼叫to_lowercase()回傳該字串的新副本。
  • lowercased_ascii()接受一個可變的擁有所有權的字串,使用就地版本(make_ascii_lowercase())進行修改,然後直接回傳修改後的同一個字串。

函式行為判斷

根據函式簽名可以初步判斷其記憶體行為:

  1. 接受不可變參照並回傳參照或切片的函式不太可能建立副本(例如fn func(&self) -> &str
  2. 接受參照但回傳擁有所有權物件的函式可能正在建立副本(例如fn func(&self) -> String
  3. 接受可變參照的函式可能正在就地修改資料(例如fn func(&mut self)
  4. 接受擁有所有權物件並回傳相同類別的擁有所有權物件的函式可能正在建立副本(例如fn func(String) -> String
  5. 接受可變擁有所有權物件並回傳相同類別的擁有所有權物件的函式可能不建立副本(例如fn func(mut String) -> String

當不確定函式是否建立副本、就地操作或僅傳遞所有權時,應查閱檔案和原始碼。Rust的記憶體語義使透過檢查輸入和輸出來推斷演算法如何操作資料變得相對容易,但這只在被呼叫的函式遵循這些模式時才有效。在有嚴重效能問題的情況下,應仔細檢查底層演算法。

智慧指標:Box的使用時機

Rust的Box是一種智慧指標,其主要目的是提供一種在堆積積分配資料的方法。在Rust中,在堆積積分配資料的兩種主要方式是使用VecBox

Box在功能方面相當有限;它僅處理其所持有物件的記憶體分配和釋放,幾乎不提供其他功能,但這是設計使然。儘管如此,Box仍然非常有用,當需要在堆積積儲存資料時(除了使用Vec外),它應該是首選。

使用Option包裝Box

由於Box不能為空(除了某些最好不要探索的特殊情況外),我們通常在任何可能不存在裝箱資料的情況下將Box放在Option內。

// 如果資料或物件是可選的,應該將Box放在Option中
let optional_boxed_data: Option<Box<SomeType>> = Some(Box::new(data));

Option類別在Rust中經常使用;如果你以前沒有遇到過可選類別,可以將其視為安全處理空值的方式。Rust沒有空指標(除了不安全程式碼),但它確實有None,這是Option的一種狀態,表示沒有值。

Option是一種單體(monad)——一種函式設計模式,透過它你可以將不應該有無限制存取的值包裝在函式中。Rust提供了一些語法糖,使處理可選類別更加愉快。

在處理可能為空的資料結構時,將BoxOption結合使用是Rust中的一種常見模式,這種方式既保持了記憶體安全,又提供了表達資料可能不存在的清晰方式。

Rust的記憶體管理系統透過所有權、借用和生命週期機制提供了安全而高效的記憶體控制。在選擇是否進行克隆或複製時,需要考慮:

  1. 效能影響:克隆操作會複製所有資料,這在大型資料結構上可能代價高昂
  2. 記憶體使用:不必要的克隆會增加程式的記憶體佔用
  3. 操作意圖:明確區分需要修改原始資料的操作和需要保持原始資料不變的操作
  4. API設計:設計函式時,應明確其記憶體行為,使其符合Rust的模式和慣例

對於堆積積配,Box提供了簡單直接的方式,而與Option結合使用則增加了處理可能不存在值的能力。理解這些模式對於寫出記憶體高效與安全的Rust程式至關重要。

在實際開發中,玄貓建議先關注程式碼正確性和清晰度,然後在需要時最佳化憶體使用。Rust的設計使得這種逐步最佳化方法非常有效,因為編譯器會幫助我們識別和避免許多常見的記憶體問題。

Rust智慧指標的強大之處

當談到Rust的記憶體管理時,Box和Option這兩個類別的組合是我們最常用的工具之一。這種組合的優勢在於它幾乎完全消除了執行期錯誤(如空指標例外)的可能性,讓我們不必擔心無效、未初始化或重複釋放記憶體的問題。

然而,使用堆積積記憶體時仍有一個關鍵問題需要考慮:記憶體設定可能會失敗。

處理記憶體設定失敗

記憶體設定失敗的常見原因是系統耗盡可用記憶體。如何處理這種情況在很大程度上取決於應用程式的性質。大多數情況下,記憶體設定失敗時的預期結果是程式「快速失敗」並以記憶體不足錯誤(OOM)結束,這也是預設行為。

某些特殊應用程式會提供自己的記憶體管理功能:

  • 網頁瀏覽器通常有內建的工作管理器和記憶體管理系統
  • 資料函式庫上交易處理系統可能需要優雅地處理記憶體設定失敗

當你懷疑記憶體設定可能失敗時,Box提供了try_new()方法,它會回傳一個Result。這與Option類別,可能處於成功或失敗狀態。Box的預設new()方法在設定失敗時會產生panic,導致程式當機。在大多數情況下,當機實際上是處理設定失敗的最佳方式。另一種選擇是在自定義設定器中捕捉設定失敗(這個主題稍後會討論)。

Option和Result是Rust中處理「可能不存在值」和「可能失敗操作」的核心類別。如果你想更深入理解它們,可以嘗試使用enum自己實作。在Rust中,使用列舉和模式比對來建立自己的optional類別非常簡單。

使用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 結構包含兩個欄位:data(被Box包裹的實際資料)和next(可選的指向下一個專案的Box)
    • data被Box包裹確保它存在堆積積上,而不是堆積積疊
    • next是一個Option<Box<ListItem<T>>>,表示它可能指向下一個元素,也可能是None(表示串列結尾)
    • SinglyLinkedList結構只包含一個head欄位,它必須始終存在
  2. ListItem實作

    • new方法建立一個新的列表專案,將資料放入Box中,並將next初始化為None
    • next方法回傳對下一項的參照(如果存在)
    • mut_tail方法遞迴尋找串列的尾部,回傳對尾部元素的可變參照
    • data方法提供對內部資料的參照
  3. SinglyLinkedList實作

    • new方法建立一個新串列,要求提供一個初始元素
    • append方法在串列尾部增加新元素
    • head方法提供對頭元素的直接存取

這個串列實作的關鍵特點是它完全安全——串列永遠不會為空,不會包含無效資料或空指標。這種安全性正是得益於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;
        }
    }
}

這段測試程式碼做了以下事情:

  1. 建立一個新的字串列,頭元素值為"head"
  2. 增加兩個元素:“middle"和"tail”
  3. 取得頭元素的參照
  4. 使用迴圈遍歷串列:
    • 列印每個元素的值
    • 嘗試取得下一個元素
    • 如果下一個元素存在,更新當前元素並繼續迴圈
    • 如果沒有下一個元素,跳出迴圈

執行這段程式碼會產生以下輸出:

item: head
item: middle
item: tail

雖然實作自己的連結串列是學習Rust記憶體管理的好方法,但實際開發中,你很可能不需要自己實作連結串列。Rust的標準函式庫了std::collections::LinkedList,如果你真的需要連結串列。然而,在大多數情況下,直接使用Vec會是更好的選擇。

參照計數:分享所有權

前面我們討論了Box,這是一個有用但功能有限的智慧指標。Box有一個明顯的限制:它不能被分享。也就是說,在Rust程式中,你不能有兩個獨立的Box指向同一個資料;Box擁有其資料,不允許同時有多個借用。

然而,有些情況下我們確實需要分享資料:

  • 在執行緒之間分享資料
  • 在不同的資料結構中儲存相同的資料(例如同時在Vec和HashMap中)

這種情況下,我們需要的是參照計數的智慧指標。

參照計數的工作原理

參照計數是記憶體管理中的常見技術,用於追蹤一個指標有多少個副本,以及何時可以釋放記憶體。實作通常依賴於維護一個靜態計數器,記錄給定指標的副本數量。每當建立新副本時,計數器就會增加。當副本被銷毀時,計數器就會減少。如果計數器降到零,就可以釋放記憶體,因為這表示不再有指標的副本,因此記憶體不再被使用或無法存取。

參照計數是解決多所有權問題的一種優雅方案。在Rust中,標準函式庫了Rc(參照計數)和Arc(原子參照計數)兩種智慧指標來實作這一功能。它們的區別在於:

  • Rc只能在單執行緒環境中使用
  • Arc可以在多執行緒環境中安全使用,但有輕微的效能開銷

自己實作參照計數智慧指標是一個有趣的練習,但在Rust中有些棘手,需要使用原始(unsafe)指標。如果你覺得連結串列的練習太簡單,可以嘗試實作自己的參照計數指標。

選擇適當的智慧指標

在Rust中,選擇正確的智慧指標取決於你的具體需求:

  1. 單一所有權,堆積積分配:使用Box<T>

    • 當你需要將資料放在堆積積上
    • 當你有大型資料結構,不希望在函式呼叫時複製
    • 當你需要擁有確切已知大小的類別
  2. 多個所有者,單執行緒:使用Rc<T>

    • 當你需要在單執行緒環境中分享資料
    • 當你無法在編譯時確定哪個部分最後釋放資料
  3. 多個所有者,多執行緒:使用Arc<T>

    • 當你需要在多執行緒環境中分享資料
    • 當你需要執行緒安全的參照計數
  4. 可變性需求:結合RefCell<T>Mutex<T>

    • Rc結合使用RefCell實作單執行緒環境中的內部可變性
    • Arc結合使用Mutex實作多執行緒環境中的安全可變存取

記憶體管理的最佳實踐

在Rust中有效管理記憶體的一些關鍵原則:

  1. 優先使用堆積積疊配:盡可能使用自動分配的堆積積疊憶體,而不是手動管理堆積積記憶體

  2. 明智選擇容器:對大多數情況,Vec通常是比連結串列更好的選擇,因為它有更好的快取區域性和更少的分配開銷

  3. 謹慎處理迴圈參照:參照計數不能自動處理迴圈參照,需要使用Weak參照或重新設計資料結構

  4. 考慮記憶體分配的成本:在效能關鍵的程式碼中,過多的堆積積分配可能導致效能瓶頸

  5. 使用適當的生命週期標註:正確使用生命週期可以減少不必要的記憶體分配

Rust的記憶體管理機制結合了高階抽象和低階控制,讓開發者能夠寫出既安全又高效的程式碼。透過理解並正確使用Box和參照計數等智慧指標,我們可以充分利用Rust的記憶體安全保證,同時保持對記憶體使用的精確控制。

在實作連結串列的過程中,我們看到了如何使用Box來安全地在堆積積上分配資料,以及如何使用Option處理可能不存在的值。這些技術不僅適用於連結串列,還可以應用於各種需要精確記憶體控制的資料結構和演算法。

Rust 的參照計數智慧指標:在安全與彈性間取得平衡

在 Rust 程式設計的世界中,記憶體管理是一個核心課題。當標準的所有權和借用規則無法滿足特定需求時,Rust 提供了一系列智慧指標來增強程式設計的彈性。在這篇文章中,玄貓將探討參照計數智慧指標,以及如何透過內部可變性來解決某些設計挑戰。

參照計數指標:Rc 與 Arc 的差異

Rust 提供兩種參照計數指標,它們各自適用於不同的場景:

  • Rc (Reference Counted) - 單執行緒環境下的參照計數智慧指標,實作對物件的分享所有權
  • Arc (Atomic Reference Counted) - 多執行緒環境下的參照計數智慧指標,能夠安全地跨執行緒分享物件所有權

單執行緒與多執行緒物件的區別

在許多程式語言中,我們常討論函式或物件是否「執行緒安全」。然而,Rust 的設計哲學與此有所不同。在 Rust 中,所有程式碼預設都是安全的,但某些物件可以跨執行緒移動或同步,而其他物件則不能。

這種特性源自物件是否實作了 SendSync 特徵:

  • Rc 不提供 SendSync(實際上,Rc 明確標記這些特徵為未實作),因此 Rc 只能在單一執行緒中使用
  • Arc 實作了 SendSync,所以可以安全地用於多執行緒程式碼

值得注意的是,Arc 使用原子計數器,這是平台相依的,通常在作業系統或 CPU 層級實作。原子操作比一般的算術運算成本更高,所以只有在需要原子性時才使用 Arc

當玄貓在開發高併發系統時,我常需要在效能和安全性之間取得平衡。Arc 雖然有額外成本,但在多執行緒環境中是不可或缺的工具。

內部可變性:突破借用檢查器的限制

在某些情況下,Rust 的借用檢查器可能無法提供足夠的彈性來處理可變參照。這就是內部可變性發揮作用的地方。雖然聽起來像是一種逃生艙,但它並不會破壞 Rust 的安全保證,仍然允許我們寫出安全的程式碼。

為了實作內部可變性,Rust 提供了兩種特殊類別:

  1. Cell - 透過移動值進出自身來實作可變性
  2. RefCell - 允許我們借用參照,在執行期進行借用檢查

大多數情況下,RefCellCell 更實用,因為它允許我們借用參照,而不是像 Cell 那樣移動值進出自身。

RefCellCell 本質上是提供了更多資訊給 Rust 編譯器,告訴它我們想如何借用資料。編譯器雖然很強大,但在靈活性上有所限制,有些完全安全的程式碼可能無法透過編譯,因為編譯器無法理解我們的意圖。

在實踐中,我發現 RefCellCell 的使用場景相對有限。如果發現自己頻繁使用這些工具來繞過借用檢查器,可能需要重新思考程式設計方法。它們主要用於特定情況,例如需要可變存取的容器和資料結構。

多執行緒的內部可變性

CellRefCell 的一個限制是它們只適用於單執行緒應用程式。如果需要跨執行緒的安全性,可以使用 MutexRwLock,它們提供了相同的內部可變性功能,但可以跨執行緒使用。這些通常與 Arc 搭配使用,而不是 Rc

實作雙向連結串列:Rc 與 RefCell 的實際應用

讓我們看一個實際例子,展示如何使用 RcRefCell 來實作雙向連結串列。這在僅使用 Box 的情況下是不可能的,因為 Box 不允許分享所有權。

use std::cell::RefCell;
use std::rc::Rc;

struct ListItem<T> {
    prev: Option<ItemRef<T>>,  // 指向前一個節點的指標
    data: Box<T>,              // 資料仍然儲存在 Box 中
    next: Option<ItemRef<T>>,  // 指向下一個節點的指標
}

// 類別名,讓程式碼更簡潔
type ItemRef<T> = Rc<RefCell<ListItem<T>>>;

struct DoublyLinkedList<T> {
    head: ItemRef<T>,
}

impl<T> ListItem<T> {
    fn new(data: T) -> Self {
        ListItem {
            prev: None,
            data: Box::new(data),  // 資料移入 Box 中
            next: None,
        }
    }
    
    fn data(&self) -> &T {
        self.data.as_ref()
    }
}

impl<T> DoublyLinkedList<T> {
    fn new(data: T) -> Self {
        DoublyLinkedList {
            head: Rc::new(RefCell::new(ListItem::new(data))),
        }
    }
    
    fn append(&mut self, data: T) {
        // 首先找到串列尾端的指標
        let tail = Self::find_tail(self.head.clone());
        // 為新專案建立指標
        let new_item = Rc::new(RefCell::new(ListItem::new(data)));
        // 更新新專案的 prev 指標,指向前一個尾端
        new_item.borrow_mut().prev = Some(tail.clone());
        // 更新前一個尾端的 next 指標,指向新的尾端
        tail.borrow_mut().next = Some(new_item);
    }
    
    fn head(&self) -> ItemRef<T> {
        self.head.clone()
    }
    
    fn tail(&self) -> ItemRef<T> {
        Self::find_tail(self.head())
    }
    
    fn find_tail(item: ItemRef<T>) -> ItemRef<T> {
        if let Some(next) = &item.borrow().next {
            // 如果 next 指標不為空,則遞迴繼續搜尋
            Self::find_tail(next.clone())
        } else {
            // 如果 next 指標為空,則我們找到了尾端
            item.clone()
        }
    }
}

這個雙向連結串列的實作展示了 RcRefCell 的強大功能:

  1. ListItem 結構:每個節點包含三個部分 - 指向前一個節點的指標、資料、以及指向下一個節點的指標。

  2. ItemRef 類別名Rc<RefCell<ListItem<T>>> 這個組合允許多個所有者分享同一個節點,同時還能修改其內容。

  3. append 方法

    • 先找到當前串列的尾端
    • 建立新節點
    • 設定新節點的 prev 指向當前尾端
    • 更新當前尾端的 next 指向新節點
  4. find_tail 方法

    • 使用遞迴方式尋找串列尾端
    • 如果當前節點的 next 不為 None,則繼續查詢
    • 如果當前節點的 next 為 None,則回傳當前節點

相比僅使用 Box 的單向連結串列,這個版本雖然更複雜,但提供了更大的靈活性。透過 RcRefCell 的結合,我們能夠實作節點的分享所有權和內部可變性。

在開發過程中,玄貓發現這種模式在處理複雜、相互關聯的資料結構時特別有用。雖然增加了一些複雜度,但提供的靈活性往往是值得的。

寫入時複製:函式程式設計的實用模式

在本文前面,我們討論了避免不必要的複製。然而,在某些情況下,複製資料比就地修改更為可取。這種模式具有一些非常好的特性,特別是如果你偏好函式程式設計模式。

你可能沒有聽說過寫入時複製 (clone on write),但你可能熟悉寫入時複製 (copy on write)。

寫入時複製是一種設計模式,其中資料從不在原地修改。相反,每當需要更改資料時,它會被複製到新位置並進行修改,然後回傳對新資料副本的參照。一些程式語言將此模式作為原則強制執行,例如 Scala,其中資料結構被分類別可變或不可變,所有不可變結構都實作寫入時複製。流行的 JavaScript 函式庫mmutable.js 完全根據這種模式,所有資料結構修改都會產生資料的新副本。

例如,使用寫入時複製的串列或陣列,append 操作將回傳一個包含所有舊元素加上新附加元素的新串列,同時保持原始專案串列不變。程式設計師假設編譯器可以處理最佳化舊資料的清理。

在 Rust 中,這種模式被稱為寫入時複製 (clone on write),因為它依賴於 Clone 特徵。Clone 有一個表親 Copy 特徵,它們的區別在於 Copy 表示按位複製(即字面上將物件的位元組複製到新的記憶體位置),而 Clone 是顯式複製。對於 Clone,我們呼叫物件的 clone() 方法來複製它,但 Copy 透過指定隱式發生(即 let x = y;)。

Clone 特徵通常透過 #[derive(Clone)] 自動實作,但也可以手動實作。這種模式在處理大量資料或需要保持資料歷史版本時特別有用。

在 Rust 中平衡安全性和彈性

Rust 的記憶體管理機制非常強大,但有時也會感到限制。智慧指標和內部可變性為我們提供了在保持 Rust 安全保證的同時增加程式設計彈性的方法。

在實際專案中,玄貓發現這些工具的使用應該謹慎與有目的性。過度使用 RcRefCell 或其他逃生艙可能表明程式設計方法需要重新考慮。然而,在適當的情況下,它們可以成為解決複雜問題的關鍵工具。

參照計數智慧指標和內部可變性的結合使得實作複雜的資料結構成為可能,同時保持 Rust 的安全保證。從實作雙向連結串列到採用函式程式設計模式,這些工具擴充套件了 Rust 的表達能力,同時保持了其核心優勢 - 記憶體安全和並發安全。

隨著你在 Rust 旅程中的深入,這些進階技術將成為你工具箱中的重要工具,讓你能夠在不犧牲安全性的前提下解決各種程式設計挑戰。

Rust 的 Clone on Write 智慧指標與實作策略

在開發高效能系統時,記憶體管理常是效能最佳化關鍵。Rust 提供了一種稱為「寫入時複製」(Clone on Write) 的技術,讓我們能夠延遲資料的複製操作,只在確實需要修改時才進行複製,從而大幅減少不必要的記憶體使用。

Rust 中實作 Clone on Write 的三種智慧指標

Rust 標準函式庫了三種關鍵的智慧指標來實作 Clone on Write 模式:

  1. Cow (Copy on Write) - 一個根據列舉的智慧指標,提供了方便的語義
  2. Rc (Reference Counted) - 單執行緒環境下的參照計數智慧指標,透過 make_mut() 方法提供寫入時複製功能
  3. Arc (Atomic Reference Counted) - 多執行緒環境下的原子參照計數指標,同樣支援 make_mut() 方法

這三種智慧指標各有優勢,選擇哪一種取決於應用場景的需求。接下來讓我們深入瞭解 Cow 的實作原理。

Cow 智慧指標的內部結構

Cow 是 Rust 標準函式庫個相當優雅的設計。它的型別簽名如下:

pub enum Cow<'a, B> where
    B: 'a + ToOwned + ?Sized, {
    Borrowed(&'a B),
    Owned(<B as ToOwned>::Owned),
}

這個列舉包含兩個變體:

  • Borrowed - 持有一個借用的參照 &'a B
  • Owned - 持有一個擁有所有權的值 <B as ToOwned>::Owned

Cow 的核心思想是:它可以開始時只持有資料的參照,但在需要修改時,會自動將資料複製出來並轉換為擁有所有權的版本。這種方式避免了不必要的複製操作,只在確實需要修改時才進行複製。

需要注意的是,與 Box 不同,Cow 中的資料不一定設定在堆積積。如果希望在 Cow 中使用堆積積定的資料,需要在 Cow 內部使用 Box,或者直接使用 RcArc

此外,Rust 的 Clone on Write 不是語言層級的特性,需要明確使用 Cow 特徵來實作。

使用 Cow 實作不可變單向連結串列

為了展示 Cow 的實際應用,讓我們更新先前的單向連結串列範例,使其變為不可變的資料結構。首先,我們來看 ListItem 的實作:

#[derive(Clone)]
struct ListItem<T>
where
    T: Clone,
{
    data: Box<T>,
    next: Option<Box<ListItem<T>>>,
}

impl<T> ListItem<T>
where
    T: Clone,
{
    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)
        } 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()
    }
}

在這個實作中,我們為 ListItem 匯出了 Clone 特徵,這是使用 Cow 的必要條件,因為 Cow 依賴於 Clone 特徵的行為。

ListItem 結構包含:

  • data - 一個 Box<T> 型別的欄位,用於儲存實際資料
  • next - 一個 Option<Box<ListItem<T>>> 型別的欄位,指向下一個節點

方法包括:

  • new() - 建立一個新的 ListItem
  • next() - 取得下一個節點的參照
  • mut_tail() - 遞迴尋找串列的最後一個節點
  • data() - 取得節點中的資料參照

接下來,讓我們看如何在單向連結串列中使用 Cow

#[derive(Clone)]
struct SinglyLinkedList<'a, T>
where
    T: Clone,
{
    head: Cow<'a, ListItem<T>>,
}

impl<'a, T> SinglyLinkedList<'a, T>
where
    T: Clone,
{
    fn new(data: T) -> Self {
        SinglyLinkedList {
            head: Cow::Owned(ListItem::new(data)),
        }
    }

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

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

在這個實作中,我們使用 Cow 來儲存串列的頭節點:

  1. SinglyLinkedList 結構中的 head 欄位使用 Cow<'a, ListItem<T>> 型別,這允許我們延遲複製操作。

  2. 我們必須為結構體加入生命週期引數 'a,這樣編譯器就能知道結構體和 head 引數具有相同的生命週期。

  3. new() 方法使用 Cow::Owned 初始化串列的頭節點,表示我們擁有這個節點的所有權。

  4. append() 方法的簽名發生了變化 - 它不再需要可變的 self,而是回傳一個全新的串列。這是不可變資料結構的典型模式。

  5. 呼叫 to_mut() 觸發了寫入時複製 (Clone on Write) 操作,這會遞迴發生,透過取得頭節點的可變參照。

使用 Cow 的好處是,當我們只讀取資料時,不會進行任何複製。只有當我們需要修改資料(如 append 操作)時,才會觸發複製。這在處理大型資料結構時特別有效,可以顯著減少記憶體使用和提高效能。