枚举类型,通常也被简称为枚举,它允许我们列举所有可能的值来定义一个类型。在本篇文章中,我们首先会定义并使用一个枚举,以向你展示枚举是如何连同数据来一起编码信息的。
接着,我们会讨论一个特别有用的枚举:Option,它常常被用来描述某些可能不存在的值。随后,我们将学会如何在 match 表达式中使用模式匹配,并根据不同的枚举值来执行不同的代码。
最后,我们还会介绍另外一种常用的结构 if let,它可以在某些场景下简化我们处理枚举的代码。你可以找到许多拥有枚举特性的语言,但它们提供的具体功能却不尽相同。
如果一定要比较的话,Rust中的枚举更类似于F#、OCaml和Haskell这类函数式编程语言中的代数数据类型(algebraic data type)。
现在,让我们来尝试处理一个实际的编码问题,并接着讨论在这种情形下,为什么使用枚举要比结构体更加合适。
假设我们需要对IP地址进行处理,那么目前有两种被广泛使用的IP地址标准:IPv4和IPv6。因为我们只需要处理这两种情形,所以可以将所有可能的值枚举出来,这也正是枚举名字的由来。
另外,一个IP地址要么是IPv4的,要么是IPv6的,没有办法同时满足两种标准。这个特性使得IP地址非常适合使用枚举结构来进行描述,因为枚举的值也只能是变体中的一个成员。
无论是IPv4还是IPv6,它们都属于基础的IP地址协议,所以当我们需要在代码中处理IP地址时,应该将它们视作同一种类型。我们可以通过定义枚举IpAddrKind来表达这样的概念,声明该枚举需要列举出所有可能的IP地址种类—V4和V6,这也就是所谓的枚举变体(variant):
enum IpAddrKind {
V4,
V6,
}
现在,IpAddrKind 就是一个可以在代码中随处使用的自定义数据类型了。
我们可以像下面的代码一样分别使用 IpAddrKind 中的两个变体来创建实例:
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
需要注意的是,枚举的变体全都位于其标识符的命名空间中,并使用两个冒号来将标识符和变体分隔开来。由于 IpAddrKind::V4 和 IpAddrKind::V6 拥有相同的类型 IpAddrKind,所以我们可以定义一个接收 IpAddrKind 类型参数的函数来统一处理它们:
fn route(ip_type: IpAddrKind) { }
现在,我们可以使用任意一个变体来调用这个函数了:
route(IpAddrKind::V4);
route(IpAddrKind::V6);
除此之外,使用枚举还有很多优势。让我们继续考察这个IP地址类型,到目前为止,我们只能知道IP地址的种类,却还没有办法去存储实际的IP地址数据。考虑到我们刚刚学习了结构体,所以你也许会像示例6-1所示的那样去解决这个问题。
// 示例6-1:使用struct来存储IP地址的数据和IpAddrKind变体
❶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❷:一个IpAddrKind类型(也就是我们之前定义的枚举❶)的字段kind❸,以及一个String类型的字段address❹。另外,我们还分别创建了两个不同的结构体实例。
第一个实例,home❺,使用了IpAddrKind::V4作为字段kind的值,并存储了关联的地址数据127.0.0.1。第二个实例,loopback❻,存储了IpAddrKind的另外一个变体V6作为kind的值,并存储了关联的地址::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"));
我们直接将数据附加到了枚举的每个变体中,这样便不需要额外地使用结构体。另外一个使用枚举代替结构体的优势在于:每个变体可以拥有不同类型和数量的关联数据。
还是以IP地址为例,IPv4地址总是由4个0~255之间的整数部分组成。假如我们希望使用4个u8值来代表V4地址,并依然使用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"));
目前,我们已经为存储IPv4地址及IPv6地址的数据结构给出了好几种不同的方案。但实际上,由于存储和编码IP地址的工作实在太常见了,因此标准库为我们内置了一套可以开箱即用的定义!
让我们来看一看标准库是如何设计IpAddr的。它采用了和我们自定义一样的枚举和变体定义,但将两个变体中的地址数据各自组装到了两个独立的结构体中:
struct Ipv4Addr {
// --略--
}
struct Ipv6Addr {
// --略--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
在这段代码中,你可以在枚举的变体中嵌入任意类型的数据,无论是字符串、数值,还是结构体,甚至可以嵌入另外一个枚举!
另外,标准库中的类型通常不会比我们设想的实现要复杂多少。需要注意的是,虽然标准库中包含了一份IpAddr的定义,但由于我们没有把它引入当前的作用域,所以可以无冲突地继续创建和使用自己定义的版本。
我们会在后面深入讨论作用域引入。继续来看示例6-2中另外一个关于枚举的例子,它的变体中内嵌了各式各样的数据类型。
// 示例6-2:枚举Message的变体拥有不同数量和类型的内嵌数据
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
这个枚举拥有4个内嵌了不同类型数据的变体:
⭐ Quit没有任何关联数据。
⭐ Move包含了一个匿名结构体。
⭐ Write包含了一个String。
⭐ ChangeColor包含了3个i32值。
定义示例6-2中的枚举有些类似于定义多个不同类型的结构体。但枚举除了不会使用struct关键字,还将变体们组合到了同一个Message类型中。下面代码中的结构体可以存储与这些变体完全一样的数据:
两种实现方式之间的差别在于,假如我们使用了不同的结构体,那么每个结构体都会拥有自己的类型,我们无法轻易定义一个能够统一处理这些类型数据的函数,而我们定义在示例6-2中的Message枚举则不同,因为它是单独的一个类型。
枚举和结构体还有一点相似的地方在于:正如我们可以使用impl关键字定义结构体的方法一样,我们同样可以定义枚举的方法。下面的代码在Message枚举中实现了一个名为call的方法:
impl Message {
fn call(&self) {
❶ // 方法体可以在这里定义
}
}
❷let m = Message::Write(String::from("hello"));
m.call();
方法定义中的代码同样可以使用self来获得调用此方法的实例。在这个例子中,我们创建了一个变量 m❷,并为其赋予了值Message::Write(String::from("hello")),而该值也就是执行m.call()指令时传入call方法❶的self。
让我们再来看一看标准库中提供的另外一个非常常见且实用的枚举:Option。
在前文中,我们看到了IpAddr枚举是如何利用Rust的类型系统来将更多的信息,而不仅仅是数据,编码到程序中去的。而本节则会针对性地研究一个定义于标准库中的枚举:Option。
由于这里的Option类型描述了一种值可能不存在的情形,所以它被非常广泛地应用在各种地方。将这一概念使用类型系统描述出来意味着,编译器可以自动检查我们是否妥善地处理了所有应该被处理的情况。
使用这一功能可以避免某些在其他语言中极其常见的错误。在设计编程语言时往往会规划出各式各样的功能,但思考应当避免设计哪些功能也是一门非常重要的功课。Rust并没有像许多其他语言一样支持空值。空值(Null)本身是一个值,但它的含义却是没有值。
在设计有空值的语言中,一个变量往往处于这两种状态:空值或非空值。Tony Hoare,空值的发明者,曾经在2009年的一次演讲Null References: The Billion Dollar Mistake中提到:
这是一个价值数十亿美金的错误设计。当时,我正在为一门面向对象语言中的引用设计一套全面的类型系统。我的目标是,通过编译器自动检查来确保所有关于引用的操作都是百分之百安全的。但是我却没有抵挡住引入一个空引用概念的诱惑,仅仅是因为这样会比较容易去实现这套系统。这导致了无数的错误、漏洞和系统崩溃,并在之后的40多年中造成了价值数10亿美金的损失。
空值的问题在于,当你尝试像使用非空值那样使用空值时,就会触发某种程度上的错误。因为空或非空的属性被广泛散布在程序中,所以你很难避免引起类似的问题。
但是不管怎么说,空值本身所尝试表达的概念仍然是有意义的:它代表了因为某种原因而变为无效或缺失的值。引发这些问题的关键并不是概念本身,而是那些具体的实现措施。
因此,Rust中虽然没有空值,但却提供了一个拥有类似概念的枚举,我们可以用它来标识一个值无效或缺失。这个枚举就是Option
enum Option {
Some(T),
None,
}
由于Option
另外,它的变体也是这样的:我们可以在不加Option::前缀的情况下直接使用Some或None。但Option
这里的语法
let some_number = Some(5);
let some_string = Some("a string");
let absent_number: Option = None;
假如我们使用了None而不是Some变体来进行赋值,那么我们需要明确地告知Rust这个Option
当我们有了一个Some值时,我们就可以确定值是存在的,并且被Some所持有。而当我们有了一个None值时,我们就知道当前并不存在一个有效的值。这看上去与空值没有什么差别,那为什么Option
简单来讲,因为Option
let x: i8 = 5;
let y: Option = Some(5);
let sum = x + y;
运行这段代码,我们可以看到类似下面的错误提示信息:
error[E0277]: the trait bound `i8: std::ops::Add>` is
not satisfied
-->
|
5 | let sum = x + y;
| ^ no implementation for `i8 + std::option::Option`
|
哇!这段错误提示信息实际上指出了Rust无法理解i8和Option
而只有当我们持有的类型是Option
换句话说,为了使用Option
在编写代码的过程中,不必再去考虑一个值是否为空可以极大地增强我们对自己代码的信心。为了持有一个可能为空的值,我们总是需要将它显式地放入对应类型的Option
当我们随后使用这个值的时候,也必须显式地处理它可能为空的情况。无论在什么地方,只要一个值的类型不是Option
这是Rust为了限制空值泛滥以增加Rust代码安全性而做出的一个有意为之的设计决策。那么,当你持有了一个Option
Option
总的来说,为了使用一个Option
而另外一些代码则只会在持有None值时运行,这些代码将没有可用的T值。match表达式就是这么一个可以用来处理枚举的控制流结构:它允许我们基于枚举拥有的变体来决定运行的代码分支,并允许代码通过匹配值来获取变体内的数据。