定义一个枚举
Defining an Enum
结构体提供了一种将相关字段和数据,分组在一起的方法,比如有着 width
和 height
两个字段的 Rectangle
,而枚举则提供了一种,表示一个值是一组可能值之一的方法。例如,我们可能想表达,Rectangle
是一组其中还包括 Circle
和 Triangle
等可能形状之一。为此,Rust 允许我们,将这些可能性编码为某个枚举。
我们来看看我们可能想在代码中表达的一种情况,看看为什么在这种情况下,枚举要比结构体更有用、更合适。假设我们需要处理 IP 地址。目前,有两种用于 IP 地址的主要标准:版本四和版本六。由于我们的程序只会遇到这两种可能的 IP 地址,因此我们可以 枚举出 所有可能的变种,这就是枚举名称的由来。
任何 IP 地址都可以是版本 4 或版本 6 的地址,但不能同时是这两种地址。IP 地址的这一属性,使得枚举这种数据结构非常合适,因为某个枚举值,只能是其变体之一。从根本上说,版本 4 和版本 6 地址,都仍是 IP 地址,因此当代码在处理适用于任何类别的 IP 地址的情况时,他们应被视为同一类型。
可以通过定义一个 IpAddrKind
枚举,并列出某个 IP 地址可以是的那些可能种类,即 V4
与 V6
,我们就可以在咱们的代码中,表达这一概念。下面就是这个枚举的那些变种:
#![allow(unused)] fn main() { enum IpAddrKind { V4, V6, } }
IpAddrKind
现在就是一种,我们可以在我们代码的其他地方使用的自定义数据类型。
枚举值
Enum Values
我们可以像下面这样,创建出 IpAddrKind
两个变种的实例:
#![allow(unused)] fn main() { let four = IpAddrKind::V4; let six = IpAddrKind::V6; }
请注意,该枚举的两个变种,都是在其标识符下的命名空间中,而我们要使用双冒号,分隔两者。这很有用,因为现在 IpAddrKind::V4
和 IpAddrKind::V6
两个值,属于同一类型:IpAddrKind
。例如,我们随后就可以定义出一个,取任意 IpAddrKind
的函数:
#![allow(unused)] fn main() { fn route(ip_kind: IpAddrKind) {} }
并且我们可以两个变种,调用这个函数:
#![allow(unused)] fn main() { route(IpAddrKind::V4); route(IpAddrKind::V6); }
使用枚举还有更多优势。请再想想我们的 IP 地址类型,目前我们还没有存储具体 IP 地址 数据 的方法;我们只知道他是什么 类别,kind。鉴于咱们刚刚在第 5 章中,学习了结构体,咱们可能会想使用结构体,来解决这个问题,如下清单 6-1 所示。
enum IpAddrKind { V4, V6, } 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"), }; }
清单 6-1:使用 struct
存储 IP 地址的数据及 IpAddrKind
变种
这里,我们定义了个有着两个字段的结构体 IpAddr
:一个是 IpAddrKind
类型的 kind
字段(我们之前定义的枚举),另一个是 String
类型的 address
字段。咱们有着此结构体的两个实例。第一个实例是 home
,其 kind
字段的值 IpAddrKind::V4
,与其关联的地址数据为 127.0.0.1
。第二个实例是 loopback
。其 kind
字段值为 IpAddrKind
的另一变种 V6
,关联的地址是 ::1
。我们已使用了一个结构体,将 kind
和 address
两个值捆绑在一起,因此现在这个变种,与值相关联了。
不过,只使用一个枚举,来表示这同样的概念,更为简洁:我们可以将数据直接放入各个枚举变种中,而不是将枚举放在结构体中。下面这个 IpAddr
枚举的新定义指出,V4
和 V6
两个变种,都将有着关联的 String
值:
#![allow(unused)] fn main() { enum IpAddr { V4(String), V6(String), } let home = IpAddr::V4(String::from("127.0.0.1")); let loopback = IpAddr::V6(String::from("::1")); }
我们直接将数据,附加到枚举的各个变种,因此不需要一个额外结构体。在这里,我们还可以更容易地了解,枚举工作原理的另一细节:我们定义的每个枚举变种名字,还成为了用来构造枚举实例的一个函数。也就是说,IpAddr::V4()
是个取一个 String
参数,并返回 IpAddr
类型实例的函数调用。定义那个枚举时,我们就自动获得了这个构造函数。
使用枚举而非结构体,还有另一好处:每个变种都可以有不同类型和数量的关联数据。版本四的 IP 地址,总是有范围在 0 到 255 之间的四个数字部分。如果我们打算将 V4
地址,存储为四个 u8
值,但仍想要将 V6
地址,表示为一个 String
值,那么我们就无法使用结构体。枚举则可以轻松处理这种情况:
#![allow(unused)] fn main() { 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 地址并对其进行编码,是如此普遍,以致 标准库就有我们可以使用的定义!我们来看看标准库是如何定义 IpAddr
的:他有着与我们定义和使用的完全同样的枚举和变种,但他将地址数据,以两个不同结构体的形式,嵌入到两个变种中,对于每个变种,表示地址数据的结构体定义都不同:
#![allow(unused)] fn main() { struct Ipv4Addr { // --跳过-- } struct Ipv6Addr { // --跳过-- } enum IpAddr { V4(Ipv4Addr), V6(Ipv6Addr), } }
这段代码说明,咱们可以在枚举变种中,放入任何类别的数据:比如字符串、数字类型或结构体等。咱们甚至还可以包含另一枚举!此外,一些标准库类型,通常也不会比咱们接下来会构造出的类型复杂多少。
请注意,即使标准库包含了 IpAddr
的定义,我们仍然可以创建并使用咱们自己的定义,而不会发生冲突,因为我们还没有将标准库的定义,引入我们的作用域。我们将在第 7 章中,详细讨论将类型引入作用域的问题。
我们再来看看,下面清单 6-2 中枚举的另一示例:这个枚举的变种中,嵌入了多种类型。
#![allow(unused)] fn main() { enum Message { Quit, Move { x: i32, y: i32 }, Write (String), ChangeColor(i32, i32, i32), } }
清单 6-2:每个变种都存储了不同数量和类型值的 Message
枚举
该枚举有着分别嵌入了不同类型的四个变种:
-
Quit
完全没有与其关联的数据; -
Move
有着一些命名的字段,就像结构体那样; -
Write
包含单个String
; -
ChangeColor
包含了三个i32
的值。
定义有着如清单 6-2 中的那些变种的枚举,类似于定义不同种类的结构体定义,只是枚举没有使用 struct
关键字,且全部变种都在 Message
这个类型下编组在一起。下面的结构体,可以保存与前面那些枚举变种,同样的数据:
#![allow(unused)] fn main() { struct QuitMessage; // 单元结构体 struct MoveMessage { x: i32, y: i32, } struct WriteMessage(String); // 元组结构体 struct ChangeColorMessage(i32, i32, i32); // 元组结构体 }
但是,如果我们使用不同结构体(每个结构体都有自己的类型),我们就无法像使用清单 6-2 中定义的 Message
枚举(是个单一类型)那样,轻松定义出取任何一种这些类别消息的函数。
枚举和结构体之间,还有个相似之处:正如我们可以使用 impl
在结构体上定义方法一样,我们也可以在枚举上定义方法。下面是我们可以在 Message
枚举上,定义的一个名为 call
的方法:
#![allow(unused)] fn main() { impl Message { fn call(&self) { // 方法体将定义在这里 } } let m = Message::Write(String::from("hello")); m.call(); }
这个方法的主体,将使用 self
来获取我们在其上调用该方法的值。在本例中,我们创建了一个值为 Message::Write(String::from("hello"))
的变量 m
,当 m.call()
运行时,这就是出现在 call
方法主体中 self
的内容。
我们来看看标准库中,另一个非常常见和有用的枚举:Option
。
Option
枚举及其相对于空值的优势
The Option
Enum and Its Advantages Over Null Values
本节会探讨 Option
的一个案例研究,这是标准库定义的另一枚举。Option
类型编码了一种非常常见的情况,即某个值可能是某物,也可能什么都不是。
例如,如果咱们请求了某个非空列表中的首个项目,咱们将得到某个值。如果咱们请求某个空列表中的第一项,则什么也得不到。用类型系统来表达这一概念,意味着编译器可以检查,咱们是否处理了所有应处理的情况;这一功能可以防止其他编程语言中,极为常见的编程错误。
编程语言的设计,通常会考虑包含哪些功能,但排除哪些功能,也很重要。Rust 没有许多其他语言所具有的空值 null 功能。Null 是个表示没有值的值。在有控制的语言中,变量总是处于两种状态之一:空值或非空值。
空值的发明者 Tony Hoare 于 2009 年的演讲 “空值引用:10 亿美金代价失误(Null Reference: The Billion Dollar Mistake)” 中,就讲了这个问题:
我称他为我的 "十亿美元错误"。当时,我正在为面向对象语言中的引用,设计第一个全面的类型系统。我的目标是确保所有引用的使用,都绝对安全,并由编译器自动进行检查。但是,我无法抵制加入空引用的诱惑,只是因为这太容易实现了。这导致了无数的错误、漏洞和系统崩溃,在过去的四十年里,这些错误和漏洞可能造成了数十亿美元的损失。
空值的问题在于,如果试图将空值用作非空值,就会出现某种错误。由于这种空值或非空值属性普遍存在,因此极易出现这种错误。
然而,空值试图表达的这种概念,仍然是有用的:空值是指由于某种原因,当前无效或不存在的值。
问题其实不在于概念,而在于特定的实现。因此,Rust 没有空值,但有个枚举可以编码值存在或不存在的概念。这个枚举就是 Option<T>
,标准库对其定义 如下:
#![allow(unused)] fn main() { enum Option<T> { None, Some(T), } }
Option<T>
枚举非常有用,以致他甚至被包含在 Rust 前奏中;咱们不需要显式地将他引入作用域。他的变种也包含在前奏中:咱们可以直接使用 Some
和 None
,而无需 Option::
这个前缀。Option<T>
枚举仍然只是个普通的枚举,而 Some(T)
和 None
,也仍然是 Option<T>
类型的变种。
<T>
这种语法,是我们尚未讨论过的一项 Rust 特性。他是个泛型参数,我们将在第 10 章,详细介绍泛型。现在,咱们只需知道 <T>
表示 Option
枚举的 Some
变种,可以容纳任意类型的数据,而每个用来代替 T
的具体类型,都会使整个 Option<T>
类型,成为不同的类型。下面是一些使用 Option
值,保存数字类型和字符串类型的示例:
#![allow(unused)] fn main() { let some_numer = Some(5); let some_char = Some('e'); let absent_number: Option<i32> = None; }
其中 some_number
的类型是 Option<i32>
。some_char
的类型是 Option<char>
,这是一种不同的类型。Rust 可以推断出这些类型,因为我们在 Some
变种中,指定了某个值。对于 absent_number
,Rust 要求我们注解整个 Option
类型:编译器无法仅通过查看 None
值,来推断相应 Some
变种将持有的类型。在这里,我们告诉 Rust,我们的意思是 absent_number
属于 Option<i32>
类型。
当我们有某个 Some
值时,我们知道有个值存在,并且该值被保存在 Some
中。当我们有个 None
值时,从某种意义上说,他的含义与空值相同:我们没有一个有效值。那么,为什么 Option<T>
比空值更好呢?
简而言之,由于 Option<T>
和 T
(T
可以是任何类型)属于不同的类型,编译器不会让我们,将某个 Option<T>
值用作其肯定是个有效值。例如,下面这段代码将不会编译,因为他试图将一个 i8
,与一个 Option<i8>
相加:
#![allow(unused)] fn main() { let x: i8 = 5; let y: Option<i8> = Some(5); let sum = x + y; }
如果我们运行这段代码,我们会收到如下报错:
$ cargo run
Compiling option_demo v0.1.0 (C:\tools\msys64\home\Lenny.Peng\rust-lang-zh_CN\projects\option_demo)
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`
= help: the following other types implement trait `Add<Rhs>`:
<i8 as Add>
<i8 as Add<&i8>>
<&'a i8 as Add<i8>>
<&i8 as Add<&i8>>
For more information about this error, try `rustc --explain E0277`.
error: could not compile `option_demo` (bin "option_demo") due to previous error
强悍如此!实际上,这条报错信息,表示 Rust 不理解如何将某个 i8
与某个 Option<i8>
相加,因为他们属于不同的类型。在 Rust 中,当我们有个 i8
类型的值时,编译器会确保我们,始终有个有效的值。在使用该值之前,我们无需检查是否为空值,就可以放心地继续。只有当我们有个 Option<i8>
(或其他任何类型的值)时,我们才必须担心可能并没有值,编译器会确保我们,在使用该值前处理这种情况。
换句话说,在对 Option<T>
执行 T
的运算之前,我们必须先将其转换为 T
。一般来说,这有助于捕捉空值最常见的问题之一:假设了某个项目不是空值,而实际上他却是空值。
消除错误地假定某个非空值的风险,可以让咱们对咱们代码更有信心。为了获得某个可能为空的值,咱们必须显式地选择,将该值的类型设为 Option<T>
。然后,在使用该值时,咱们必须显式地处理,该值为空的情况。只要值的类型不是 Option<T>
,咱们就可以放心地认为,该值不是空值。这是 Rust 为限制空值泛滥,和提高 Rust 代码的安全性,而特意做出的设计决定。
那么,当咱们有着某个 Option<T>
类型的值时,该怎样从 Some
变种中获取到那个 T
值,以便使用该值呢?Option<T>
这个枚举,有着大量在不同场景下,都有用的方法;咱们可以在 其文档 中,查看这些方法。熟悉 Option<T>
的方法,将对咱们的 Rust 之旅大有裨益。
一般来说,为了使用某个 Option<T>
值,咱们需要编写处理每个变种的代码。咱们会想要一些,仅在咱们有个 Some(T)
值时才会运行的代码,而这些代码,就可以使用内部的 T
值;咱们会想要另一些,只有在咱们有个 None
值时才会运行的代码,而这些代码就没有可用的 T
值。与枚举一起使用的 match
表达式,便是一种正好完成这个目的的控制流结构:他会根据枚举有着哪一个变种,而运行不同的代码,而这些代码,就可以使用匹配值内部的数据。