Rust 學習之路─第六章:列舉和型樣匹配


在先前的章節中我們已經稍微用過「Result」列舉了,在這個章節中,我們將會學著定義自己的列舉型別,以及使用另一個同樣是由Rust內建,也很常用的「Option」列舉。列舉和同樣我們先前已經用過的「match」關鍵字經常互相搭配著使用,在這個章節中,我們也會更深入地學習「match」關鍵字和「if let」、「while let」語法的用法。

先舉一個例子來說明列舉的用途。現在我們需要用Rust程式來處理IP位址,而IP位址有兩個主要版本,分別是32位元的「IPv4」和128位元的「IPv6」,我們的程式也只要處理這兩種版本的IP位址。所有的IP位址都一定是屬於「IPv4」或是「IPv6」,但不能同時是「IPv4」和「IPv6」。我們可以定義出一個IP位址種類的列舉「IpAddrKind」,且這個列舉有「V4」、「V6」兩種變體(variant)。程式如下:

列舉與結構體類似,定義出來後只是一個型別,要作為「值」使用的話一樣還要經過實體化。IpAddrKind列舉可以實體化出「V4」和「V6」兩種不同類型的值,實體化的程式碼如下:

實體化出列舉值的方式十分簡單。以上程式,「four」和「six」變數的型別都是「IpAddrKind」,也就是說如果要將它們傳入函數作為參數使用,參數的型別可以使用「IpAddrKind」。例如:

將目前的「IpAddrKind」列舉搭配結構體使用看看吧!程式如下:

程式第6行到第9行,定義了一個新的結構體「IpAddr」,用來表示一個IP位址種類和位址。種類的型別即為「IpAddrKind」列舉,而位址則使用字串型別來儲存。這樣的寫法並沒有什麼問題,但我們可以再寫得更精簡一點,只用一個列舉,不搭配其它結構體,就能夠完成一樣的事情。程式如下:

在定義列舉的變體時,可以在變體名稱後面加上小括號「()」來定義其攜帶其它值的欄位,類似數組結構體的語法。每個列舉的變體可以攜帶的值,數量並沒有限制。例如:

以上程式將「V4」從儲存一個字串改成儲存4個8位元的無號整數。雖然「home」和「loopback」變數都是屬於「IpAddr」型別,但它們攜帶的值卻可以是不同數量、不同型別。

接著再來看看其它列舉的例子。請看以下程式:

這個「Message」列舉有四個變體,能用來處理四種不同類型的訊息。這裡要注意的是變體「Move」定義的方式,它的語法類似結構體,使用大括號「{}」,且擁有欄位名稱。如果把「Message」列舉的四個變體轉成結構體的寫法的話,程式如下:

使用結構體的方式改寫原先的「Message」列舉後,「QuitMessage」、「MoveMessage」、「WriteMessage」和「ChangeColorMessage」就都不是同一個型別了。

列舉的變體跟結構體很像,除了可以定義資料欄位之外,它也可以使用「impl」關鍵字來定義方法和關聯函數。如以下程式:

「impl」關鍵字搭配列舉所實作出來的方法適用於該列舉底下的所有變體。也就是說,以上程式,不論是「Message::Write」、「Message::Quit」、「Message::Move」還是「Message::ChangeColor」的實體,都可以使用「call」方法。在「call」方法可以利用「self」參數對實體本身做「型樣匹配」(pattern matching)來決定要執行什麼程式,這個部份將在這個章節後半部做介紹。

先來介紹一下Rust程式語言提供的一個常用的列舉型別「Option」。「Option」列舉和「Result」列舉都是在定義Rust程式語言的方法或是函數時,常使用的型別。「Result」列舉用來表示函數或是方法有沒有執行成功,而「Option」列舉則可以用來包裹函數或是方法的回傳值。「Result」列舉的「Ok」變體不是就可以包裹回傳值了嗎?為什麼還要用「Option」列舉呢?

在許多程式語言中有「null」或「nil」關鍵字可以使用,即「空值(沒有這個值)」的概念。而在Rust程式語言中,並不能直接使用「null」或「nil」關鍵字來代表「空值」。可是在很多種情況下,函數或是方法雖然在邏輯上是執行成功的,但是因某些條件的關係而不會回傳值出來,這時候要怎麼讓Rust的函數或是方法回傳「空值」呢?

