[為你自己學 Rust] 列舉(Enum)

[為你自己學 Rust] 列舉(Enum)

在寫程式的時候,雖然對電腦來說都是 0 跟 1,但對身為開發者的人類來說有好的命名或識別是很重要的,對自己好,對跟你一起工作的夥伴也好。

這個章節要介紹的「列舉(Enum)」並不是什麼很新或很特別的設計,在其它程式語言也常見,列舉是用來表示某種特定類型的值集合,通常會要把同樣類型的東西放在一起(例如顏色 Color),並且給它個名字(RedGreenBlue)。在程式碼裡使用 Enum 的時候比較不會因為不小心打錯字而造成錯誤,同時程式碼的可讀性也會比較好。

但 Rust 的 Enum 功能除了把項目列出來之外,還有一些其它程式語言的 Enum 所沒有的。

建立列舉

在 Rust 可以使用小寫的 enum 關鍵字建立列舉:

enum CatBreed {
    Persian,           // 波斯貓
    AmericanShorthair, // 美國短毛貓
    Mix,               // 米克斯
}

在列舉裡面的東西沒有限定數量,在 Enum 裡那些看起來像屬性或欄位的東西叫做「變體(Variants)」。不管是 Enum 本身的或是變體的命名慣例,Rust 都是建議你使用駝峰式命名法。這裡我建立了個名為 CatBreed 的 Enum,裡面有波斯貓、美國短毛貓以及混種的米克斯。

如果要使用定義好的 Enum,需連名帶姓一起用:

let breed = CatBreed::Persian;

中間是 2 個冒號 ::。以往使用 let breed = "persian" 這樣的字串寫法一不小心寫錯可能不容易發現,但用 Enum 的好處就是只要打錯一點點,馬上就會被挑出來。

通常有 Enum 之後,在其它程式語言通常就會用它再搭配 if..elseswitch 根據不同的變體而有不同的流程。不過在 Rust 並沒有 switch 的寫法,倒是有 match 可以用,它寫起來跟 switch 有點像,但有我個人很喜歡的「模式匹配(Pattern Matching)」功能:

let breed = CatBreed::Persian;

match breed {
    CatBreed::Persian => {
        println!("我是波斯貓");
    }

    CatBreed::AmericanShorthair => {
        println!("我是美國短毛貓");
    }

    CatBreed::Mix => {
        println!("我是米克斯");
    }
}

使用 match 的時候,如果分支(Branch)有大括號包起來的話,每個分支之間可以不需要加逗號,如果分支的內容比較簡單可以一行就寫完,也可以把大括號拿掉,改寫成這樣:

match breed {
    CatBreed::Persian => println!("我是波斯貓"),
    CatBreed::AmericanShorthair => println!("我是美國短毛貓"),
    CatBreed::Mix => println!("我是米克斯"),
}

這種寫法的話每個分支之間就得用逗號分開了。因為本章節的範例都比較簡單,以下我會用比較簡捷的寫法。

關於 match,第一個我覺得好用的點,就是不用寫 break,我真的很常忘記在 switch 的時候忘了加上 break。如果光就只有這樣的話,它就跟其它程式語言的 switch 就沒太大的差別了。

match 跟 Enum 搭在一起用的時候,Rust 編譯器會檢查是否所有的可能性都考慮到了,就以這點來說很 Rust。假設我故意漏一個沒寫,像這樣:

match breed {
    CatBreed::AmericanShorthair => println!("我是美國短毛貓"),
    CatBreed::Persian => println!("我是波斯貓")
}

編譯過程就會發生錯誤:

$ cargo run
error[E0004]: non-exhaustive patterns: `CatBreed::Mix` not covered
10 |     match breed {
   |           ^^^^^ pattern `CatBreed::Mix` not covered

Rust 編譯器明白的告訴你 CatBreed::Mix 這個沒有寫到。其它程式語言的 Enum 可能根本不在意這種事,沒寫到就沒寫到,在 JavaScript 說不定就給你個 undefined 就算了,但 Rust 編譯器就是這麼龜毛,可以的話就盡量在編譯階段就知道所有的可能性,要講清楚說明白,不要有任何不確定性。

但如果變體有 10 個、20 個怎麼辦?難道要每個都寫嗎?match 有提供一種「剩下的我都包了」的寫法:

match breed {
    CatBreed::Mix => println!("我是米克斯"),
    _ => println!("我是品種貓")
}

使用 _ 可以用來代表所有其它的可能性,有點像預設值的概念,所以上面這段範例就能解釋為「如果不是米克斯,其它的都是品種貓」。不過因為 match 在比對的時候會由上而下依序比對,使用 _ 的時候要注意順序問題,像是這樣反過來這樣寫的話:

match breed {
    _ => println!("我是品種貓"),
    CatBreed::Mix => println!("我是米克斯")
}

因為在上面的 _ 就會把所有的可能性都吃掉了,後續的 CatBreed::Mix 就根本沒有機會被觸及,所以不管是什麼品種,一律都只會印出「我是品種貓」字樣。這樣寫編譯的時候不會出錯,因為不知道你是故意的還是不小心的,但 Rust 還是會貼心的提醒你一下:

$ cargo run
warning: unreachable pattern
11 |         _ => {
   |         - matches any value
...
15 |         CatBreed::Mix => {
   |         ^^^^^^^^^^^^^ unreachable pattern

最遙遠的距離不是生與死,而是你就在我面前,我卻永遠走到不你身邊的 unreachable pattern

如果各位有一邊開著電腦一邊跟著敲打程式碼一邊執行的話,應該會發現剛剛執行的時候 Rust 編譯器會一直丟訊息提醒你一些事:

$ cargo run
warning: variants `Persian` and `AmericanShorthair` are never constructed
1 | enum CatBreed {
  |      -------- variants in this enum
2 |     Persian,           // 波斯貓
  |     ^^^^^^^
3 |     AmericanShorthair, // 美國短毛貓
  |     ^^^^^^^^^^^^^^^^^

意思就是這裡的 PersianAmericanShorthair 這兩種變體在程式碼裡面根本沒出現。為什麼沒出現?是不是一開始想比較多,多加了一些上去,還是後來需求變更導致某些變體不再使用但卻沒刪掉(或不敢刪)?其實這在 Struct 也有一樣的情況,Rust 編譯器在做正確的事,但如果你覺得 Rust 編譯器管的有點多,同樣可以在 Enum 前面加上 #[allow(dead_code)] 的屬性設定,暫時關閉檢查:

#[allow(dead_code)]
enum CatBreed {
    Persian,           // 波斯貓
    AmericanShorthair, // 美國短毛貓
    Mix,               // 米克斯
}

變體還能帶參數!

其它程式語言的 Enum 大概就真的只有「列舉」字面上的意思,把所有的變體一字排開列出來而已,但 Rust 的 Enum 還有一些特別的設計,其中之一就是它的變體還能帶參數:

enum CatBreed {
    Persian,              // 波斯貓
    AmericanShorthair,    // 美國短毛貓
    Mix(String, u8),      // 米克斯
}

如果變體有參數的話,在使用的時候也要帶給它:

let kitty = CatBreed::Mix(String::from("Kitty"), 8);
let nancy = CatBreed::Persian;

然後你也可以把它傳給其它函數,整個程式碼看起來會變這樣:

#[allow(dead_code)]
enum CatBreed {
    Persian,             // 波斯貓
    AmericanShorthair,   // 美國短毛貓
    Mix(String, u8),     // 米克斯
}

fn main() {
    let kitty = CatBreed::Mix(String::from("Kitty"), 8);
    let nancy = CatBreed::Persian;

    greeting(&kitty);
    greeting(&nancy);
}

fn greeting(cat: &CatBreed) {
    match cat {
        CatBreed::Mix(name, age) => println!("我是米克斯,我叫 {},我今年 {} 歲", name, age),
        _ => println!("我是品種貓")
    }
}

上面這個 greeting(cat: &CatBreed) 意思是帶進來的這個 cat 是一種 CatBreed (的參照),這 Enum 用起來的手感好像跟一般的型別或 Struct 有點像...

還沒完,Enum 裡的變體可以帶 Struct 進去,甚至連變體本身也可以是一個 Struct:

struct Skill {
    action: String
}

enum CatBreed {
    Persian,             // 波斯貓
    AmericanShorthair,   // 美國短毛貓
    Mix(String, u8),     // 米克斯
    Other(Skill),        // 其它
    Alien{power: u32}    // 外星貓
}

實際用起來大概會變這樣:

fn main() {
    let goku_cat = CatBreed::Other(Skill{action: "龜派氣功".to_string()});
    let frieza_cat = CatBreed::Alien { power: 530000 }; // 戰鬥力 53 萬

    greeting(&goku_cat);
    greeting(&frieza_cat);
}

fn greeting(cat: &CatBreed) {
    match cat {
        CatBreed::Mix(name, age) => println!("我是米克斯,我叫 {},我今年 {} 歲", name, age),
        CatBreed::Other(skill) => println!("使出絕招{}!", skill.action),
        CatBreed::Alien { power } => println!("我的戰鬥力是 {}", power),
        _ => println!("我是品種貓")
    }
}

看到這裡,有用過其它程式語言的 Enum 的人,應該很明顯感受到差異了。但,怎麼好像有點像在用 Struct 的即視感?不急,你再接著往下看:

impl CatBreed {
    fn go(&self) {
        println!("Go!");
    }
}

咦?impl 也能幫 Enum 加功能?是的,如果你喜歡,連 Trait 也能加在 Enum 上。這樣,到底 Enum 跟 Struct 有什麼差別?什麼時候該選用哪一種?

Enum vs Struct

Rust 中的 Enum 和 Struct 確實有些相似之處,但它們也有一些不一樣的地方,使用情境也不太一樣:

相同

不同:

使用時機

如果是要用來表示有限的狀態,例如像是用來表示最新款 iPhone 手機的顏色、訂單是否已結帳、已出貨、已到貨等不同狀態,或是需要對這些可能性進行模式匹配(Pattern Matching),然後依不同情況執行不同的程式碼。如果是這種情況可考慮使用 Enum,其它則可考慮使用 Struct。

Enum 跟 Struct 在 Rust 裡都是重要而且常用的資料型態,通常會根據實際情況而且選用合適的種類,我知道這句話聽起來跟在非洲每 60 秒就有一分鐘過去一樣的沒幫助,但在現在我相信講了也沒辦法想像是到底什麼情境適合哪一種,這就待後半段實作的時候遇到實際的狀況再來解釋會更有感覺。