返回文章列表

Rust 記憶體管理與指標型別

Rust 語言以其獨特的記憶體管理機制和所有權系統聞名,本文探討 Rust 的指標型別、向量、切片和字串的內部運作方式,並解析 Rust 如何在編譯時期防止記憶體安全問題。文章涵蓋了不同指標型別的使用場景,例如參考、箱子以及原始指標,並闡述了 Rust

程式語言 系統程式設計

Rust 的記憶體管理機制與其他程式語言有著顯著區別,它採用所有權系統在編譯時期就避免了懸空指標、重複釋放等常見的記憶體錯誤。Rust 的值預設在堆積疊上分配,並透過所有權系統追蹤其生命週期,當值超出作用域時,其佔用的記憶體會自動釋放。對於需要在堆積上分配記憶體的情況,Rust 提供了箱子(Box)型別,以及更底層的原始指標(raw pointers)。然而,使用原始指標需要謹慎,因為 Rust 編譯器無法保證其安全性,需要開發者自行管理。此外,Rust 的參考型別也與 C/C++ 的指標有所不同,Rust 的參考預設不可變,且永遠不為空,可變參考則透過 &mut 標記,這些特性都提升了記憶體安全性。

Rust 的記憶體管理與指標型別

Rust 語言在設計上著重於最小化記憶體分配,以提高記憶體效率。與其他語言不同,Rust 的值預設是巢狀儲存的,不會自動在堆積(heap)上分配記憶體。例如,值 ((0, 0), (1440, 900)) 會被儲存為四個相鄰的整數,如果將其儲存在區域性變數中,這個區域性變數的大小就是四個整數的寬度,不需要在堆積上分配任何記憶體。

指標型別

Rust 提供了多種指標型別來滿足不同的需求,主要包括參考(references)、箱子(boxes)和原始指標(raw pointers)。

參考(References)

參考是 Rust 的基本指標型別,可以指向堆積疊(stack)或堆積上的任何值。&x 會產生對 x 的參考,而 *r 則會參考 r 所指向的值。與 C 和 C++ 中的 &* 運算子類別似,Rust 的參考也不會在超出範圍時自動釋放任何資源。

Rust 的參考與 C 指標有幾個關鍵的不同:

  • Rust 的參考永遠不會為空(null)。
  • Rust 的參考預設是不可變的(immutable),使用 &T 表示;可變參考使用 &mut T 表示。
  • Rust 會追蹤值的擁有權(ownership)和生命週期(lifetimes),從而在編譯時排除懸掛指標(dangling pointers)、重複釋放(double frees)和指標失效(pointer invalidation)等錯誤。

箱子(Boxes)

箱子是 Rust 中在堆積上分配值的簡單方式。使用 Box::new 可以在堆積上分配記憶體,並在超出範圍時自動釋放。例如:

let t = (12, "eggs");
let b = Box::new(t); // 在堆積上分配元組

這裡,b 的型別是 Box<(i32, &str)>。當 b 超出範圍時,除非它被移動(例如被傳回),否則它所指向的記憶體會被立即釋放。

原始指標(Raw Pointers)

Rust 也提供了原始指標型別 *mut T*const T,它們與 C++ 中的指標類別似。使用原始指標是不安全的,因為 Rust 不會追蹤它們所指向的內容。原始指標可能為空,或者指向已經被釋放或現在包含不同型別值的記憶體。

要解參照原始指標,必須在 unsafe 區塊中進行。unsafe 區塊是 Rust 提供給開發者自行處理安全性問題的高階語言功能。如果程式碼中沒有 unsafe 區塊,或者其中的程式碼是正確的,那麼 Rust 的安全性保證仍然有效。

陣列、向量和切片

Rust 提供了三種型別來表示記憶體中的一連串值:

  1. 陣列 [T; N]:具有固定大小 N 和型別 T 的陣列,其大小在編譯時確定,不能動態改變。
  2. 向量 Vec<T>:動態分配的可增長序列,其元素存在於堆積上,可以動態調整大小。
  3. 切片 &[T]&mut [T]:對其他值(如陣列或向量)中一系列元素的參考,可以是分享的或可變的。

對於這三種型別,v.len() 可以取得元素的數量,而 v[i] 可以存取第 i 個元素。Rust 會檢查索引是否在有效範圍內,如果超出範圍,表示式會 panic。

