Rust 的記憶體管理機制確保了程式在編譯時期就能避免許多常見的記憶體錯誤。字串型別 String 儲存在堆積上,具有可變性,並透過所有權系統管理其生命週期。當 String 變數超出作用域時,其記憶體會自動釋放,避免了記憶體洩漏。而字串常數 string literal 儲存在靜態儲存區,不可變,生命週期與整個程式相同。Rust 的所有權系統確保每個值都只有一個所有者,透過移動語義,指定操作會轉移所有權而非複製資料,避免了潛在的雙重釋放錯誤。借用機制則允許在不轉移所有權的情況下使用資料,可變借用允許修改借用的資料,但同一時間只能有一個可變借用或多個不可變借用,避免資料競爭。切片則提供了更精細的資料存取方式,允許操作字串或其他集合的連續部分,無需複製整個資料結構。

為什麼字串(String)可變而字串常數(string literal)不可變?

在探討Rust的記憶體管理機制之前,我們需要了解字串(String)與字串常數(string literal)之間的差異。字串常數是不可變的,而字串(String)則是可變的。這種差異源於它們在記憶體中的儲存方式。

字串的記憶體分配

字串(String)型別是可變的,其內容可以在執行時動態更改。由於在編譯時無法預知所需的空間大小,因此需要在堆積(heap)上分配一些空間來儲存它。處理字串涉及向分配器(allocator)請求記憶體,並在不再使用時將其傳回給分配器。前者由程式設計師完成:我們呼叫String::from函式來請求記憶體。後者的處理方式因程式語言而異。有些語言使用垃圾收集器(garbage collector)來追蹤未使用的記憶體並釋放它。其他語言則將釋放記憶體的責任交給程式設計師。如果未能正確釋放記憶體,可能會導致不同的記憶體錯誤,例如:

  • 記憶體洩漏(Memory leaks):即使不再使用,記憶體仍未被釋放
  • 提前釋放記憶體
  • 重複釋放記憶體

Rust採用不同的方法,避免了這些可能的記憶體錯誤。當擁有某塊記憶體的變數的作用域結束時,會自動呼叫一個名為drop的函式來釋放記憶體。釋放記憶體的程式碼是在drop函式中定義的。

fn main() {
    let x = String::from("hello rust");
} // x的作用域在此結束,因此x被丟棄

移動(Move)、複製(Copy)和克隆(Clone)

移動、複製和克隆是Rust所有權原則中的基本概念。在本文中,我們將學習這些概念,瞭解Rust中值的移動、複製和克隆的含義。

移動

原始資料型別(如整數、布林值和字元)實作了Copy特徵。因此,當一個原始資料型別的變數被指定給另一個變數時,其值會被複製。對於未實作Copy特徵的資料型別,其值預設會被移動而不是被複製。例如,所有String和向量等資料型別的操作都是移動操作。讓我們來看一些例子以更好地理解它:

let x = 10;
let y = x;

在上述程式碼片段中,我們將值10指定給變數x,然後將x指定給ylet y = x;。現在,變數xy都具有值10。由於這些變數的值在編譯時已知,因此它們被推播到堆積疊(stack)記憶體中。

現在,讓我們考慮一個類別似的例子,但使用的是String資料型別的變數:

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

讓我們看看String資料型別在記憶體中是如何儲存的。在上述程式碼片段中,當String::from("hello")向分配器請求記憶體以在堆積上儲存文字"hello"時,分配器會在堆積上尋找空間,分配空間以儲存文字"hello",並將指向該記憶體緩衝區起始位置的指標傳回給我們的程式。傳回的指標與長度和容量一起構成了字串的元件。字串的元件儲存在堆積疊上,而其內容儲存在堆積上的緩衝區中,如圖3.2所示:

圖3.2:String的記憶體表示

@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultTextAlignment center
skinparam rectangleBackgroundColor #F5F5F5
skinparam rectangleBorderColor #333333
skinparam arrowColor #333333

title 圖3.2:String的記憶體表示

