Rust 的所有權系統是其核心特性,用於管理記憶體並避免常見的記憶體錯誤。理解所有權和移動語義對於編寫高效且安全的 Rust 程式碼至關重要。本文將詳細介紹如何正確地從向量中移動元素,使用 Option 型別來處理可能不存在的值,以及 Copy 特性的限制和自定義型別實作。此外,還會探討 RcArc 如何實作分享所有權,以及如何使用弱參考來避免迴圈參照導致的記憶體洩漏。最後,將深入研究 Rust 中的參照型別,包括分享參照和可變參照,並與 C++ 的參照進行比較,以幫助讀者更好地理解 Rust 的所有權系統。

Rust 中的所有權與移動語義

在 Rust 程式語言中,所有權(ownership)是一個核心概念,用於管理記憶體資源的使用。簡單來說,每個值都有一個所謂的「擁有者」(owner),而當這個擁有者不再需要這個值時,Rust 就會自動釋放這個值所佔用的記憶體。這種機制避免了記憶體洩漏和懸掛指標等常見問題。

從向量中移動元素

考慮以下程式碼,試圖從一個向量(Vec)中移動元素:

let mut v = vec!["liberté".to_string(), "égalité".to_string(), "fraternité".to_string()];
let third = v[2];

Rust 編譯器會拒絕這段程式碼,並給出錯誤訊息:

error[E0507]: cannot move out of indexed content
--> ownership_move_out_of_vector.rs:14:17
|
14 | let third = v[2];
| ^^^^
| |
| help: consider using a reference instead `&v[2]`
| cannot move out of indexed content

內容解密:

這段錯誤訊息表明 Rust 不允許直接從向量中透過索引移動元素。因為這樣做會使向量處於不一致的狀態。Rust 建議使用參考(&v[2])來存取元素,而不移動它。

如何正確地從向量中移動元素

如果確實需要從向量中移動元素,有幾種方法可以做到:

  1. 從向量的末端彈出元素

    let fifth = v.pop().unwrap();
    assert_eq!(fifth, "105");
    

    內容解密:

    pop 方法移除並傳回向量的最後一個元素。如果向量為空,則傳回 None。這裡使用 unwrap 來取得值,假設向量不為空。

  2. 從向量的中間移動元素,並將最後一個元素移到其位置

    let second = v.swap_remove(1);
    assert_eq!(second, "102");
    

    內容解密:

    swap_remove 方法移除指定索引的元素,並將向量的最後一個元素移到該索引處。這保持了向量的連續性,但改變了元素的順序。

  3. 用另一個值替換被移動的元素

    let third = std::mem::replace(&mut v[2], "substitute".to_string());
    assert_eq!(third, "103");
    

    內容解密:

    std::mem::replace 方法用新的值替換指定位置的值,並傳回舊值。這保持了向量的一致性。

遍歷向量並移動元素

當直接將向量傳遞給 for 迴圈時,向量會被移動到迴圈內部,並且在迴圈內部被解構為其元素:

for mut s in v {
    s.push('!');
    println!("{}", s);
}

內容解密:

在這個迴圈中,每個元素被移動到變數 s 中,並且由於 s 現在擁有該字串,因此可以在迴圈體內修改它。

使用 Option 型別來管理值的存在與否

有時,將值的型別改為 Option<T> 可以更好地管理值的存在與否。例如:

struct Person { name: Option<String>, birth: i32 }
let mut composers = Vec::new();
composers.push(Person { name: Some("Palestrina".to_string()), birth: 1525 });
let first_name = composers[0].name.take();
assert_eq!(first_name, Some("Palestrina".to_string()));
assert_eq!(composers[0].name, None);

內容解密:

使用 Option<String> 作為 name 欄位的型別,允許 nameNonetake 方法用於取出 Some 值,並將欄位設為 None

複製型別(Copy Types)

對於像整數或字元這樣的簡單型別,Rust 使用複製語義而不是移動語義。當指定或傳遞這些型別的值時,會建立一個新的副本,而不是移動原始值。

let num1: i32 = 36;
let num2 = num1;

內容解密:

在這個例子中,num2num1 的一個獨立副本。修改 num2 不會影響 num1

