Rust程式語言的標準函式庫中已有內建了數個好用的集合資料結構。不同於基本資料型別只能夠儲存一個值,也不同於元組、陣列、結構體只能儲存固定大小的資料量,集合的資料結構將資料儲存在堆積中,也就是說不需要在編譯階段就知道要存入的資料的數量和大小。



每一種集合資料結構有著不同的性能,我們必須看情況自行選擇一個適當的來使用。在這個章節中,我們會介紹三種Rust程式語言內建的集合:VecStringHashMap

Vec

Vec為利用向量(Vector)式資料結構所實作出來的結構體,可以將多個同一種型別的資料儲存在一塊連續的記憶體上,類似陣列。但與陣列不同的是,這塊連續的記憶體空間大小是可以變動的,所以可以儲存不固定數量的元素。

建立新的Vec結構實體

使用Vec結構體所提供的new函數,可以建立出Vec結構實體。Vec結構體已經包含在std::prelude中,因此不用再特別使用其它命名空間。有一點要注意的是,雖然我們還沒有開始正式學習泛型,但Rust程式語言的集合型別通常會搭配泛型一起使用。Vec的泛型定義為<T>,只有一個參數。如果我們直接將程式碼寫成以下這樣,會編譯錯誤:

let v = Vec::new();

編譯錯誤的原因是,編譯器並不知道Vec的泛型T實際上到底是什麼東西。我們需要定義清楚,才能夠讓程式通過編譯。程式如下:

let v1: Vec<i32> = Vec::new();
let v2 = Vec::<i32>::new();

以上使用了兩種不同的方式來實體化Vec結構體。第一種,透過直接定義v1變數的型別來指定Vec的泛型T的型別,再指派Vec結構實體給它。第二種,利用「turbofish」語法直接給定泛型T的型別。

由於Vec結構體在Rust程式語言中實在是太常用了,如果每次要建立Vec結構實體的時候都必須要將程式碼寫成以上這樣實在是很麻煩,所以Rust有另外提供了語法糖來建立Vec結構實體。程式如下:

let v = vec![1, 2, 3];

看到vec!結尾的驚嘆號就知道它跟println!一樣是個巨集。直接在vec!後面接上陣列的語法,就可以產生出Vec結構實體並賦予它要儲存的初始元素。

將資料存入Vec結構實體

使用Vec結構實體提供的push方法,可以將新的資料插入至該Vec結構實體的尾端。舉例來說:

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}

以上程式利用Vec結構實體提供的push方法,將值5678存入Vec結構實體中,Vec結構實體會自動增加其使用的記憶體空間以存放這些值。這裡要注意的是,第2行建立出Vec結構實體時,並沒有去指定泛型T的型別,但程式卻依然能通過編譯。這個狀況跟之前提到的在使用let關鍵字時明確定義Vec型別的方式有點類似,只不過我們現在是將明確定義Vec型別的地方放在第一次使用這個Vec結構實體時。

讀寫Vec結構實體中的元素

Vec結構實體的用法和陣列的用法幾乎一樣。舉例來說:

let mut v = vec![1, 2, 3, 4];
v[0] = v[2];

以上程式,將Vec結構實體v索引2的3存到索引0的位置,取代掉原本索引0的1

當然,Vec結構實體也可以跟陣列一樣,使用get方法來取得元素值。程式如下:

let mut v = vec![1, 2, 3, 4];
let x = v.get(0);

get方法所回傳的值為Option列舉的實體,如果傳入get方法的索引值在Vec結構實體的索引範圍內的話,就會回傳Some變體的實體,且包裹元素值的參考在第一個參數;如果索引值超出範圍,就會回傳None變體的實體。

使用For迴圈走訪的方式也跟陣列一樣。程式如下:

let v = vec![100, 32, 57];

for i in &v {
    println!("{i}");
}

Vec結構體的使用方式就是這麼簡單,跟陣列非常相似。除了有可以將新元素插入至Vec結構實體尾端的push方法之外,另外還有pop方法可以移除Vec結構實體尾端的元素並回傳出來。例如:

let mut v = vec![1, 2];

let two = v.pop();

就連切片的用法也和陣列完全相同,包括切片的型別也是一樣。例如:

fn main() {
    let mut a = vec![1, 2, 3, 4, 5];

    print_array(&a);
    fill_array(&mut a[2..4], 0);
    print_array(&a);
}

fn print_array(arr: &[i32]) {
    println!("{arr:?}");
}

fn fill_array(arr: &mut [i32], value: i32) {
    for i in arr.iter_mut() { // or for i in &mut arr
        *i = value;
    }
}