rectangle "pointer" as node1
rectangle "length" as node2
rectangle "capacity" as node3
rectangle "contents" as node4

node1 --> node2
node2 --> node3
node3 --> node4

@enduml

圖表翻譯: 此圖示呈現了String在記憶體中的儲存結構。其中,Stack儲存了指向Heap中實際內容的指標、長度和容量等資訊。

可以使用方法as_ptr()len()capacity()分別列印字串的指標、長度和容量,如下所示:

fn main() {
    let s1 = String::from("hello");
    println!("s1 = {}", s1);
    println!("pointer = {:?}, length = {}, capacity = {}", s1.as_ptr(), s1.len(), s1.capacity());
}

輸出結果:

s1 = hello
pointer = 0x5560cda9fad0, length = 5, capacity = 5

多次執行程式時,為指標s1.as_ptr()列印的記憶體位址可能會有所不同,因為指標的值代表動態分配的記憶體位址,而且由於作業系統採用位址空間佈局隨機化(ASLR)和其他動態分配機制,因此該位址在程式執行之間會發生變化。ASLR確保記憶體位址不可預測,從而使惡意攻擊者更難利用與記憶體相關的漏洞。

現在,讓我們看看當我們將字串s1指定給另一個字串s2時會發生什麼。堆積疊上的值(即指標、長度和容量)被複製,而儲存在堆積上的資料則不會被複製。將s1指定給s2後的記憶體表示如圖3.3所示:

圖3.3:將s1指定給s2時的記憶體表示

@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultTextAlignment center
skinparam rectangleBackgroundColor #F5F5F5
skinparam rectangleBorderColor #333333
skinparam arrowColor #333333

title 圖3.3:將s1指定給s2時的記憶體表示

rectangle "pointer" as node1
rectangle "length" as node2
rectangle "capacity" as node3
rectangle "contents" as node4

node1 --> node2
node2 --> node3
node3 --> node4

@enduml

圖表翻譯: 此圖示呈現了將s1指定給s2後的記憶體結構。可以看到,s1和s2分享同一個Heap中的內容。

這種行為可以透過範例進行驗證,如下所示:

fn main() {
    let s1 = String::from("hello");
    println!("s1 = {}", s1);
    println!("{:?}, {}, {}", s1.as_ptr(), s1.len(), s1.capacity());
    let s2 = s1; // 將s1指定給s2
    println!("s2 = {}", s2);
    println!("{:?}, {}, {}", s2.as_ptr(), s2.len(), s2.capacity());
}

輸出結果:

s1 = hello
s1: pointer = 0x562e34eb6ad0, length = 5, capacity = 5
s2 = hello
s2: pointer = 0x562e34eb6ad0, length = 5, capacity = 5

輸出結果清楚地表明,字串s1s2的指標、長度和容量是相同的。但是,可能會自然而然地產生一個問題:誰擁有堆積上的資料?是s1還是s2,還是兩者兼有?所有權規則規定,一個值只能有一個所有者。因此,這裡允許有兩個所有者嗎?如果允許,哪個所有者應該釋放資料?讓我們透過範例來找出這些問題的答案。

#### 內容解密:

上述範例演示了當將一個String指定給另一個String時,實際上是進行了一次移動操作。這意味著原始變數s1不再有效,因為其值已經被移動到s2。這種機制避免了因為多個變數指向同一塊記憶體而導致的重複釋放問題。

讓我們試著在將s1指定給s2之後列印字串s1的值或其其中一個元件,如下所示:

fn main() {
    let s1 = String::from("hello");
    println!("s1 = {}", s1);
    let s2 = s1; // 將s1指定給s2
    println!("s2 = {}", s2);
    println!("s1 = {}", s1);
}

上述程式碼片段的輸出結果如圖3.4所示:

圖3.4:存取已經被移動的字串s1所導致的錯誤

輸出結果清楚地表明,由於存取已經被移動的值s1而導致錯誤。String型別並未實作Copy特徵,因此s1的值被移動而不是被複製。這裡,s1失效,其指標、長度和容量也不再有效。移動操作透過確保底層字串資料的單一所有權來防止因重複釋放而導致的記憶體錯誤。

