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 提供了三種型別來表示記憶體中的一連串值:
- 陣列
[T; N]:具有固定大小N和型別T的陣列,其大小在編譯時確定,不能動態改變。 - 向量
Vec<T>:動態分配的可增長序列,其元素存在於堆積上,可以動態調整大小。 - 切片
&[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]語法用於建立一個包含count個value的向量。- 此函式傳回一個初始化為零的畫素緩衝區向量,用於影像處理等場景。
另一種建立向量的方法是使用迭代器(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],然後分別建立了對這兩個資料結構的切片參照sv和sa。 - Rust 自動將
&Vec<f64>參照和&[f64; 4]參照轉換為直接指向資料的切片參照。 - 這展示了Rust如何處理從向量和陣列到切片參照的轉換。
切片參照在記憶體中的表現
記憶體佈局如圖3-2所示,向量v和陣列a在記憶體中與切片sa和sv的參照關係得以展現。普通參照是一個非擁有型指標,指向單一值,而對切片的參照則是一個非擁有型指標,指向多個值。這使得切片參照成為處理任何均質資料序列的良好選擇,無論是儲存在陣列、向量、堆積疊還是堆積中。
列印切片元素的函式範例
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 字串。