「Option」列舉就是為了表示「空值」而存在的,其定義如下:

「<T>」是泛型的語法,這個在之後的章節會介紹。現在只要知道「Option」列舉有「Some」和「None」兩種變體,且「Some」可以包裹其他型別的值,而「None」就是表示「空值」啦!實體化「Option」列舉的方式如下:

在使用「Result」列舉和「Option」列舉的變體時不需要撰寫「Result::」和「Option::」來指定使用哪個列舉下的變體,因為「Result」列舉和「Option」列舉已經被包含在「std::prelude」模組中,會自動被引用進目前程式的scope。

另外,如果想要用類似的方式實體化「Result」列舉的話,必須要先定義好「Result」的泛型型別,才能通過編譯,因為「Result」的泛型有兩個參數,各自是「Ok」和「Err」變體所包裹的值的型別。「Result」列舉的定義如下:

舉例來說:

以上程式會編譯錯誤,因為直接實體化「Ok(5)」,只能推測「T」是「i32」,並不能推測「E」的型別;直接實體化「Err(5)」,也只能推測「E」是「&str」,並不能推測「T」的型別。

所以要明確定義「Result」列舉的型別所用的泛型型別,才可以通過編譯,程式如下:

到這裡如果還是不懂「<>」所表示的「泛型」到底是什麼也沒關係,後面的章節會有詳細的介紹。

接下來介紹一下「match」關鍵字吧!雖然我們之前有用過,但並沒有將它徹底地瞭解。「match」關鍵字可以用來做任何值的「型樣匹配」(pattern matching),早在練習我們寫猜數字程式的時候就已使用過,複習一下,程式如下:

再舉一個新的例子,請看以下程式碼:

這個程式中的「value_in_cents」函數可以將「Coin」變體的實體轉成其對應的一個32位元的無號整數。「match」和「if」關鍵字不同的地方在於:「if」關鍵字必須使用布林值來進行流程判斷;而「match」關鍵字則可以判斷任何型別的值,經常與列舉型別的值一同使用。

以「match」關鍵字來匹配「Option」列舉的值來舉例:

以上程式,「plus_one」函數可以接受一個「Option<i32>」的值。如果傳入的值是「None」的實體的話,就回傳「None」;如果傳入的值是「Some」的實體的話,就將傳入的「Some」實體包裹在第一個參數的值加一之後,再用新的「Some」包裹起來回傳。

「match」關鍵字有一個很重要的特性,那就是他必須將要判斷的值的所有可能都列在「arm」,如果有遺漏,程式就會編譯失敗。舉例來說:

以上程式會編譯失敗,因為「x」的型別為「Option<i32>」,它可能的值除了「Some(i32)」外,還有「None」,但是「match」中並未有arm去判斷當「x」為「None」時要做的事情。

當然,如果每次使用「match」關鍵字時都要將所有可能的值都寫成arm會很麻煩,如果我們要判斷的值是「u8」型別,那不就要寫256(28)條arm嗎?別緊張,對於我們想要統一用一樣的方式處理的其它值,可以使用底線「_」作為匹配樣本。舉例來說:

「_」的概念就像是「if」的「else」,當「_」先前的arm都匹配失敗時,就會執行「_」的arm。也就是說,「_」的arm應該放在最後一個,如果安插在arm跟arm的之間,編譯時將會出現警告訊息。

再來看一個「match」關鍵字和「Option」列舉的例子:

以上程式的目的是要判斷當「some_u8_value」值為「Some」的實體,且包裹著「3」時,就印出「three」,否則不做任何事情。在很多時候,我們在意的只有「Some」變體所包裹的值,而不想去理會當「Option」列舉的實體如果是「None」時應該要做什麼事。Rust程式語言另外還有一種語法能使用,也就是「if let」語法。以上程式,可以使用「if let」語法改寫為:

先不要去想「match」關鍵字寫的程式敘述要怎麼轉成「if let」語法,而是去想如果我們要用之前學到的「if」關鍵字來判斷「some_u8_value」變數的值是不是「Some(3)」的話要怎麼寫,最直覺的程式碼如下:

這樣的程式是可以編譯並執行的哦!但是如果我們只是要判斷「some_u8_value」變數的值是不是「Some」的實體的話,用「if」關鍵字要怎麼做到呢?拿剛才的「plus_one」函數來修改看看:

以上程式會編譯錯誤,因為「i」被當成是要作為參數傳入Some變體的變數了,而不是要代替Some實體第一個參數所包裹的值,因此編譯器會認為「i」沒有事先宣告出來,產生編譯錯誤。

為了要讓編譯器了解這邊的「i」是要代替Some實體第一個參數所包裹的值,我們必須使用「if let」語法來改寫。如下:

除了在「if」關鍵字後面加上「let」關鍵字外,還需要將判斷值是否相等的「==」改為指派值用的「=」。請注意「=」是將右邊的值指派給左邊的變數,因此「if let Some(i) = x」不能夠寫成「if let x = Some(i)」。

當然就和一般的「if」關鍵字組成的程式敘述一樣,「if let」和「else if」也是可以一起使用的。如下:

「if let」語法就是原本的「if」關鍵字所組成的程式敘述再加上「let」關鍵字的特殊用法,不要用「match」關鍵字去想它會比較容易理解。如果想單獨將「let」關鍵字和列舉實體拆出來使用的話,是不行的。舉例來說:

我們預期會宣告出一個變數「i」並指派「3」給它,然而這行程式會編譯錯誤。

除了「if let」語法之外,還有「while let」語法。舉例來說:

程式第8行開始的while迴圈,會在stack變數的Vec實體呼叫「pop」方法的回傳值為Some變體的時候,才繼續執行迴圈。

事實上,型樣匹配不只限於在「match」關鍵字、「if let」或是「while let」語法才能使用。我們使用「let」關鍵字宣告變數,如「let x = 5」、「let (x, y) = (1, 2)」,甚至是函數或方法的參數,如「(x, y): (i32, i32)」,或是for迴圈,其實也都是在做型樣匹配哦!舉例來說:

以上程式執行結果如下:

5
6
1: 0
2: 1
3: 2
10
11

Rust程式語言將型樣匹配分為「可辯駁」(refutable)和「不可辯駁」(irrefutable)兩種,「let x = 5」、「let (x, y) = (1, 2)」、「(x, y): (i32, i32)」這樣的用法都是屬於「不可辯駁」的型樣匹配,因為不論是「x」和「y」都可以是任何值,匹配一定可以成功。而「if let Some(x) = a_value」這種的匹配方式為「可辯駁」的,因為「a_value」實際的值可能為Some變體,也可能為None變體,有可能會匹配失敗。

函數或方法的參數、「let」關鍵字和for迴圈,只能支援「不可辯駁」的型樣匹配,「if let」和「while let」語法只支援「可辯駁」的型樣匹配,「match」的arm,可以同時支援「不可辯駁」和「可辯駁」的型樣匹配。

「match」關鍵字的型樣匹配,還可以有一些特殊的變化。例如我們可以在每個arm要匹配的樣本中,使用「|」字元,來表示「或」(or)的邏輯。舉例來說:

也可以同時使用多個「|」字元。舉例來說:

以上程式,由於「12345」是連續的整數數值範圍,我們也可以在型樣匹配的樣本中使用「...」語法。舉例來說:

注意這個「...」語法並不是先前學過的「..」範圍語法。「...」語法只能用在值是整數或是字元型別的時候。當然「..=」這個範圍語法可以等同於「...」語法,被允許用在match的arm。

型樣匹配除了可以用在列舉之外,一般的結構體也能使用,可以快速地「解開」結構體。舉例來說:

以上程式執行結果如下:

0
7

再舉一個例子:

執行結果如下:

135

我們也可以對結構體使用可辯駁的型樣匹配。舉例來說:

如果不想要直接使用結構體的欄位名稱作為變數名稱的話,也可以直接在「:」字元後面定義該欄位的值要匹配到的變數名稱。舉例來說:

那麼如果我們想要對結構體使用可辯駁的型樣匹配,同時又想使用自己的變數名稱的話,要怎麼做呢?那就是在變數名稱後面使用「@」字元來串接要匹配的值。舉例來說:

當然也可以讓多個結構體和列舉複合使用,舉例來說:

以上程式執行結果如下:

3
4

