Rust 枚舉的定義

2023-03-22 15:09 更新
ch06-01-defining-an-enum.md
commit c76f1b4d011fe59fc4f5e6f258070fc40d9921e4

結(jié)構(gòu)體給予你將字段和數(shù)據(jù)聚合在一起的方法,像 Rectangle 結(jié)構(gòu)體有 width 和 height 兩個字段。而枚舉給予你將一個值成為一個集合之一的方法。比如,我們想讓 Rectangle 是一些形狀的集合,包含 Circle 和 Triangle 。為了做到這個,Rust提供了枚舉類型。

讓我們看看一個需要訴諸于代碼的場景,來考慮為何此時使用枚舉更為合適且實用。假設(shè)我們要處理 IP 地址。目前被廣泛使用的兩個主要 IP 標(biāo)準(zhǔn):IPv4(version four)和 IPv6(version six)。這是我們的程序可能會遇到的所有可能的 IP 地址類型:所以可以 枚舉 出所有可能的值,這也正是此枚舉名字的由來。

任何一個 IP 地址要么是 IPv4 的要么是 IPv6 的,而且不能兩者都是。IP 地址的這個特性使得枚舉數(shù)據(jù)結(jié)構(gòu)非常適合這個場景,因為枚舉值只可能是其中一個成員。IPv4 和 IPv6 從根本上講仍是 IP 地址,所以當(dāng)代碼在處理適用于任何類型的 IP 地址的場景時應(yīng)該把它們當(dāng)作相同的類型。

可以通過在代碼中定義一個 IpAddrKind 枚舉來表現(xiàn)這個概念并列出可能的 IP 地址類型,V4 和 V6。這被稱為枚舉的 成員variants):

enum IpAddrKind {
    V4,
    V6,
}

現(xiàn)在 IpAddrKind 就是一個可以在代碼中使用的自定義數(shù)據(jù)類型了。

枚舉值

可以像這樣創(chuàng)建 IpAddrKind 兩個不同成員的實例:

    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

注意枚舉的成員位于其標(biāo)識符的命名空間中,并使用兩個冒號分開。這么設(shè)計的益處是現(xiàn)在 IpAddrKind::V4 和 IpAddrKind::V6 都是 IpAddrKind 類型的。例如,接著可以定義一個函數(shù)來獲取任何 IpAddrKind

fn route(ip_kind: IpAddrKind) {}

現(xiàn)在可以使用任一成員來調(diào)用這個函數(shù):

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);

使用枚舉甚至還有更多優(yōu)勢。進(jìn)一步考慮一下我們的 IP 地址類型,目前沒有一個存儲實際 IP 地址 數(shù)據(jù) 的方法;只知道它是什么 類型 的??紤]到已經(jīng)在第五章學(xué)習(xí)過結(jié)構(gòu)體了,你可能會像示例 6-1 那樣處理這個問題:

    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };

示例 6-1:將 IP 地址的數(shù)據(jù)和 IpAddrKind 成員存儲在一個 struct 中

這里我們定義了一個有兩個字段的結(jié)構(gòu)體 IpAddrIpAddrKind(之前定義的枚舉)類型的 kind 字段和 String 類型 address 字段。我們有這個結(jié)構(gòu)體的兩個實例。第一個,home,它的 kind 的值是 IpAddrKind::V4 與之相關(guān)聯(lián)的地址數(shù)據(jù)是 127.0.0.1。第二個實例,loopback,kind 的值是 IpAddrKind 的另一個成員,V6,關(guān)聯(lián)的地址是 ::1。我們使用了一個結(jié)構(gòu)體來將 kind 和 address 打包在一起,現(xiàn)在枚舉成員就與值相關(guān)聯(lián)了。

我們可以使用一種更簡潔的方式來表達(dá)相同的概念,僅僅使用枚舉并將數(shù)據(jù)直接放進(jìn)每一個枚舉成員而不是將枚舉作為結(jié)構(gòu)體的一部分。IpAddr 枚舉的新定義表明了 V4 和 V6 成員都關(guān)聯(lián)了 String 值:

    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));

我們直接將數(shù)據(jù)附加到枚舉的每個成員上,這樣就不需要一個額外的結(jié)構(gòu)體了。這里也很容易看出枚舉工作的另一個細(xì)節(jié):每一個我們定義的枚舉成員的名字也變成了一個構(gòu)建枚舉的實例的函數(shù)。也就是說,IpAddr::V4() 是一個獲取 String 參數(shù)并返回 IpAddr 類型實例的函數(shù)調(diào)用。作為定義枚舉的結(jié)果,這些構(gòu)造函數(shù)會自動被定義。

用枚舉替代結(jié)構(gòu)體還有另一個優(yōu)勢:每個成員可以處理不同類型和數(shù)量的數(shù)據(jù)。IPv4 版本的 IP 地址總是含有四個值在 0 和 255 之間的數(shù)字部分。如果我們想要將 V4 地址存儲為四個 u8 值而 V6 地址仍然表現(xiàn)為一個 String,這就不能使用結(jié)構(gòu)體了。枚舉則可以輕易的處理這個情況:

    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));

