目錄
- 枚舉和模式匹配
- 枚舉的定義
- Option 枚舉
- 控制流運算符 match
- 簡潔控制流 if let
枚舉和模式匹配
枚舉的定義
結構體給予你將字段和數據聚合在一起的方法,像 Rectangle
結構體有 width
和 height
兩個字段。而枚舉給予你一個途徑去聲明某個值是一個集合中的一員。
假設我們要處理 IP 地址。目前被廣泛使用的兩個主要 IP 標準:IPv4 和 IPv6。這是程序可能會遇到的所有可能的 IP 地址類型:所以可以枚舉出所有可能的值,這也正是此枚舉名字的由來。
任何一個 IP 地址要么是 IPv4 的要么是 IPv6 的,而且不能兩者都是。IP 地址的這個特性使得枚舉數據結構非常適合這個場景,因為枚舉值只可能是其中一個成員。IPv4 和 IPv6 從根本上講仍是 IP 地址,所以當代碼在處理適用于任何類型的 IP 地址的場景時應該把它們當作相同的類型。
可以通過在代碼中定義一個 IpAddrKind
枚舉來表現這個概念并列出可能的 IP 地址類型,V4
和 V6
。這被稱為枚舉的 成員:
enum IpAddrKind {V4,V6,
}
現在 IpAddrKind
就是一個可以在代碼中使用的自定義數據類型了。這樣就可以創建 IpAddrKind
兩個不同成員的實例:
let four = IpAddeKind::V4;
let six = IpAddeKind::V6;
注意枚舉的成員位于其標識符的命名空間中,并使用兩個冒號分開。這么設計的益處是現在 IpAddrKind::V4
和 IpAddrKind::V6
都是 IpAddrKind
類型的。例如,接著可以定義一個函數來接收任何 IpAddrKind
類型的參數,使用任一成員來調用這個函數:
enum IpAddrKind {V4,V6,
}fn main() {let four = IpAddrKind::V4;let six = IpAddrKind::V6;route(IpAddrKind::V4);route(IpAddrKind::V6);
}fn route(ip_kind: IpAddrKind) {}
使用枚舉甚至還有更多優勢。進一步考慮一下我們的 IP 地址類型,目前沒有一個存儲實際 IP 地址數據的方法;只知道它是什么類型的。可以使用結構體來解決這個問題:
#[derive(Debug)]
enum IpAddrKind {V4,V6,
}#[derive(Debug)]
struct IpAddr {kind: IpAddrKind,address: String,
}fn main() {let home = IpAddr {kind: IpAddrKind::V4,address: String::from("127.0.0.1"),};let loopback = IpAddr {kind: IpAddrKind::V6,address: String::from("::1"),};println!("{:?}", home);println!("{:?}", loopback);
}
這里定義了一個有兩個字段的結構體 IpAddr
:IpAddrKind
(之前定義的枚舉)類型的 kind
字段和 String
類型 address
字段。有這個結構體的兩個實例。第一個,home
,它的 kind
的值是 IpAddrKind::V4
與之相關聯的地址數據是 127.0.0.1
。第二個實例,loopback
,kind
的值是 IpAddrKind
的另一個成員,V6
,關聯的地址是 ::1
。使用了一個結構體來將 kind
和 address
打包在一起,現在枚舉成員就與值相關聯了。
還可以使用一種更簡潔的方式來表達相同的概念,僅僅使用枚舉并將數據直接放進每一個枚舉成員而不是將枚舉作為結構體的一部分。IpAddr
枚舉的新定義表明了 V4
和 V6
成員都關聯了 String
值:
#[derive(Debug)]
enum IpAddrKind {V4(String),V6(String),
}fn main() {let home = IpAddrKind::V4(String::from("127.0.0.1"));let loopback = IpAddrKind::V6(String::from("::1"));println!("{:?}", home);println!("{:?}", loopback);
}
直接將數據附加到枚舉的每個成員上,這樣就不需要一個額外的結構體了。這里也很容易看出枚舉工作的另一個細節:每一個定義的枚舉成員的名字也變成了一個構建枚舉的實例的函數。也就是說,IpAddr::V4()
是一個獲取 String
參數并返回 IpAddr
類型實例的函數調用。作為定義枚舉的結果,這些構造函數會自動被定義。
用枚舉替代結構體還有另一個優勢:每個成員可以處理不同類型和數量的數據。IPv4 版本的 IP 地址總是含有四個值在 0 和 255 之間的數字部分。如果想要將 V4
地址存儲為四個 u8
值而 V6
地址仍然表現為一個 String
,這就不能使用結構體了。枚舉則可以輕易的處理這個情況:
#[derive(Debug)]
enum IpAddrKind {V4(u8, u8, u8, u8),V6(String),
}fn main() {let home = IpAddrKind::V4(127,0,0,1);let loopback = IpAddrKind::V6(String::from("::1"));println!("{:?}", home);println!("{:?}", loopback);
}
這些代碼展示了如何用枚舉來表示兩種類型的 IP 地址。雖然這種做法是有效的,但由于存儲和處理 IP 地址在實際開發中非常常見,Rust 的標準庫早已為我們提供了一個現成的解決方案。標準庫中的 IpAddr
枚舉與我們自定義的非常相似,但它更進一步:將每種 IP 類型分別封裝在專門的結構體中,從而更清晰地區分不同格式的 IP 地址:
#![allow(unused)]
fn main() {
struct Ipv4Addr {// --snip--
}struct Ipv6Addr {// --snip--
}enum IpAddr {V4(Ipv4Addr),V6(Ipv6Addr),
}
}
這些代碼展示了可以將任意類型的數據放入枚舉成員中:例如字符串、數字類型或者結構體。甚至可以包含另一個枚舉!另外,標準庫中的類型通常并不比你設想出來的要復雜多少。
注意雖然標準庫中包含一個 IpAddr
的定義,仍然可以創建和使用我們自己的定義而不會有沖突,因為我們并沒有將標準庫中的定義引入作用域。
枚舉的成員中可以內嵌多種多樣的類型:
enum Message {Quit,Move { x: i32, y: i32 },Write(String),ChangeColor(i32, i32, i32),
}
這個枚舉有四個含有不同類型的成員:
Quit
沒有關聯任何數據。Move
類似結構體包含命名字段。Write
包含單獨一個String
。ChangeColor
包含三個i32
。
定義一個這樣的有關聯值的枚舉的方式和定義多個不同類型的結構體的方式很相像,除了枚舉不使用 struct
關鍵字以及其所有成員都被組合在一起位于 Message
類型下。如下這些結構體可以包含與之前枚舉成員中相同的數據:
struct QuitMessage; // 類單元結構體
struct MoveMessage {x: i32,y: i32,
}
struct WriteMessage(String); // 元組結構體
struct ChangeColorMessage(i32, i32, i32); // 元組結構體
不過,如果使用不同的結構體,由于它們都有不同的類型,將不能像使用定義的 Message
枚舉那樣,輕易的定義一個能夠處理這些不同類型的結構體的函數,因為枚舉是單獨一個類型。
結構體和枚舉還有另一個相似點:就像可以使用 impl
來為結構體定義方法那樣,也可以在枚舉上定義方法。這是一個定義于 Message
枚舉上的叫做 call
的方法:
#[derive(Debug)]
enum Message {Quit,Move { x: i32, y: i32 },Write(String),ChangeColor(i32, i32, i32),
}impl Message {fn call(&self) {println!("{:?}", self)}
}fn main() {let m = Message::Write(String::from("hello"));m.call();
}
方法體使用了 self
來獲取調用方法的值。這個例子中,創建了一個值為 Message::Write(String::from("hello"))
的變量 m
,而且這就是當 m.call()
運行時 call
方法中的 self
的值。
Option 枚舉
Option
是標準庫定義的另一個枚舉。Option
類型應用廣泛因為它編碼了一個非常普遍的場景,即一個值要么有值要么沒值。
例如,如果請求一個非空列表的第一項,會得到一個值,如果請求一個空的列表,就什么也不會得到。從類型系統的角度來表達這個概念就意味著編譯器需要檢查是否處理了所有應該處理的情況,這樣就可以避免在其他編程語言中非常常見的 bug。
編程語言的設計經常要考慮包含哪些功能,但考慮排除哪些功能也很重要。Rust 并沒有很多其他語言中有的空值功能。空值是一個值,它代表沒有值。在有空值的語言中,變量總是這兩種狀態之一:空值和非空值。
空值的問題在于當你嘗試像一個非空值那樣使用一個空值,會出現某種形式的錯誤。因為空和非空的屬性無處不在,非常容易出現這類錯誤。
然而,空值嘗試表達的概念仍然是有意義的:空值是一個因為某種原因目前無效或缺失的值。
問題不在于概念而在于具體的實現。為此,Rust 并沒有空值,不過它確實擁有一個可以編碼存在或不存在概念的枚舉。這個枚舉是 Option<T>
,而且它定義于標準庫中,如下:
enum Option<T> {None,Some(T),
}
Option<T>
枚舉是如此有用以至于它甚至被包含在了 prelude 之中,你不需要將其顯式引入作用域。另外,它的成員也是如此,可以不需要 Option::
前綴來直接使用 Some
和 None
。即便如此 Option<T>
也仍是常規的枚舉,Some(T)
和 None
仍是 Option<T>
的成員。
<T>
是一個泛型類型參數,目前,只需要知道的就是 <T>
意味著 Option
枚舉的 Some
成員可以包含任意類型的數據,同時每一個用于 T
位置的具體類型使得 Option<T>
整體作為不同的類型。這里是一些包含數字類型和字符串類型 Option
值的例子:
fn main() {let some_number = Some(5);let some_string = Some("a string");let absent_number: Option<i32> = None;println!("{:?}", some_number);println!("{:?}", some_string);println!("{:?}", absent_number);
}
some_number
的類型是 Option<i32>
。some_char
的類型是 Option<char>
,是不同于some_number
的類型。因為在 Some
成員中指定了值,Rust 可以推斷其類型。對于 absent_number
,Rust 需要指定 Option
整體的類型,因為編譯器只通過 None
值無法推斷出 Some
成員保存的值的類型。這里我們告訴 Rust 希望 absent_number
是 Option<i32>
類型的。
當有一個 Some
值時,就知道存在一個值,而這個值保存在 Some
中。當有個 None
值時,在某種意義上,它跟空值具有相同的意義:并沒有一個有效的值。那么,Option<T>
為什么就比空值要好呢?
簡而言之,因為 Option<T>
和 T
(這里 T
可以是任何類型)是不同的類型,編譯器不允許像一個肯定有效的值那樣使用 Option<T>
。例如,這段代碼不能編譯,因為它嘗試將 Option<i8>
與 i8
相加:
fn main() {let x: i8 = 5;let y: Option<i8> = Some(5);let sum = x + y;
}
運行這段代碼將會產生錯誤信息:
error[E0277]: cannot add `Option<i8>` to `i8`--> src/main.rs:46:17|
46 | let sum = x + y;| ^ no implementation for `i8 + Option<i8>`|= help: the trait `Add<Option<i8>>` is not implemented for `i8`= help: the following other types implement trait `Add<Rhs>`:`&i8` implements `Add<i8>``&i8` implements `Add``i8` implements `Add<&i8>``i8` implements `Add`
這意味著 Rust 不知道該如何將 Option<i8>
與 i8
相加,因為它們的類型不同。當在 Rust 中擁有一個像 i8
這樣類型的值時,編譯器確保它總是有一個有效的值。這樣可以自信使用而無需做空值檢查。只有當使用 Option<i8>
(或者任何用到的類型)的時候需要擔心可能沒有值,而編譯器會確保在使用值之前處理了為空的情況。
換句話說,在對 Option<T>
進行運算之前必須將其轉換為 T
。通常這能幫助開發者捕獲到空值最常見的問題之一:假設某值不為空但實際上為空的情況。
Option 枚舉最常見的應用場景就是函數可能返回空值,如下面代碼所示:
fn find_user(id: u32) -> Option<String> {if id == 1 {Some("Alice".to_string())} else {None}
}fn main() {if let Some(name) = find_user(1) {println!("User: {}", name);} else {println!("User not found.");}
}
用途: 比如查數據庫、查列表、查配置時,找不到返回 None
,找到了返回 Some(value)
。
控制流運算符 match
Rust 有一個叫做 match
的極為強大的控制流運算符,它允許我們將一個值與一系列的模式相比較,并根據相匹配的模式執行相應代碼。模式可由字面值、變量、通配符和許多其他內容構成。match
的力量來源于模式的表現力以及編譯器檢查,它確保了所有可能的情況都得到處理。
可以把 match
表達式想象成某種硬幣分類器:硬幣滑入有著不同大小孔洞的軌道,每一個硬幣都會掉入符合它大小的孔洞。同樣地,值也會通過 match
的每一個模式,并且在遇到第一個 “符合” 的模式時,值會進入相關聯的代碼塊并在執行中被使用:
enum Coin {Penny,Nickel,Dime,Quarter,
}fn value_in_cents(coin: Coin) -> u8 {match coin {Coin::Penny => 1,Coin::Nickel => 5,Coin::Dime => 10,Coin::Quarter => 25,}
}fn main() {println!("{}", value_in_cents(Coin::Penny));println!("{}", value_in_cents(Coin::Nickel));println!("{}", value_in_cents(Coin::Dime));println!("{}", value_in_cents(Coin::Quarter));
}
拆開 value_in_cents
函數中的 match
來看。首先,列出 match
關鍵字后跟一個表達式,在這個例子中是 coin
的值。這看起來非常像 if
所使用的條件表達式,不過這里有一個非常大的區別:對于 if
,表達式必須返回一個布爾值,而這里它可以是任何類型的。
接下來是 match
的分支。一個分支有兩個部分:一個模式和一些代碼。第一個分支的模式是值 Coin::Penny
而之后的 =>
運算符將模式和將要運行的代碼分開。每一個分支之間使用逗號分隔。
當 match
表達式執行時,它將結果值按順序與每一個分支的模式相比較。如果模式匹配了這個值,這個模式相關聯的代碼將被執行。如果模式并不匹配這個值,將繼續執行下一個分支,非常類似一個硬幣分類器。可以擁有任意多的分支。
每個分支相關聯的代碼是一個表達式,而表達式的結果值將作為整個 match
表達式的返回值。
如果分支代碼較短的話通常不使用大括號,正如每個分支都只是返回一個值。如果想要在分支中運行多行代碼,可以使用大括號,而分支后的逗號是可選的。例如,如下代碼在每次使用Coin::Penny
調用時都會打印出 “Lucky penny!”,同時仍然返回代碼塊最后的值,1
:
fn value_in_cents(coin: Coin) -> u8 {match coin {Coin::Penny => {println!("Lucky penny!");1}Coin::Nickel => 5,Coin::Dime => 10,Coin::Quarter => 25,}
}
匹配分支的另一個有用的功能是可以綁定匹配的模式的部分值。
1999 年到 2008 年間,美國在 25 美分的硬幣的一側為 50 個州的每一個都印刷了不同的設計。其他的硬幣都沒有這種區分州的設計,所以只有這些 25 美分硬幣有特殊的價值。可以將這些信息加入一個 enum
,通過改變 Quarter
成員來包含一個 State
值:
#[derive(Debug)]
enum UsState {Alabama,Alaska,
}enum Coin {Penny,Nickel,Dime,Quarter(UsState),
}fn value_in_cents(coin: Coin) -> u8 {match coin {Coin::Penny => 1,Coin::Nickel => 5,Coin::Dime => 10,Coin::Quarter(state) => {println!("State quarter from {:?}!", state);25}}
}fn main() {let value = value_in_cents(Coin::Quarter(UsState::Alaska));println!("{:?}", value);
}
如果調用 value_in_cents(Coin::Quarter(UsState::Alaska))
,coin
將是 Coin::Quarter(UsState::Alaska)
。當將值與每個分支相比較時,沒有分支會匹配,直到遇到 Coin::Quarter(state)
。這時,state
綁定的將會是值 UsState::Alaska
。接著就可以在 println!
表達式中使用這個綁定了,像這樣就可以獲取 Coin
枚舉的 Quarter
成員中內部的州的值。
在之前的部分中使用 Option<T>
時,是為了從 Some
中取出其內部的 T
值;還可以像處理 Coin
枚舉那樣使用 match
處理 Option<T>
,只不過這回比較的不再是硬幣,而是 Option<T>
的成員,但 match
表達式的工作方式保持不變。
比如想要編寫一個函數,它獲取一個 Option<i32>
,如果其中含有一個值,將其加一。如果其中沒有值,函數應該返回 None
值,而不嘗試執行任何操作:
fn main() {let five = Some(5);let six = plus_one(five);let none = plus_one(None);println!("{:?}, {:?}, {:?}", five, six, none);
}
fn plus_one(x: Option<i32>) -> Option<i32> {match x {None => None,Some(i) => Some(i + 1),}
}
match
還有另一方面需要討論:這些分支必須覆蓋了所有的可能性。考慮一下 plus_one
函數的這個版本,它有一個 bug 并不能編譯:
fn main() {let five = Some(5);let six = plus_one(five);let none = plus_one(None);println!("{:?}, {:?}, {:?}", five, six, none);
}
fn plus_one(x: Option<i32>) -> Option<i32> {match x {Some(i) => Some(i + 1),}
}
沒有處理 None
的情況,所以這些代碼會造成一個 bug。幸運的是,這是一個 Rust 知道如何處理的 bug。如果嘗試編譯這段代碼,會得到這個錯誤:
error[E0004]: non-exhaustive patterns: `None` not covered--> src/main.rs:8:11|
8 | match x {| ^ pattern `None` not covered|
note: `Option<i32>` defined here--> /Users/huangruibang/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/option.rs:572:1|
572 | pub enum Option<T> {| ^^^^^^^^^^^^^^^^^^
...
576 | None,| ---- not covered= note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown|
9 ~ Some(i) => Some(i + 1),
10 ~ None => todo!(),|
Rust 知道沒有覆蓋所有可能的情況甚至知道哪些模式被忘記了。Rust 中的匹配是窮盡的:必須窮舉到最后的可能性來使代碼有效。特別的在這個 Option<T>
的例子中,Rust 防止開發者忘記明確的處理 None
的情況,這讓開發者免于假設擁有一個實際上為空的值,從而使錯誤不可能發生。
有時候只需要對特定的值采取特殊操作,其他的值采取默認操作,就可以通過通配模式——將匹配到的默認值綁定為 other
來實現。例如:只在 1、3、5、7 的時候有輸出,其它數字都不進行操作:
fn main() {let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];for element in arr {match element {1 => println!("One"),3 => println!("Three"),5 => println!("Five"),7 => println!("Seven"),other => println!("Other"),}}
}
除了用通配模式,還可以用占位符 _
來實現:
fn main() {let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];for element in arr {match element {1 => println!("One"),3 => println!("Three"),5 => println!("Five"),7 => println!("Seven"),_ => println!("Other"),}}
}
簡潔控制流 if let
在數字 1-10 中隨機生成一個數,只有生成 6 才會顯示 “You win!”,用 match
的代碼如下:
use rand::Rng;
fn main() {let number = rand::thread_rng().gen_range(1..=10);println!("{}", number);match number {6 => println!("You win!"),_ => (),}
}
if let
語法以一種不那么冗長的方式結合 if
和 let
,來處理只匹配一個模式的值而忽略其他模式的情況:
use rand::Rng;
fn main() {let number = rand::thread_rng().gen_range(1..=10);println!("{}", number);if let 6 = number {println!("You win!");};
}
if let
語法獲取通過等號分隔的一個模式和一個表達式。它的工作方式與 match
相同,這里的表達式對應 match
而模式則對應第一個分支。模式不匹配時 if let
塊中的代碼不會執行。
使用 if let
意味著編寫更少代碼,更少的縮進和更少的樣板代碼。然而,這樣會失去 match
強制要求的窮盡性檢查。match
和 if let
之間的選擇依賴特定的環境以及增加簡潔度和失去窮盡性檢查的權衡取舍。
換句話說,可以認為 if let
是 match
的一個語法糖,它當值匹配某一模式時執行代碼而忽略所有其他值。
可以在 if let
中包含一個 else
。else
塊中的代碼與 match
表達式中的 _
分支塊中的代碼相同,這樣的 match
表達式就等同于 if let
和 else
。
生成非 6 的數字顯示 “You lose!”:
use rand::Rng;
fn main() {let number = rand::thread_rng().gen_range(1..=10);println!("{}", number);if let 6 = number {println!("You win!");}else { println!("You lose!");}
}
if let
–else
是 match
的簡化版,類似 if
–else
,但專門匹配特定模式,更適合只關心一種匹配的情況。