Copy 特性的限制與自定義型別實作

在 Rust 中,Copy 特性代表一個型別可以進行簡單的位元複製,而不需要執行任何額外的操作或資源管理。然而,並非所有型別都能滿足這個條件,像是 StringBox<T>File 等型別,因為它們涉及堆積疊分配的資源管理或作業系統資源的處理。

為何某些型別無法實作 Copy

  • 當一個型別在其值被丟棄時需要執行特殊操作(例如釋放資源),它就無法實作 Copy。像是 Vec 需要釋放其元素、File 需要關閉檔案控制程式碼、MutexGuard 需要解鎖互斥鎖等。
  • 對這些型別進行簡單的位元複製會導致不清楚哪個值負責管理原始資源,從而引起資源管理的混亂。

自定義型別的 Copy 實作

預設情況下,使用者自定義的結構體和列舉型別並不實作 Copy。但如果結構體的所有欄位都實作了 Copy,那麼可以透過在定義上方新增 #[derive(Copy, Clone)] 屬性來使該結構體實作 Copy

#[derive(Copy, Clone)]
struct Label { number: u32 }

這樣,原本無法編譯透過的程式碼(因傳遞 Label 給函式而移動所有權)就能正常運作。

內容解密:

  1. #[derive(Copy, Clone)]:這是一個屬性宏,用於自動為自定義型別實作 CopyClone 特性。只有當所有欄位都支援 Copy 時,才能使用此屬性。
  2. struct Label { number: u32 }:由於 u32Copy 型別,因此整個 Label 結構體可以被標記為 Copy

然而,如果結構體的欄位包含非 Copy 型別(如 String),嘗試使用 #[derive(Copy, Clone)] 會導致編譯錯誤。

#[derive(Copy, Clone)]
struct StringLabel { name: String } // 編譯錯誤:String 不是 Copy 型別

內容解密:

  1. String 不是 Copy:因為 String 管理堆積上的緩衝區,簡單複製會導致重複釋放同一個記憶體區域,因此 Rust 禁止 StringCopy
  2. 錯誤訊息解讀:編譯器明確指出錯誤源於 name 欄位的型別(String)未實作 Copy

為何 Rust 不自動推斷型別為 Copy

  • 將一個型別標記為 Copy 代表了對該型別的特定承諾:它的所有操作都是簡單且可預測的。這限制了該型別能夠包含的成員型別。
  • 如果未來需要修改型別使其不再是 Copy,現有的程式碼可能需要大幅改動。

與 C++ 的比較

C++ 允許透過過載指定運算子和自定義複製/移動建構函式來定製型別的行為。Rust 則堅持簡單、一致的規則:所有移動都是淺複製,源值會變為未初始化狀態;Copy 型別的複製則保持源值有效。

此圖示說明瞭 C++ 和 Rust 在處理複製和移動語意上的不同設計哲學。

圖示內容解密:

  1. C++ 的靈活性:C++ 允許開發者自定義複製和移動操作,但這也引入了潛在的複雜度和錯誤風險。
  2. Rust 的設計哲學:Rust 透過簡化這些操作的語意,確保程式碼的可讀性和安全性。

Rc 與 Arc:分享所有權的解決方案

對於某些情況,單一所有權模型可能過於嚴格。Rust 提供了 RcArc 兩種參照計數智慧指標,允許值在多個所有者之間分享,直到最後一個所有者釋放它。

使用 Rc 實作分享所有權

use std::rc::Rc;

let s: Rc<String> = Rc::new("shirataki".to_string());
let t: Rc<String> = s.clone();
let u: Rc<String> = s.clone();

此範例展示瞭如何使用 Rc 在多個變數之間分享同一個 String 例項。

內容解密:

  1. Rc::new:建立一個新的參照計數指標,並在堆積上分配對應的值。
  2. s.clone():增加參照計數,並傳回新的 Rc 指標指向同一個堆積分配值。
  3. 分享所有權:當最後一個 Rc 指標被丟棄時,Rust 自動釋放堆積上的值。