這些代碼展示了使用枚舉來存儲兩種不同 IP 地址的幾種可能的選擇。然而,事實證明存儲和編碼 IP 地址實在是太常見了以致標(biāo)準(zhǔn)庫提供了一個開箱即用的定義!讓我們看看標(biāo)準(zhǔn)庫是如何定義 IpAddr 的:它正有著跟我們定義和使用的一樣的枚舉和成員,不過它將成員中的地址數(shù)據(jù)嵌入到了兩個不同形式的結(jié)構(gòu)體中,它們對不同的成員的定義是不同的:

struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

這些代碼展示了可以將任意類型的數(shù)據(jù)放入枚舉成員中:例如字符串、數(shù)字類型或者結(jié)構(gòu)體。甚至可以包含另一個枚舉!另外,標(biāo)準(zhǔn)庫中的類型通常并不比你設(shè)想出來的要復(fù)雜多少。

注意雖然標(biāo)準(zhǔn)庫中包含一個 IpAddr 的定義,仍然可以創(chuàng)建和使用我們自己的定義而不會有沖突,因為我們并沒有將標(biāo)準(zhǔn)庫中的定義引入作用域。第七章會講到如何導(dǎo)入類型。

來看看示例 6-2 中的另一個枚舉的例子:它的成員中內(nèi)嵌了多種多樣的類型:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

示例 6-2:一個 Message 枚舉,其每個成員都存儲了不同數(shù)量和類型的值

這個枚舉有四個含有不同類型的成員:

  • ?Quit ?沒有關(guān)聯(lián)任何數(shù)據(jù)。
  • ?Move ?類似結(jié)構(gòu)體包含命名字段。
  • ?Write ?包含單獨(dú)一個 ?String?。
  • ?ChangeColor ?包含三個 ?i32?。

定義一個如示例 6-2 中所示那樣的有關(guān)聯(lián)值的枚舉的方式和定義多個不同類型的結(jié)構(gòu)體的方式很相像,除了枚舉不使用 struct 關(guān)鍵字以及其所有成員都被組合在一起位于 Message 類型下。如下這些結(jié)構(gòu)體可以包含與之前枚舉成員中相同的數(shù)據(jù):

struct QuitMessage; // 類單元結(jié)構(gòu)體
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // 元組結(jié)構(gòu)體
struct ChangeColorMessage(i32, i32, i32); // 元組結(jié)構(gòu)體

不過,如果我們使用不同的結(jié)構(gòu)體,由于它們都有不同的類型,我們將不能像使用示例 6-2 中定義的 Message 枚舉那樣,輕易的定義一個能夠處理這些不同類型的結(jié)構(gòu)體的函數(shù),因為枚舉是單獨(dú)一個類型。

結(jié)構(gòu)體和枚舉還有另一個相似點(diǎn):就像可以使用 impl 來為結(jié)構(gòu)體定義方法那樣,也可以在枚舉上定義方法。這是一個定義于我們 Message 枚舉上的叫做 call 的方法:

    impl Message {
        fn call(&self) {
            // 在這里定義方法體
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();

方法體使用了 self 來獲取調(diào)用方法的值。這個例子中,創(chuàng)建了一個值為 Message::Write(String::from("hello")) 的變量 m,而且這就是當(dāng) m.call() 運(yùn)行時 call 方法中的 self 的值。

讓我們看看標(biāo)準(zhǔn)庫中的另一個非常常見且實用的枚舉:Option

Option 枚舉和其相對于空值的優(yōu)勢

這一部分會分析一個 Option 的案例,Option 是標(biāo)準(zhǔn)庫定義的另一個枚舉。Option 類型應(yīng)用廣泛因為它編碼了一個非常普遍的場景,即一個值要么有值要么沒值。

例如,如果請求一個包含項的列表的第一個值,會得到一個值,如果請求一個空的列表,就什么也不會得到。從類型系統(tǒng)的角度來表達(dá)這個概念就意味著編譯器需要檢查是否處理了所有應(yīng)該處理的情況,這樣就可以避免在其他編程語言中非常常見的 bug。

編程語言的設(shè)計經(jīng)常要考慮包含哪些功能,但考慮排除哪些功能也很重要。Rust 并沒有很多其他語言中有的空值功能。空值Null )是一個值,它代表沒有值。在有空值的語言中,變量總是這兩種狀態(tài)之一:空值和非空值。

Tony Hoare,null 的發(fā)明者,在他 2009 年的演講 “Null References: The Billion Dollar Mistake” 中曾經(jīng)說到:

I call it my billion-dollar mistake. At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn't resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.

我稱之為我十億美元的錯誤。當(dāng)時,我在為一個面向?qū)ο笳Z言設(shè)計第一個綜合性的面向引用的類型系統(tǒng)。我的目標(biāo)是通過編譯器的自動檢查來保證所有引用的使用都應(yīng)該是絕對安全的。不過我未能抵抗住引入一個空引用的誘惑,僅僅是因為它是這么的容易實現(xiàn)。這引發(fā)了無數(shù)錯誤、漏洞和系統(tǒng)崩潰,在之后的四十多年中造成了數(shù)十億美元的苦痛和傷害。

