Enums and Pattern Matching 枚举和模式匹配
在本章中,我们将介绍枚举的相关内容。枚举允许你通过列举可能的变量来定义一种类型。首先,我们将会定义和使用一个枚举,来像你介绍如何通过枚举给数据赋予意义;然后,我们会一起研究一个非常有用的枚举Option
,它表达某个值可以是任何东西或者没有东西;接着,我们还会讨论下模式匹配,并介绍match
表达式,通过它我们能对枚举中不同的值执行不同的代码;最后,我们会介绍如何使用if let
构造来方便简洁的让你在代码中处理枚举。
在很多语言中都有枚举这个特性,但它的功能在每种语言中又有所不同。Rust中的枚举更类似函数式编程语言(F#,OCaml,Haskell)中的代数数据类型。
Defining an Enum 定义一个枚举
我们先来尝试假设某一场景,在该场景中,相比于使用结构,枚举将更加有用并合适——我们现在须要一个IP地址。目前IP地址有两大主流标准,IPv4和IPv6,这即是程序会遇到的IP地址的唯一可能性:我们可以枚举所有可能的变量,通过这种方式为枚举命名。
一个IP地址,它可以是IPv4也可以是IPv6,但无法同时既是IPv6,又是IPv4。鉴于IP地址的这种特点,使用枚举数据结构就会非常合适,因为枚举的值只能是它其中的一个变量。因为IPv4和IPv6目前仍然还是IP地址的基石,所以代码在处理两类地址时须要一视同仁,要能够接受任何类型的IP地址。
在下面的程序中,我们通过定义一个IpAddrKind
枚举来表达上述的概念,并且将所有可能的IP地址类型通过枚举变量V4
和V6
罗列出来:
enum IpAddrKind {
V4,
V6,
}
IpAddrKind
是一个自定义的数据类型,我们可以在代码中的任何地方用到它。
Enum Values 枚举的值
我们可以分别为IpAddrKind
中的两个变量创建对应的实例:
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
注意到没,我们通过用两个冒号指明枚举中的变量是来自同个命名空间,即枚举定义。这样一来,IpAddrKind::V4
的值和IpAddrKind::V6
的值都是相同的类型:IpAddrKind
,下一步我们就可以定义一个函数接受任何IpAddrKind
的值:
fn route(ip_kind: IpAddrKind) { }
我们可以使用任一变量来调用这个函数:
route(IpAddrKind::V4);
route(IpAddrKind::V6);
使用枚举还有很多其它的优点。再看看我们的IP地址类型,现在我们还没有往它里面写入真实的IP地址数据,只知道它的类型。你使用在第5章结构中学到的知识,通过下面这样的方式来绕过这个问题:
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"),
};
这里我们定义了一个结构IpAddr
,它有两个字段:kind
字段类型是IpAddrKind
,即我们之前定义的枚举;字段address
,类型是String
。我们为这个结构创建了两个实例:home
的kind
是值IpAddrKind::V4
,它的IP地址是127.0.0.1
;第二个实例loopback
使用了另一个变量IpAddrKind::V6
来作为kind
字段的值,其IP是::1
。在上面的例子中,我们通过一个结构将kind
和address
的值绑定在了一起,现在我们的枚举变量有了一个相关的值。
现实中,大可不必如此麻烦,我们可以通过一个更加简洁的方法——只使用枚举而非将它包含在结构中实现相同的目的,我们可以直接往枚举变量中写入数据。下面例子中IpAddr
的新定义方式,可以为V4
和V6
变量直接设置关联的String
值:
enum IpAddr {
V4(String),
V6(String),
}
let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));
我们直接将数据添加到枚举变量,省去了一个额外的结构。
相比使用结构,在这里使用枚举还有另一个优点:每一个变量能够包含不同类型和数量的关联数据。IPv4地址包含有四段0到255直接的数字,如果对于V4
地址,我们保存四个u8
值,但仍使用String
值来表示V6
地址,用结构显然是不能实现这样的需求的,但枚举却可以做到:
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地址是一个非常通用的需求,所以Rust的标准库中已经有了一个我们可以使用的定义。来研究下标准库中是如何去定义IpAddr
的:它的确像我们一样定义并使用了枚举,但是枚举变量中的数据,却是被存储进了不同的结构中:
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
这段代码说明你可以把任何数据类型塞进枚举变量中:譬如字符串、数字或结构。你甚至可以包含另一个枚举!不必担心,标准库中的数据类型,通常不会比下面讲到的例子复杂多少。
尽管标准库中已经包含了IpAddr
的定义,但是我们仍是可以使用我们自己定义的枚举类型而不会冲突,这是因为我们没有将标准库的定义引入我们的作用域。在第7章中,我们会介绍如何将其它数据类型引入作用域。
让我们再来看一个更加复杂的枚举,它的枚举变量里包含了很多不同的数据类型:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
这个枚举包含了四个不同数据类型的枚举变量:
Quit
没有任何关联数据Move
包含一个匿名结构Write
包含一个String
ChangeColor
包含三个i32
值
定义包含多个变量的枚举就像是定义不同类型的结构,只不过枚举不是使用关键字struct
,并且所有的变量都被组合起来挂在Message
类型下。如果想通过结构来实现像上面一样的功能,那你就得像下面这样做:
struct QuitMessage; // unit struct
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct
这样做固然没错,但如果在程序中我们使用了许多不同的结构,它们都有自己的数据类型,想要定义个函数来处理所有这些类型的数据,那显然并不是个轻松的活计。而如果使用枚举Message
,那就非常简单了。
枚举和结构还有一个相似的地方:就像我们能用关键字impl
为结构定义不同的方法函数,在枚举上同样也可以做。下面的例子中,我们在Message
这个枚举上定义了一个call
方法函数:
impl Message {
fn call(&self) {
// method body would be defined here
}
}
let m = Message::Write(String::from("hello"));
m.call();
在方法函数体中,使用self
来获取调用方法的实例值。在这个例子里,我们创建了一个变量m
,它的值是Message::Write(String::from("hello"))
,在m.call()
调用call
方法函数时,方法函数体中self
的值即是这个实例m
的值。
下面在介绍一下在标准库中被广泛使用到的一个非常有用的枚举:Option
。
The Option
Enum and Its Advantages Over Null Values Option
枚举对比Null值的优势
在上一节中,我们看到通过IpAddr
枚举,可以让Rust的类型系统处理多种数据类型。本节中,我们将学习下Option
,它是标准库中定义的一个枚举。Option
在许多地方被用到,它被用于处理一些高频出现的场景,这些场景中,一个值可以是任何东西或者根本没有东西。在类型系统中引用这个概念,意味着编译器能够检查你是否处理了所有你应该要处理的各种可能;这个功能能够阻止发生一些其它编程语言中常常出现的bug。
编程语言常常只会考虑你想要哪些功能,但是你不想要的功能同样也很重要。Rust中没有其它编程语言中都有的null特性,Null空通常用于代表变量中没有任何值。在那些支持null值的编程语言中,变量通常只有两种状态:空和非空。
null的发明者Tony Hoare,在2009年发表了一篇著作“Null References: The Billion Dollar Mistake 空引用:价值十亿的错误”,在文章中,他写到:
我把它称作我价值十亿的错误。当初我在为一个OO语言的引用功能设计我的第一个通用类型系统时,我的目的是要确保编译器能够自动去检查所有的引用都绝对安全。那时我没能经受住引入空引用的诱惑,因为它是如此容易实现。于是乎,它带来了数不尽的错误、漏洞、系统崩溃,并且在过去的四十个年头中,可能已经造成了超过十亿美元的损失。
造成问题的原因,通常是你尝试将一个空值作为非空值去使用。因为空值和非空值是如此普遍的在程序中使用并出现,所以非常容易产生各种错误。
不过,空值想表达的概念还是有价值的:空值也是一个值,它代表了因为某些原因,一个值目前是无效的或是不存在的。
空值的概念并没有问题,但实际应用时却是两码事了。有鉴于此,Rust中就没有空值一说了,Rust通过使用一个枚举来表达一个值可能存在或不存在的概念,这既是标准库中定义的枚举Option<T>
:
enum Option<T> {
Some(T),
None,
}
枚举Option<T>
是如此有用,所以它已被加进了序幕中,你不须要另外再专门将它引入作用域,除此之外,它的枚举变量也是一样的,你可以在程序中直接使用Some
和None
而不须要添加Option::
前缀。Option<T>
仍然只是一个普通的枚举,Some(T)
和None
也只是Option<T>
类型的变量。
这里的<T>
语法是一个我们还没介绍过的Rust特性,它是一个普通类型参数,我们会在第10章中详细介绍。在本章中,你只需要知道,<T>
代表Option
里的Some
枚举变量可以接受任何类型的数据。下面是使用Option
来存储数字类型和字符串类型的例子:
let some_number = Some(5);
let some_string = Some("a string");
let absent_number: Option<i32> = None;
如果我们使用None
而非Some
,我们须要告诉Rust我们想要什么类型的Option<T>
,因为编译器无法只通过None
值来推断出Some
变量要包含哪种类型的值。
当我们有了一个Some
值,我们就知道存在一个值,并且它放在了Some
里;在一些情况下,我们得到了一个None
值,这和null想表达的意思是一样的:我们没有得到一个有效的值。那为什么我们要说Option<T>
要比null好呢?
简单来说,因为Option<T>
和T
(这里T
代表任何类型)是不同的数据类型,编译器不会允许我们想操作它原来的值一样去操作Option<T>
值。就像下面的例子无法通过编译,因为它尝试将i8
值与Option<i8>
相加:
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
当你尝试运行上面的代码,会提示如下的错误信息:
error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is
not satisfied
-->
|
5 | let sum = x + y;
| ^ no implementation for `i8 + std::option::Option<i8>`
|
错误消息说明了Rust无法理解如何将一个i8
与Option<i8>
相加,因为它们是不同的数据类型。当我们在Rust中获取到一个i8
类型的值,编译器会确认我们始终有一个有效的值,在使用这个值前,我们无须去检查它是否为空。但当我们用到的是Option<i8>
,说明我们担心有可能它不会有一个值,编译前会检查我们在使用它前是否已经做好处理各种情况的准备。
换而言之,我们须要将Option<T>'
转化为T
,才能执行T
相关的操作。自然的,这将帮助我们捕获一类十分普遍的null问题:假设某个变量不为空,但偏偏实际上它是空值。
不须要再担忧错误的假设了一个非空值,可以帮助你建立对自己代码的信心。为了处理一个可能为空的值,你必须明确的将这个值类型定义为Option<T>
。这样,当你在用到这个值的时候,你会被明确要求去处理这个值为空的情况。Rust中任何类型不为Option<T>
的值,你都能够安全的假设,这些值是不为空的。这也是Rust设计时深思熟虑的产物,它限制了空值的影响范围,并提高了Rust代码的安全性。
那当我们有了一个Option<T>
的值,我们又如何去获取并使用Some
枚举变量中的T
类型值呢?其实Option<T>
枚举内置了大量的方法函数来处理种种情况,你可以在这个文档中找到它们。熟悉各种Option<T>
的方法函数,会让你在学习Rust的旅程中受益匪浅。
一般来说,为了使用Option<T>
的值,你须要来为每一个变量编写代码。你想在有Some(T)
值时执行一些代码,这些代码能够访问里面的T
值;你也想在遇到None
值时,去执行另一些代码,这些代码没有一个有用的T
值。大名鼎鼎的match
表达式就是一种可以用来处理枚举的控制流结构:它可以根据不同的枚举变量,执行不同的代码,同时能够使用里面的值。
The match
Control Flow Operator match
控制流操作符
Rust有一个灰常强大的控制流操作符叫做match
,它可以让你将一个值与一组模式去匹配,并且当匹配到不同的模式时去执行不同的代码。匹配模式可以有数字值、变量名、通配符或是其它东西组成。第18章中我们将介绍所有类型的模式和它们的工作原理。强大的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,
}
}
让我们来分解下value_incents
函数中的match
语句。首先我们在match
关键字后跟上了一个表达式,即本例中的coin
,看起来就像if
语句中的表达式一样,但两者有个显著的不同:if
中的表达式须要返回一个波尔值,但match
中的表达式的返回值可以是任意类型。在本例中,coin
的返回值类型即是我们在函数前定义的Coin
枚举。
在match
表达式里,则是match
的分路(arm臂)。每个分路由两部分组成:一个模式和一些代码。第一条分路有一个模式,值是Coin::Penny
,然后通过=>
符号来分隔模式和对应要运行的代码,在这条分路中,只有一个硬代码,值1
。分路间通过逗号互相分隔。
当执行match
表达式时,它会将表达式的返回值与分路中的每一个模式去依次对比。如果返回值与模式匹配,这条分路中的代码就会被执行;如果返回值与模式不匹配,程序就会继续找下一条分路去对比,就像我们的点币机做的那样。你想要多少分路,就可以创建多少分路,例子中的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,
}
}
Patterns that Bind to Values 绑定到值的模式
match 分路另一个有用的功能是它们能够绑定值的片段,如果它能匹配相应的模式,通过这种方式我们能够获取枚举变量中的值。
我们尝试修改下之前的例子,让我们其中一个枚举变量中包含具体的数据。在1999到2008年间,美帝在25美分硬币的背面为50个州设计了不同的图案,而其它的硬币就没有这样的待遇了,所以只有25美分比较特殊。我们可以把这些信息添加到一个enum
中,将Quarter
变量定义为包含一个UsState
值在里面:
#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
}
假设我们的一个哥们儿正尝试手机50个州的25美分硬币,在对零钱进行分类时,我们将提示每个州的名称,如果恰巧这是哥们儿还没有的,他就可以收藏这个硬币了。
在match表达式中,用于匹配Coin::Quarter
的模式中新增了一个变量state
,当匹配到Coin::Quarter
时,state
变量的值将绑定到25美分对应的州。接下来,我们就可以在分路的代码中使用这个state
:
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
},
}
}
当我们尝试value_in_cents(Coin::Quarter(UsState::Alaska))
来调用上面的函数,coin
的值就会是Coin::Quarter(UsState::Alaska)
,我们拿这个值去一一匹配分路,所有的模式都无法匹配,直到执行到Coin::Quarter(state)
。这时,state
会绑定值UsState::Alaska
。接下来我们就可以在println!
表达式中使用这个被绑定的值,因此我们成功将Coin
枚举变量Quarter
中的州的值提取了出来。
Matching with Option<T>
匹配Option<T>
在上一节中,我们想要在返回值是Some
的情况下,获取Option<T>
中的T
类型值,同样,就像我们对Coin
枚举做的那样,我们可以用match
来处理Option<T>
。不同于比对硬币,我们这次比较的是变量Option<T>
,不过match
表达式的工作方式还是一样的。
譬如我们创建了一个函数,可以接受Option<i32
的值,并且当有值传入时,会将它加1;如果没有值传入,函数须要返回一个None
值,然后不做任何操作。
多亏了match
表达式,我们可以轻松的实现这个功能:
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
让我们来看下第一次调用plus_one
函数时发生的时。当我们执行plus_one(five)
时,plus_one
函数体中的变量x
会有一个值Some(5)
,我们拿它与分路匹配:
None => None,
显然,Some(5)
不匹配模式None
,所以我们检索下一条分路:
Some(i) => Some(i + 1),
Some(5)
当然匹配Some(i)
咯,于是i
绑定到了Some
中的值,即i
获取到了值5
,接着便执行分路中的代码,我们往i
的值上加了1并创建了一个新的Some
值,它包含了结果6
。
再看下我们第二次调用plus_one
函数时发生的事,这时x
是None
,我们进入match
表达式的第一条分路,进行匹配:
None => None,
匹配上了!因为没有什么值可加,所以程序终止了,并且返回了=>
右侧的None
值。因为第一条分路已经匹配上,其它分路将不再比较。
match
表达式和枚举的组合,在许多场景中都非常有用。在Rust的代码中,你将频繁看到这种套路:match
后紧跟一个枚举,将变量绑定到枚举变量中具体的值,执行基于这个值的代码。一开始你或许会觉得这种方法太皮了,但当你熟悉后,你就会恨不得所有语言中都有这种方法,毫无疑问,它将始终是用户的最爱。
Matches Are Exhaustive 匹配模式是无遗漏的
对于match
表达式,我们尚有一点须要交代。如果我们将plus_one
函数改为下面的代码,它将会有一个bug并且无法通过编译:
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
-->
|
6 | match x {
| ^ pattern `None` not covered
Rust意识到了我们没有考虑所有可能发生的情况,甚至还知道我们忘记了哪个模式。匹配模式在Rust中是无遗漏的:我们必须列举所有的可能性以确保代码有效。尤其是在用到Option<T>
的场景中,Rust会告诫我们去处理None
的情况,这样我们就避免了想要获取一个值却遇到空值的情况,即我们之前介绍过的十亿美刀错误。
The _
Placeholder _
占位符
当我们不想罗列所有可能的值时,Rust也提供了一个特殊的模式给我们。譬如u8
变量的值可以是0到255之间的整数。如果我们只关心值1,3,5,7的情况,而不想去罗列其它的值时,我们就能用特殊模式_
去替代:
let some_u8_value = 0u8;
match some_u8_value {
1 => println!("one"),
3 => println!("three"),
5 => println!("five"),
7 => println!("seven"),
_ => (),
}
_
模式能够匹配任何值,将它放在所有分路的最后,_
就能匹配所有之前没有说明过的情况。例子中的()
只是一个元组值,所以_
的场景下,什么都不会发生。换句话说,我们希望当遇到非_
前面的分路中罗列的值时,程序什么都不去做。
然而,当我们仅关注唯一的场景时,match
表达式可能就看上去太啰嗦了。面对这种情况,Rust提供了另一种选择,if let
表达式。
Concise Control Flow with if let
通过if let
实现简单的控制流
if let
语法通过将if
和let
结合使用,可以提供一个更加简洁的方式来处理某一类模式匹配而忽略其它的可能。下面的程序可以匹配一个Option<u8>
值,但只希望在值是3的时候执行额外的代码:
let some_u8_value = Some(0u8);
match some_u8_value {
Some(3) => println!("three"),
_ => (),
}
在匹配到Some(3)
时,我们想要搞点事情,但对于None
和其它Some(u8)
值,我们希望不要做任何处理。为了满足match
表达式,我们不得不添加_ => ()
这样一条分路。
不过要是我们通过if let
,就能用更少的代码实现这样的功能。下面的代码,它的功能就和之前的match
完全一样:
if let Some(3) = some_u8_value {
println!("three");
}
if let
语句接收一个模式,并通过一个等于号连接一个表达式,它的工作方式和前面例子中的match
表达式一样。它的模式是第一分路的模式,它的表达式也是第一分路的表达式。
使用if let
意味着更少的按键、更少缩进和更少的固定代码。不过有得有失,同时你也丢掉了match
的无遗漏性。实际开发中,match
和let if
的取舍,主要依赖于在特定的场景下你想要做什么,同时请权衡下如何平衡简洁性和遗漏检查。
其实,你可以把if let
当做match
的语法糖,它只匹配一个模式,并忽略其它的值。
我们也可以在if let
中使用一个else
,else
中的代码块,它的作用与macth
表达式中的_
是等效的。回忆下我们之前定义的Coin
枚举,枚举变量Quarter
总是包含一个UsState
值。如果我们想清点所有非25美分硬币的数量同时又提示25美分硬币的州,我们可以像下面这样修改我们的match
表达式:
let mut count = 0;
match coin {
Coin::Quarter(state) => println!("State quarter from {:?}!", state),
_ => count += 1,
}
或者,我们可以用if let
和else
表达式来实现:
let mut count = 0;
if let Coin::Quarter(state) = coin {
println!("State quarter from {:?}!", state);
} else {
count += 1;
}
如果遇到这样一种情况,你的程序逻辑使用match
来表达显得过于复杂,这时别忘了Rust工具箱中的if let
。
Summary 总结
我们现在介绍了如何使用枚举去创建一个自定义类型,这个自定义类型能够是一组可以枚举的值。我们也介绍了标准库中的Option<T>
类型,它能帮助你使用Rust的类型系统来预防错误。当一个枚举值中包含数据时,你能使用match
和if let
来获取这些值,并依据你的需要,针对不同场景去处理或使用这些值。
你的Rust程序已经能够通过结构和枚举来表达具体的概念,为你的API创建自定义类型确保了类型的安全性:编译器会确保你的函数只会获取到它们想要的值的类型。
为了给你的用户提供一个结构良好、直观且易于使用的API,并且只将那些用户需要的功能暴露出来,我们须要接着学习Rust模块化的知识。