總之,Rust 透過嚴格的所有權和借用規則,以及提供像 RcArc 這樣的工具,在安全性和效能之間取得了良好的平衡。開發者應根據具體場景選擇適當的工具,以寫出高效且安全的程式碼。

參考計數字串與分享所有權的限制

在 Rust 中,Rc(Reference Counted)指標允許多個所有者分享同一個值。Figure 4-12 展示了一個參考計數字串,有三個參考指向它。 此圖示展示了 Rc 如何管理分享所有權。

你可以對 Rc<String> 直接使用 String 的方法:

assert!(s.contains("shira"));
assert_eq!(t.find("taki"), Some(5));
println!("{} are quite chewy, almost bouncy, but lack flavor", u);

內容解密:

  1. 直接方法呼叫:Rust 允許對 Rc<T> 直接呼叫 T 的方法,這裡的 stuRc<String> 例項。
  2. containsfind 方法:用於檢查字串是否包含特定子字串或查詢子字串的位置。
  3. println! 巨集:用於格式化輸出字串。

然而,Rc 指標所擁有的值是不可變的。如果你嘗試對 Rc<String> 進行修改:

s.push_str(" noodles");

Rust 將會報錯:

error: cannot borrow immutable borrowed content as mutable
--> ownership_rc_mutability.rs:12:5
|
12 | s.push_str(" noodles");
| ^ cannot borrow as mutable

內容解密:

  1. 不可變性Rc 指標保證其指向的值不可變,以確保多個所有者可以安全地分享該值。
  2. 錯誤訊息:錯誤訊息指出無法將不可變借用轉換為可變借用。

迴圈參考與記憶體洩漏

使用參考計數管理記憶體會遇到一個經典問題:當兩個或多個 Rc 值相互參考時,它們的參考計數永遠不會歸零,導致記憶體洩漏(Figure 4-13)。

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title Rust 所有權與移動語義深入解析