s1指定給s2後,s1s2的記憶體表示如圖3.5所示:

圖3.5:s1被移動到s2後的記憶體表示

@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultTextAlignment center
skinparam rectangleBackgroundColor #F5F5F5
skinparam rectangleBorderColor #333333
skinparam arrowColor #333333

title 圖3.5:s1被移動到s2後的記憶體表示

rectangle "pointer" as node1
rectangle "length" as node2
rectangle "capacity" as node3
rectangle "contents" as node4
rectangle "invalid" as node5

node1 --> node2
node2 --> node3
node3 --> node4
node4 --> node5

@enduml

圖表翻譯: 此圖示呈現了s1被移動到s2後的記憶體結構。可以看到,s1已經失效,而s2指向了原來的Heap中的內容。

當值被傳遞給函式時,也可以看到完全相同的行為。傳遞型別為String、向量等的值會使它們在原始作用域中不可用,因為它們被移動了。另一方面,如果傳遞型別為整數的值作為引數,則由於其值被複製,因此在原始作用域中仍然可用。

#### 內容解密:

上述說明演示了Rust中所有權和移動語義的基本原理。這種設計確保了Rust程式的安全性和效能,避免了許多常見的記憶體錯誤。

綜上所述,Rust透過其獨特的所有權系統和借用檢查器,提供了一種安全且高效的方式來管理記憶體資源,從而避免了許多常見的記憶體相關問題。

Rust 程式語言中的所有權、借用與切片

Rust 程式語言採用獨特的所有權系統來管理記憶體,這使得開發者能夠在不使用垃圾回收機制的情況下,確保記憶體安全。本篇文章將探討 Rust 中的所有權、借用和切片等重要概念,並透過例項程式碼進行詳細說明。

所有權的基本概念

在 Rust 中,每個值都有一個所謂的「所有者」。當所有者超出作用域時,該值將被丟棄。以下是一個簡單的例子:

fn main() {
    let s = String::from("hello");  // s 是 "hello" 的所有者
    println!("{}", s);
}  // s 超出作用域,"hello" 被丟棄

內容解密:

  • String::from("hello") 在堆積上分配記憶體儲存字串 “hello”。
  • s 是這個字串的所有者,當 s 超出作用域時,相關記憶體會被釋放。

移動(Move)語義

當一個值被指定給另一個變數時,所有權會被移動。以下是一個例子:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // s1 的所有權被移動到 s2
    println!("{}", s2);
    // println!("{}", s1);  // 編譯錯誤:s1 不再有效
}

內容解密:

  • s1 的所有權被移動到 s2,因此 s1 不再有效。
  • 這種機制避免了多個變數指向同一塊記憶體的問題,從而確保了記憶體安全。

複製(Clone)

如果需要深度複製資料,可以使用 clone 方法:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();  // 複製 s1 的內容到 s2
    println!("s1 = {}, s2 = {}", s1, s2);
}

內容解密:

  • s1.clone() 複製了 s1 的內容,並在堆積上分配新的記憶體給 s2
  • s1s2 現在是兩個獨立的字串變數。

借用(Borrowing)

Rust 允許函式借用值,而不需要取得其所有權。這可以透過參照(reference)來實作:

fn main() {
    let x = String::from("hello");
    foo_str_ref(&x);  // 傳遞 x 的參照
    println!("{}", x);  // x 仍然有效
}

fn foo_str_ref(s: &String) {
    println!("{}", s);
}

內容解密:

  • &x 建立了一個指向 x 的參照,並將其傳遞給 foo_str_ref
  • foo_str_ref 借用了 x 的值,但沒有取得其所有權,因此 x 在函式呼叫後仍然有效。

可變借用(Mutable Borrowing)

要修改借用的值,需要使用可變參照(mutable reference):

fn main() {
    let mut s = String::from("hello");
    update_string(&mut s);  // 傳遞 s 的可變參照
    println!("{}", s);
}

fn update_string(s: &mut String) {
    s.push_str(" rust");  // 修改 s 的內容
}