陣列的使用

陣列可以使用方括號內的一系列值來建立,也可以使用 [V; N] 語法建立一個具有相同值的陣列。例如:

let lazy_caterer: [u32; 6] = [1, 2, 4, 7, 11, 16];
let taxonomy = ["Animalia", "Arthropoda", "Insecta"];
let mut sieve = [true; 10000]; // 建立一個具有10,000個true值的陣列

陣列的長度是其型別的一部分,並且在編譯時固定。如果需要動態大小的陣列,通常會使用向量。

雖然陣列本身的方法有限,但 Rust 可以將對陣列的參考隱式轉換為切片,因此可以直接在陣列上呼叫切片的方法,例如:

let mut chaos = [3, 5, 4, 1, 2];
chaos.sort(); // sort是在切片上定義的方法
assert_eq!(chaos, [1, 2, 3, 4, 5]);

向量(Vectors)詳解

向量(Vec<T>)是一種可調整大小的元素陣列,元素型別為 T,並在堆積疊(heap)上分配記憶體。向量是 Rust 中非常重要的資料結構,用於儲存動態數量的元素。

建立向量

有多種方法可以建立向量。最簡單的方法是使用 vec! 巨集(macro),其語法與陣列字面值(array literal)非常相似:

let mut v = vec![2, 3, 5, 7];
assert_eq!(v.iter().fold(1, |a, b| a * b), 210);

內容解密:

  • vec! 巨集用於建立向量,等同於建立一個空向量並逐一推播元素。
  • let mut v 宣告一個可變的向量 v
  • assert_eq! 用於驗證向量的元素乘積是否等於 210。

由於這是一個向量,而非陣列,因此我們可以動態地新增元素:

v.push(11);
v.push(13);
assert_eq!(v.iter().fold(1, |a, b| a * b), 30030);

內容解密:

  • v.push(element) 方法用於向量末尾新增元素。
  • 每次新增元素後,向量的大小會動態調整。

我們還可以透過重複給定值來建立向量,語法同樣模仿陣列字面值:

fn new_pixel_buffer(rows: usize, cols: usize) -> Vec<u8> {
    vec![0; rows * cols]
}

內容解密:

  • vec![value; count] 語法用於建立一個包含 countvalue 的向量。
  • 此函式傳回一個初始化為零的畫素緩衝區向量,用於影像處理等場景。

另一種建立向量的方法是使用迭代器(iterator):

let v: Vec<i32> = (0..5).collect();
assert_eq!(v, [0, 1, 2, 3, 4]);

內容解密:

  • (0..5) 建立一個範圍迭代器,產生從 0 到 4 的數字。
  • .collect() 方法將迭代器的結果收集到一個向量中。
  • 需要顯式指定 v 的型別,以確定收集的具體型別。

向量的基本操作

與陣列類別似,向量支援切片(slice)方法:

let mut v = vec!["a man", "a plan", "a canal", "panama"];
v.reverse();
assert_eq!(v, vec!["panama", "a canal", "a plan", "a man"]);

內容解密:

  • v.reverse() 方法反轉向量中的元素順序。
  • 此方法實際定義在切片上,但可以透過向量隱式借用切片來呼叫。

向量的內部結構與容量管理

Vec<T> 由三部分組成:指向堆積上分配的緩衝區的指標、緩衝區的容量以及當前包含的元素數量(即長度)。當緩衝區達到其容量上限時,新增元素將導致重新分配更大的緩衝區,並將現有內容複製過去。

let mut v = Vec::with_capacity(2);
assert_eq!(v.len(), 0);
assert_eq!(v.capacity(), 2);
v.push(1);
v.push(2);
assert_eq!(v.len(), 2);
assert_eq!(v.capacity(), 2);
v.push(3);
assert_eq!(v.len(), 3);
assert_eq!(v.capacity(), 4);

內容解密:

  • Vec::with_capacity(capacity) 用於建立具有指定初始容量的向量。
  • len() 傳回當前向量中的元素數量。
  • capacity() 傳回向量在重新分配之前可以容納的元素數量。
  • 當超出初始容量時,向量會自動擴充。

向量的插入與移除操作

可以在向量的任意位置插入或移除元素,但這些操作可能會導致後續元素的移動,因此在處理長向量時可能較為耗時:

let mut v = vec![10, 20, 30, 40, 50];
v.insert(3, 35);
assert_eq!(v, [10, 20, 30, 35, 40, 50]);
v.remove(1);
assert_eq!(v, [10, 30, 35, 40, 50]);

內容解密:

  • insert(index, element) 在指定索引處插入一個新元素,並將後續元素向後移動。
  • remove(index) 移除指定索引處的元素,並將後續元素向前移動。

向量的彈出操作

可以使用 pop() 方法移除並傳回向量的最後一個元素:

let mut v = vec!["carmen", "miranda"];
assert_eq!(v.pop(), Some("miranda"));
assert_eq!(v.pop(), Some("carmen"));
assert_eq!(v.pop(), None);

內容解密:

  • pop() 傳回一個 Option<T>,如果向量為空則傳回 None,否則傳回 Some(value)

向量的迭代

可以使用 for 迴圈遍歷向量:

let languages: Vec<String> = std::env::args().skip(1).collect();
for l in languages {
    println!("{}: {}", l,
             if l.len() % 2 == 0 { "functional" } else { "imperative" });
}

內容解密:

  • std::env::args().skip(1).collect() 取得命令列引數並收集到一個字串向量中。
  • for 迴圈遍歷向量中的每個元素,並根據其長度輸出不同的分類別結果。

逐步建立向量(Building Vectors Element by Element)

逐步建立向量聽起來似乎不是一個好的做法,但實際上並非完全如此。當向量超出其緩衝區的容量時,它會選擇一個新的緩衝區,其大小是舊緩衝區的兩倍。假設向量一開始的緩衝區只能容納一個元素,那麼當它增長到最終容量時,它將擁有大小為1、2、4、8等的緩衝區,直到達到其最終大小$2^n$,其中$n$為某個整數。如果你仔細思考2的冪是如何工作的,你會發現之前所有較小的緩衝區的總大小加起來是$2^n - 1$,非常接近最終的緩衝區大小。由於實際元素的數量至少是緩衝區大小的一半,因此向量對每個元素執行的複製操作少於兩次!

這意味著,使用Vec::with_capacity而不是Vec::new是一種獲得常數因子改進速度的方法,而不是演算法上的改進。對於小型向量,避免一些對堆積分配器的呼叫可以在效能上產生可觀察到的差異。

切片(Slices)

切片是一種區域,寫作[T],未指定長度,是陣列或向量的一部分。由於切片可以是任意長度,因此切片不能直接儲存在變數中或作為函式引數傳遞。切片總是透過參照傳遞。

對切片的參照是一種胖指標:一個包含兩個字的值,包括指向切片第一個元素的指標和切片中的元素數量。

範例程式碼

let v: Vec<f64> = vec![0.0, 0.707, 1.0, 0.707];
let a: [f64; 4] = [0.0, -0.707, -1.0, -0.707];
let sv: &[f64] = &v;
let sa: &[f64] = &a;

內容解密:

  • 在上述程式碼中,我們首先建立了一個Vec<f64>和一個[f64; 4],然後分別建立了對這兩個資料結構的切片參照svsa
  • Rust 自動將&Vec<f64>參照和&[f64; 4]參照轉換為直接指向資料的切片參照。
  • 這展示了Rust如何處理從向量和陣列到切片參照的轉換。

切片參照在記憶體中的表現

記憶體佈局如圖3-2所示,向量v和陣列a在記憶體中與切片sasv的參照關係得以展現。普通參照是一個非擁有型指標,指向單一值,而對切片的參照則是一個非擁有型指標,指向多個值。這使得切片參照成為處理任何均質資料序列的良好選擇,無論是儲存在陣列、向量、堆積疊還是堆積中。

列印切片元素的函式範例

fn print(n: &[f64]) {
    for elt in n {
        println!("{}", elt);
    }
}

print(&v); // 適用於向量
print(&a); // 適用於陣列

內容解密:

  • 這個函式接受一個切片參照作為引數,可以應用於向量或陣列。
  • print函式遍歷切片中的每個元素並列印它。
  • 這展示了切片參照如何提供對不同資料結構中元素的通用存取方式。

索引切片

你可以透過索引來取得陣列或向量的切片,或者現有切片的切片:

print(&v[0..2]); // 列印v的前兩個元素
print(&a[2..]);  // 列印從a[2]開始的元素
print(&sv[1..3]); // 列印v[1]和v[2]