空值的問題在于當(dāng)你嘗試像一個非空值那樣使用一個空值,會出現(xiàn)某種形式的錯誤。因為空和非空的屬性無處不在,非常容易出現(xiàn)這類錯誤。

然而,空值嘗試表達(dá)的概念仍然是有意義的:空值是一個因為某種原因目前無效或缺失的值。

問題不在于概念而在于具體的實現(xiàn)。為此,Rust 并沒有空值,不過它確實擁有一個可以編碼存在或不存在概念的枚舉。這個枚舉是 Option<T>,而且它定義于標(biāo)準(zhǔn)庫中,如下:

enum Option<T> {
    None,
    Some(T),
}

Option<T> 枚舉是如此有用以至于它甚至被包含在了 prelude 之中,你不需要將其顯式引入作用域。另外,它的成員也是如此,可以不需要 Option:: 前綴來直接使用 Some 和 None。即便如此 Option<T> 也仍是常規(guī)的枚舉,Some(T) 和 None 仍是 Option<T> 的成員。

<T> 語法是一個我們還未講到的 Rust 功能。它是一個泛型類型參數(shù),第十章會更詳細(xì)的講解泛型。目前,所有你需要知道的就是 <T> 意味著 Option 枚舉的 Some 成員可以包含任意類型的數(shù)據(jù),同時每一個用于 T 位置的具體類型使得 Option<T> 整體作為不同的類型。這里是一些包含數(shù)字類型和字符串類型 Option 值的例子:

    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;

some_number 的類型是 Option<i32>。some_char 的類型是 Option<char>,這(與 some_number)是一個不同的類型。因為我們在 Some 成員中指定了值,Rust 可以推斷其類型。對于 absent_number, Rust 需要我們指定 Option 整體的類型,因為編譯器只通過 None 值無法推斷出 Some 成員保存的值的類型。這里我們告訴 Rust 希望 absent_number 是 Option<i32> 類型的。

當(dāng)有一個 Some 值時,我們就知道存在一個值,而這個值保存在 Some 中。當(dāng)有個 None 值時,在某種意義上,它跟空值具有相同的意義:并沒有一個有效的值。那么,Option<T> 為什么就比空值要好呢?

簡而言之,因為 Option<T> 和 T(這里 T 可以是任何類型)是不同的類型,編譯器不允許像一個肯定有效的值那樣使用 Option<T>。例如,這段代碼不能編譯,因為它嘗試將 Option<i8> 與 i8 相加:

    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;

如果運(yùn)行這些代碼,將得到類似這樣的錯誤信息:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` due to previous error

很好!事實上,錯誤信息意味著 Rust 不知道該如何將 Option<i8> 與 i8 相加,因為它們的類型不同。當(dāng)在 Rust 中擁有一個像 i8 這樣類型的值時,編譯器確保它總是有一個有效的值。我們可以自信使用而無需做空值檢查。只有當(dāng)使用 Option<i8>(或者任何用到的類型)的時候需要擔(dān)心可能沒有值,而編譯器會確保我們在使用值之前處理了為空的情況。

換句話說,在對 Option<T> 進(jìn)行 T 的運(yùn)算之前必須將其轉(zhuǎn)換為 T。通常這能幫助我們捕獲到空值最常見的問題之一:假設(shè)某值不為空但實際上為空的情況。

消除了錯誤地假設(shè)一個非空值的風(fēng)險,會讓你對代碼更加有信心。為了擁有一個可能為空的值,你必須要顯式的將其放入對應(yīng)類型的 Option<T> 中。接著,當(dāng)使用這個值時,必須明確的處理值為空的情況。只要一個值不是 Option<T> 類型,你就 可以 安全的認(rèn)定它的值不為空。這是 Rust 的一個經(jīng)過深思熟慮的設(shè)計決策,來限制空值的泛濫以增加 Rust 代碼的安全性。

那么當(dāng)有一個 Option<T> 的值時,如何從 Some 成員中取出 T 的值來使用它呢?Option<T> 枚舉擁有大量用于各種情況的方法:你可以查看它的文檔。熟悉 Option<T> 的方法將對你的 Rust 之旅非常有用。

總的來說,為了使用 Option<T> 值,需要編寫處理每個成員的代碼。你想要一些代碼只當(dāng)擁有 Some(T) 值時運(yùn)行,允許這些代碼使用其中的 T。也希望一些代碼在值為 None 時運(yùn)行,這些代碼并沒有一個可用的 T 值。match 表達(dá)式就是這么一個處理枚舉的控制流結(jié)構(gòu):它會根據(jù)枚舉的成員運(yùn)行不同的代碼,這些代碼可以使用匹配到的值中的數(shù)據(jù)。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號