我們先前使用過的String結構體其實也是使用Vec結構體來實作的,因此可以改變字串的長度。底下就來深入探討Rust程式語言的字串究竟是如何運作的。

String

什麼是字串?

首先我們來確定一下「字串」的意義。

Rust程式語言原生的字串型別為str,我們會以它的切片型別來使用它,也就是&str。直接寫在Rust程式語言的字串定數,型別就是&str&str就是一個參考,指到記憶體中某個存放著UTF-8編碼的字串資料的區塊,且是不可變的。

Rust程式語言的標準函式庫提供String結構體,且這個String結構體被涵蓋在std::prelude中,使用時不需要特別指定命名空間。String結構體所表示的字串,可改變、可增長,同樣也是使用UTF-8編碼,因此&String也可以自動轉型為&str

當我們提到Rust的「字串」時,通常就是在講&strString這兩個型別,而不光只講其中一個。

除了剛才提到的strString這兩個型別之外,Rust程式語言的標準函式庫還提供了OsString/OsStrCString/CStr等等用不同實作方式來處理字串的結構體,命名方式就是*String/*Str(星號*代表任意名稱)。它們可能就會以別種格式編碼字串資料,或是用別的資料結構來儲存字串資料。

建立新字串

如果我們要建立出來的字串在之後需要進行改變的話,就使用String結構體。String結構體提供了new函數可以快速地建立出一個代表空字串的String結構實體。程式如下:

let mut s = String::new();

如果要讓建立出來的String結構體擁有非空字串的初始值的話,可以使用from函數。程式如下:

let mut s = String::from("initial contents");

甚至也可以直接使用字串切片&strto_string函數,來藉由字串切片產生出String結構實體。程式如下:

let s = "initial contents";
let mut s = (&s[..7]).to_string();

Rust的字串(&strString)是用UTF-8來進行編碼,不太需要擔心會有什麼字元無法支援。舉例來說:

let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שָׁלוֹם");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
let hello = String::from("今日は");

以上用不同語言建立出來的「你好」字串,都是可以正常使用和顯示的。

串接字串

我們可以使用String結構實體的push_str方法串接字串,使原本的字串變長。舉例來說:

let mut s = String::from("foo");
s.push_str("bar");

以上程式執行完push_str方法之後,s變數所儲存的字串為foobar

另外還有push方法可以串接一個字元,使原本的字串變長。舉例來說:

let mut s = String::from("lo");
s.push('l');

以上程式執行完push方法之後,s變數所儲存的字串為lol

+運算子被許多程式語言拿來做字串串接的運算,Rust也不例外,但Rust的+運算子有點不太一樣。舉例來說:

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // Note that s1 has been moved here and can no longer be used

以上這段程式執行完之後,s3變數即為s1變數儲存的String結構實體,但所儲存的字串資料已經串接上s2變數的world!,也就是說s3變數所儲存的字串為Hello, world!。以上程式可以等效於:

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let mut s_tmp = s1; // Note that s1 has been moved here and can no longer be used
s_tmp.push_str(&s2);
let s3 = s_tmp; // Note that s_tmp has been moved here and can no longer be used

因此在+運算子完成運算後,s1變數的值實際上已經被「移動」給s3使用了。+運算子可以串接的字串數量並沒有限制,但是最一開始的運算元一定要是String結構實體。舉例來說:

let s1 = String::from("tic");
let s2 = "tac";
let s3 = "toe";

let s = s1 + "-" + s2 + "-" + s3;

以上程式可以成功編譯,s變數所儲存的字串為tic-tac-toe。如果將以上程式改為:

let s1 = "tic";
let s2 = "tac";
let s3 = "toe";

let s = s1 + "-" + s2 + "-" + s3;

程式會編譯失敗,因為+運算子一開始的運算元並不是String結構實體。

Rust程式語言的+運算子會沿用一開始作為運算元代入的String結構實體,並將之後要串接的字串資料都儲存到這個String結構實體中。這樣的作法雖然讓作為第一個運算元的變數被「移動」,但是可以省下重新建立一個新的String結構實體後,再把所有字串資料複製進去的功夫。不過在某些情況下,我們還是需要持續使用串接前的String結構實體,這時可以考慮使用format!巨集。

format!巨集的用法類似println!巨集,只不過println!巨集是直接把結果透過標準輸出印在螢幕上,而format!巨集則是將字串的格式化結果回傳出來。

舉例來說:

let s1 = String::from("tic");
let s2 = "tac";
let s3 = String::from("toe");

let s = format!("{}-{}-{}", s1, s2, s3);

使用format!巨集來組合字串就不會有「移動」的問題了,而且也不需要去在意目前的串接的運算子到底是不是字串切片。

字串的索引值

之前的章節中,我們有提到字串切片可以用來取得子字串,但是切的索引範圍必須剛好在UTF-8編碼資料的字元與字元之間,否則程式會在執行階段發生panic。如果我們對UTF-8編碼不是那麼熟悉,不知道要怎麼去算我們要抓的子字串的索引範圍,該怎麼辦呢?

Rust的字串提供了chars方法,可以將字串轉成Chars結構實體,其提供迭代器,可以一個一個準確地走訪每個字元。舉例來說:

for c in "中文ABC123".chars() {
    println!("{c}");
}

以上程式可以將每個字元依照從左到右的順序,分別印在每一行。

如果我們想要利用索引值,來讀取字串中的第幾個字元,比較方便的作法是將字串轉成Vec結構實體來存取。程式如下:

let char_vec: Vec<char> = "中文ABC123".chars().collect();

迭代器的collect方法可以將要走訪的資料轉成集合,我們在宣告char_vec變數時明確定義變數的型別為Vec<char>,因此collect方法會將資料轉成Vec<char>的實體。泛型參數的型別部份也可以用底線_取代,讓編譯器自行去推論泛型參數的型別。如此一來,char_vec[0]就是字元'中'char_vec[1]就是字元'文'char_vec[2]就是字元'A',依此類推。如果是用其它程式語言,可能是習慣把字串轉成字元陣列,但因Rust程式語言的陣列長度必須在編譯階段就決定好的關係,不能應付長度在編譯階段無法確定的字串。當然,如果是字串定數,大可直接把陣列長度(字串長度)人工算好後直接撰寫在程式碼,但這樣程式也會很難維護。

HashMap

HashMap為利用雜湊函式將一個「鍵值」(key)對應一個值的資料結構,其泛型的定義為<K, V>

建立新的HashMap結構實體並加入資料

使用HashMap結構體所提供的new函數,可以建立出HashMap結構實體。HashMap結構體中並未包含在std::prelude中,使用時需指定其命名空間,Rust標準函式庫的集合都在std::collections模組下。

舉例來說:

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    
    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
}

以上程式,相當於用HashMap結構實體建立出一個計分表,藍隊10分,黃隊50分。HashMap結構實體的insert方法,可以將新的key-value資料加入進來,如果加入的鍵值已存在,就會覆蓋原本儲存的值。

剛才用過迭代器的collect方法,除了可以產生出Vec結構的實體外,也可以再配合zip方法來產生出HashMap結構的實體。

舉例來說:

use std::collections::HashMap;

fn main() {
    let teams  = vec![String::from("Blue"), String::from("Yellow")];
    let initial_scores = vec![10, 50];

    let scores: HashMap<&String, &i32> = teams.iter().zip(initial_scores.iter()).collect();
}

泛型參數的型別部份也可以用底線_取代,讓編譯器自行去推論泛型參數的型別。

取得HashMap結構實體中的資料

Vec結構實體一樣,使用HashMap結構實體提供的get方法可以透過鍵值來取得對應的值。舉例來說:

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    let team_name = String::from("Blue");
    let score = scores.get(&team_name);
}

雖然使用get方法可以透過鍵值來取得對應的值,但如果我們需要利用取得的值來計算出新的值,覆蓋掉原本儲存的值,就要再次使用insert方法。HashMap的鍵值可以是任何型態的值,但這個值在作為HashMap的鍵值使用時必須要經過雜湊函式算出雜湊值,所以在使用鍵值查找資料時會消耗一些運算資源在計算雜湊上。為了要讓程式效能更好,我們應該要儘量地避免重複計算雜湊值。

HashMap結構實體有個entry方法,可以傳入鍵值,直接回傳鍵值對應的key-value資料,而不單單只是回傳鍵值對應的值。也就是說,可以藉由直接改變回傳出來的key-value資料,來避免再次使用HashMap結構實體的insert方法。HashMap結構實體的entry方法會回傳一個Entry結構的實體,利用其提供的or_insert方法,可以讓它將值回傳出來,如果值還沒有被設定,就會設成傳入or_insert方法第一個參數的值。

舉例來說:

use std::collections::HashMap;

fn main() {
    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{map:?}");
}

以上程式會計算hello world wonderful world的單字數量。程式執行結果如下:

{"hello": 1, "world": 2, "wonderful": 1}

總結

Vec、字串和HashMap都是很常被用來處理資料的型別,除了本章提到的用法外,Rust程式語言的標準函式庫還有很多品質好又方便的功能可以互相搭配使用,可以去翻閱一下Rust標準函式庫的文件,看看能用它來做到多少事情吧!文件的網址如下:

下一章:錯誤處理