內容解密:

  • &mut s 建立了一個可變參照,允許 update_string 修改 s 的內容。
  • s.push_str(" rust") 在原有的字串後面追加 " rust"。

切片(Slice)

切片是一種對集合(如字串或向量)中連續元素序列的參照。以下是一個例子:

fn main() {
    let x = String::from("Shaurya Pulkit");
    let word1 = &x[0..7];  // 切片從索引 0 到 6
    let word2 = &x[8..14];  // 切片從索引 8 到 13
    println!("{}: {}", word1, word1.len());
    println!("{}: {}", word2, word2.len());
}

內容解密:

  • &x[0..7] 建立了一個切片,包含 x 中索引 0 到 6 的字元。
  • &x[8..14] 建立了另一個切片,包含 x 中索引 8 到 13 的字元。

結構體、列舉與集合

在 Rust 程式語言中,結構體(Structs)、列舉(Enums)以及集合(Collections)是三個非常重要且實用的概念。本章將探討這些主題,幫助讀者瞭解如何有效地使用它們。

結構體(Structs)

結構體允許我們將不同型別的資料組合在一起。在前面的章節中,我們已經接觸過元組(Tuple),它也能夠將不同資料型別組合在一起。然而,結構體與元組之間存在一個關鍵差異:結構體中的每個資料專案都有一個名稱,並且在存取或設定這些專案時,不受順序的限制。

定義結構體

在 Rust 中,結構體可以使用 struct 關鍵字來定義,後面跟著結構體的名稱和一對大括號 {},大括號內包含結構體各欄位的定義。以下是一個 Student 結構體的定義範例:

struct Student {
    name: String,
    id: String,
    age: u8,
    class: u8,
}

初始化與使用結構體

定義好結構體後,我們可以建立結構體的例項並在程式中使用它。建立結構體例項的方式是提供結構體的名稱,後面跟著一對大括號 {},大括號內包含欄位名稱和對應的值。以下是一個建立 Student 結構體例項的範例:

let student1 = Student {
    name: String::from("Abhishek"),
    age: 20,
    id: String::from("2023EE50401"),
    class: 10,
};

在上述範例中,我們可以看到欄位值的順序與結構體定義中的順序不同,這是允許的。存取結構體欄位值的方式是使用點(.)表示法,即 <struct_name>.<field_name>。例如,要存取 student1class 欄位,可以寫成 student1.class

#### 內容解密:

此段程式碼展示瞭如何定義和使用結構體。Student 結構體具有四個欄位:nameidageclass。透過建立 student1 例項,我們可以存取和操作這些欄位的值。

列舉(Enums)

列舉允許我們定義一個型別和該型別的一組命名變體。在 Rust 中,列舉是一種非常有用的資料型別,可以用來表示多種不同的狀態或值。

集合(Collections)

集合是包含多個值的資料型別。在 Rust 的標準函式庫中,有許多常見的集合型別,如向量(Vectors)、字串(Strings)和雜湊對映(Hash Maps)。

常見集合型別

本章將介紹一些常見的集合型別,包括向量、字串和雜湊對映。這些集合型別在實際程式設計中非常有用,可以幫助我們高效地管理和操作資料。

向量(Vectors)

向量是一種動態陣列,可以根據需要自動調整大小。以下是建立和使用向量的範例:

let mut vec = Vec::new();
vec.push(1);
vec.push(2);
vec.push(3);
println!("{:?}", vec);

#### 內容解密:

此段程式碼展示瞭如何建立和使用向量。我們首先使用 Vec::new() 建立一個空向量,然後使用 push 方法新增元素。最後,使用 println! 宏印出向量的內容。

本章重點

  • 結構體允許將不同型別的資料組合在一起,並且每個資料專案都有名稱。
  • 列舉允許定義一個型別和該型別的一組命名變體。
  • 集合是包含多個值的資料型別,如向量、字串和雜湊對映。

練習題

  1. 如何定義一個結構體?
  2. 列舉的用途是什麼?
  3. 常見的集合型別有哪些?