package "Rust 記憶體管理" {
    package "所有權系統" {
        component [Owner] as owner
        component [Borrower &T] as borrow
        component [Mutable &mut T] as mutborrow
    }

    package "生命週期" {
        component [Lifetime 'a] as lifetime
        component [Static 'static] as static_lt
    }

    package "智慧指標" {
        component [Box<T>] as box
        component [Rc<T>] as rc
        component [Arc<T>] as arc
        component [RefCell<T>] as refcell
    }
}

package "記憶體區域" {
    component [Stack] as stack
    component [Heap] as heap
}

owner --> borrow : 不可變借用
owner --> mutborrow : 可變借用
owner --> lifetime : 生命週期標註
box --> heap : 堆積分配
rc --> heap : 引用計數
arc --> heap : 原子引用計數
stack --> owner : 棧上分配

note right of owner
  每個值只有一個所有者
  所有者離開作用域時值被釋放
end note

@enduml

此圖示展示了兩個 Rc 物件之間的迴圈參考。

內容解密:

  1. 迴圈參考:兩個或多個 Rc 值相互參考,形成迴圈。
  2. 記憶體洩漏:由於參考計數永遠不會歸零,這些物件永遠不會被釋放,導致記憶體洩漏。

Rust 提供了內部可變性(interior mutability)機制,可以在某些情況下建立迴圈參考並導致記憶體洩漏。

弱參考(Weak Pointer)

為了避免迴圈參考,可以使用弱參考(std::rc::Weak)來打破迴圈。

參照(References)

Rust 中的參照是一種非擁有型指標,它們不會影響其指向值的生命週期。參照必須永遠有效,不能超過其指向值的生命週期。

建立和使用參照

讓我們建立一個文藝復興時期藝術家的表格,並列出他們著名的作品:

use std::collections::HashMap;
type Table = HashMap<String, Vec<String>>;

fn show(table: Table) {
    for (artist, works) in table {
        println!("works by {}:", artist);
        for work in works {
            println!(" {}", work);
        }
    }
}

fn main() {
    let mut table = Table::new();
    table.insert("Gesualdo".to_string(),
                 vec!["many madrigals".to_string(),
                      "Tenebrae Responsoria".to_string()]);
    table.insert("Caravaggio".to_string(),
                 vec!["The Musicians".to_string(),
                      "The Calling of St. Matthew".to_string()]);
    table.insert("Cellini".to_string(),
                 vec!["Perseus with the head of Medusa".to_string(),
                      "a salt cellar".to_string()]);
    show(table);
}

內容解密:

  1. HashMap 定義:定義了一個 Table 型別,它是一個從 StringVec<String> 的雜湊表。
  2. show 函式:遍歷 Table 並列印出藝術家及其作品。
  3. main 函式:建立並填充 Table,然後呼叫 show 函式列印結果。

然而,這段程式碼存在一個問題:當呼叫 show(table) 時,整個 table 結構被移動到函式中,呼叫者無法再使用它。

程式碼改進與未來趨勢預測

為了改進這段程式碼,可以考慮使用參照來避免移動整個結構。未來,Rust 可能會提供更多靈活的借用機制,以簡化類別似的操作。

深入理解Rust中的參照(References)

在Rust中,參照是一種允許函式存取或操作結構但不取得其所有權的機制。本章節將透過具體的範例來探討參照的概念及其在Rust中的重要性。

問題的由來:所有權移動(Move Semantics)

首先,我們來看一個簡單的範例,以瞭解所有權移動的問題。假設我們有一個HashMap,其中鍵是藝術家的名字,值是他們的作品列表。

let mut table = HashMap::new();
table.insert("Gesualdo".to_string(), vec!["many madrigals".to_string()]);

內容解密:

  • 建立一個新的HashMap例項。
  • 將藝術家名稱"Gesualdo"及其作品列表插入到table中。

如果我們寫了一個函式show來列印這個HashMap的內容:

fn show(table: HashMap<String, Vec<String>>) {
    for (artist, works) in table {
        println!("works by {}:", artist);
        for work in works {
            println!(" {}", work);
        }
    }
}

內容解密:

  • 函式show接收一個HashMap,其鍵是String,值是String的向量。
  • 透過外部迴圈迭代HashMap的每個條目,並列印藝術家的名字。
  • 內部迴圈迭代每個藝術家的作品列表,並列印出來。

當我們呼叫這個函式時:

show(table);
assert_eq!(table["Gesualdo"][0], "many madrigals");

Rust編譯器會報錯,因為在呼叫show(table)後,table的所有權已經被移動到函式內部,因此在外部無法再存取它。

解決方案:使用參照(References)

為瞭解決這個問題,我們可以使用參照。參照允許我們在不取得所有權的情況下存取或修改值。Rust中的參照分為兩種:分享參照(Shared References)和可變參照(Mutable References)。

分享參照(Shared References)

分享參照允許讀取值但不允許修改它。我們可以有多個分享參照指向同一個值。

fn show(table: &HashMap<String, Vec<String>>) {
    for (artist, works) in table {
        println!("works by {}:", artist);
        for work in works {
            println!(" {}", work);
        }
    }
}

內容解密:

  • 將函式引數改為分享參照&HashMap<String, Vec<String>>
  • 在函式內部,迭代分享參照所指向的HashMap,這會產生對鍵和值的分享參照。
  • 由於是分享參照,無法修改HashMap的內容。

呼叫方式也需要相應改變:

show(&table);

內容解密:

  • table的分享參照傳遞給show函式。
  • 由於是分享參照,table的所有權仍然保留在原處。

可變參照(Mutable References)

可變參照允許讀取和修改值,但同一時間只能有一個可變參照存在。

fn sort_works(table: &mut HashMap<String, Vec<String>>) {
    for (_artist, works) in table {
        works.sort();
    }
}

內容解密:

  • 將函式引數改為可變參照&mut HashMap<String, Vec<String>>
  • 在函式內部,迭代可變參照所指向的HashMap,並對每個作品列表進行排序。

呼叫方式:

sort_works(&mut table);

內容解密:

  • table的可變參照傳遞給sort_works函式。
  • 由於是可變參照,可以修改table中的內容。

Rust與C++參照的比較

Rust的參照與C++的參照有一些相似之處,但也有明顯的不同。最主要的區別在於Rust要求顯式地建立和解參照,而C++則是隱式的。

let x = 10;
let r = &x; // 建立分享參照
assert!(*r == 10); // 解參照