底線「_」其實不只能夠被單獨使用在匹配樣本中,它的匹配方式就相當於「x」、「y」這樣的變數,只是「_」在匹配完成後就會忽略掉匹配成功的值了。舉例來說:

「Some(_)」和「Some(x)」可以說是完全一樣的匹配樣本,只是如果使用「Some(_)」來匹配的話,就不會有一個實際的變數來儲存匹配到的值。如果我們將以上程式的「Some(_)」改為「Some(x)」的話:

程式在編譯時將會出現「unused variable: x」的警告訊息。不管在哪,如果我們宣告了變數卻完全沒去使用的話,Rust的編譯器都會出現這個訊息。如果想要讓編譯器不出現這個警告訊息的話,可以讓變數的名稱以底線「_」開頭,如下:

這樣一來編譯器就不會出現「unused variable: x」的警告訊息了!

我們除了能用「_」在型樣匹配時忽略不想要的值之外,也可以使用「..」語法來直接忽略掉資料結構中所有剩下來的值。要注意這邊的「..」語法並不是範圍語法哦!舉例來說:

以上程式執行結果如下:

0

再舉一個例子:

以上程式執行結果如下:

3
4

當「..」語法用在數組的時候,其位置不一定要放在最右邊,但是只能夠出現一次。舉例來說:

以上程式執行結果如下:

7
8

再舉一個例子:

以上程式執行結果如下:

3
4
8

接著來談談型樣匹配時的擁有權吧!先看以下的程式:

這個程式會編譯成功,並且在螢幕上印出:

Found a name: Magic Len

然而,如果我們在最後添加一些程式進去,來使用「robot_name」變數的話,會發生什麼事呢?程式如下:

我們添加的程式第9行會編譯失敗,因為「robot_name」變數原本的值,已經移動給程式第5行match的arm所匹配好的變數「name」了。為了解決在型樣匹配過程中擁有權轉移的問題,我們可以在匹配樣本的變數前使用「ref」關鍵字來取得匹配到的值的參考並指派給變數,而不是直接將匹配到的值的本身指派給變數。至於為什麼不使用「&」就好,那是因為「&」在型樣匹配中代表的意義是「匹配參考型別」,而不是「取得值的參考」。

舉例來說:

用「&」來做型樣匹配其實看起來蠻奇怪,雖然現在的Rust在做類似的型樣匹配時可以省略「&」,但在早期的Rust版本,「&」卻是必要的。不過總之,就是因為「&」在型樣匹配中已經有它既定的用途了,因此我們需要使用「ref」關鍵字來代替用來取得參考的「&」。

將剛才的「Robot Name」,加上「ref」關鍵字,改寫如下:

程式第5行的變數「name」,型別變成「&String」,並不會更動到原先String結構實體的擁有者,因此程式可以通過編譯。執行結果如下:

Found a name: Magic Len
robot_name is: Some("Magic Len")

如果要使用可變參考,也可以讓「ref」關鍵字和「mut」關鍵字搭配使用。例如:

執行結果如下:

Found a name: Magic Len
robot_name is: Some("Magic Len (checked)")

另外,「match」關鍵字的型樣匹配還有「匹配守衛」(match guard)的用法,需要讓match的arm,搭配「if」關鍵字使用。利用這個匹配守衛語法,我們可以讓arm增加一些匹配條件。當匹配守衛的條件參數運算結果為「true」,這個arm就會匹配成功,執行其所對應的程式區塊。舉例來說:

總結

這章節我們學會了如何使用列舉來撰寫Rust程式,並且也深入瞭解了Rust程式語言型樣匹配了。我們現在的Rust程式功力已經足夠寫出各式各樣的運算程式(單執行緒限定),可以去找一些演算法的題目來練習看看。接下來的章節會介紹模組(mod)的用法,將我們寫好的程式包裝起來給別人使用!

關於作者

Magic Len

Magic Len

各位好,我是Magic Len,是這網站的管理員。我是台灣台中大肚山上人,畢業於台中高工資訊科和台灣科技大學資訊工程系,曾在桃機航警局服役。我熱愛自然也熱愛科學,喜歡和別人分享自己的知識與經驗。如果你有興趣認識我,可以加我的Facebook,並且請註明是從MagicLen來的。

相關文章