內容解密:

  • Rust檢查索引是否有效,嘗試借用超出資料末尾的切片將導致程式當機。
  • 這展示瞭如何使用範圍索引來存取資料結構中的特定元素序列。

字串型別(String Types)

Rust有兩種字串型別:字串字面量和String型別。字串字面量是被雙引號包圍的字元序列,使用與字元字面量相同的反斜槓轉義序列。

字串字面量範例

let speech = "\"Ouch!\" said the well.\n";
println!("In the room the women come and go,\
         Singing of Mount Abora");

內容解密:

  • 字串字面量可以跨越多行,並且可以包含轉義序列。
  • 如果一行以反斜槓結束,那麼下一行的開頭空白和換行符將被忽略。
  • 這展示瞭如何在Rust中建立和格式化字串字面量。

原始字串(Raw Strings)

原始字串以字母r為字首,所有反斜槓和空白字元都被原樣包含在字串中,不進行任何轉義序列的識別。

let default_win_install_path = r"C:\Program Files\Gorillas";
let pattern = Regex::new(r"\d+(\.\d+)*");

內容解密:

  • 原始字串對於需要大量反斜槓的字串(如正規表示式或Windows路徑)非常有用。
  • 這展示瞭如何在Rust中使用原始字串來避免轉義問題。

字串處理與記憶體管理

Rust 中的字串處理涉及多種型別,包括 String&str。這些型別在記憶體中的表示方式不同,適用於不同的場景。

原始字串(Raw Strings)

在 Rust 中,原始字串允許包含特殊字元,而無需使用轉義序列。原始字串以 r 開頭,並可使用井號(#)來明確字串的結束位置。

println!(r###"
此原始字串以 'r###' 開頭。
因此,它不會在遇到引號('"')後緊跟三個井號('###')之前結束:
"###);

您可以根據需要新增或減少井號的數量,以明確原始字串的結束位置。

位元組字串(Byte Strings)

b 為字首的字串是位元組字串。這種字串是 u8 值的切片,即位元組,而不是 Unicode 文字。

let method = b"GET";
assert_eq!(method, &[b'G', b'E', b'T']);

位元組字串可以跨越多行,使用轉義序列,並使用反斜線連線多行。原始位元組字串以 br 開頭。

內容解密:

  • b"GET":建立一個位元組字串,內容為 “GET” 的 ASCII 碼。
  • assert_eq!:檢查 method 是否等於位元組切片 [b'G', b'E', b'T']
  • 位元組字串僅能包含 ASCII 字元和 \xHH 轉義序列。

字串在記憶體中的表示

Rust 的字串是 Unicode 字元序列,但它們在記憶體中並不是以 char 陣列的形式儲存。相反,它們使用 UTF-8 編碼,這是一種可變寬度的編碼方式。

let noodles = "noodles".to_string();
let oodles = &noodles[1..];
let poodles = "ಠ_ಠ";

內容解密:

  • "noodles".to_string():將字串 “noodles” 轉換為 String 型別。
  • &noodles[1..]:建立一個切片,參照 noodles 中從第一個字元到結尾的子字串。
  • "ಠ_ಠ":一個包含 Unicode 字元的字串。

String&str

  • String 是一個擁有 UTF-8 文字的可調整緩衝區的型別,緩衝區在堆積上分配。
  • &str 是對由其他人擁有的 UTF-8 文字的參照,即它“借用”了文字。

內容解密:

  • String 可以動態調整大小,而 &str 是對現有字串的參照。
  • &str 可以指向任何型別的字串,無論是字串文字還是 String

使用字串

Rust 中的字串支援比較運算子,如 ==!=,以及許多有用的方法和函式。

assert!("ONE".to_lowercase() == "one");
assert!("peanut".contains("nut"));
assert_eq!("ಠ_ಠ".replace("ಠ", "■"), "■_■");

內容解密:

  • "ONE".to_lowercase():將 “ONE” 轉換為小寫。
  • "peanut".contains("nut"):檢查 “peanut” 是否包含 “nut”。
  • "ಠ_ಠ".replace("ಠ", "■"):將 “ಠ” 替換為 “■"。

其他字串型別

有時,程式需要處理不是有效 Unicode 的字串,例如與其他系統互動時。Rust 保證字串是有效的 UTF-8,但在某些情況下,程式可能需要處理無效的 Unicode 字串。