16 KiB
定义一个枚举
枚举是不同于结构体的第二种定义定制数据类型的方式。下面就来看看一种在代码中可能表达的情形,并见识一下为何在此情形下,相比于结构体,枚举是有用且更恰当的。假设说这里需要对 IP 地址进行处理。目前仅有两种用于 IP 地址的标准:版本四和版本六。由于这两个标准是程序将遇到的 IP 地址仅有的可能性,因此就可以 列举出(enumerate) 全部可能的变种,这正是枚举(enumeration) 名字得来之处。
任何 IP 地址都只能是版本四或版本六的地址,而不会同时两个都是。由于枚举值只能是枚举变种之一,那么 IP 地址的这个属性,令到枚举数据结构(the enum data structure)恰当起来。而版本四和版本六两种地址,从根本上说都是 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 地址 数据(data) 的时候,就要进一步思考一下这里的 IP 地址类型;这是只知道 IP 地址数据为什么 类别(king)。根据在第 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
,而他有着与地址数据 127.0.0.1
关联的 IpAddrKind::V4
作为其 kind
的值。第二个实例为 loopback
。这个实例则有不同的 IpAddrKind
变种作为其 kind
的值,即 V6
,与 kind
关联的是地址 ::1
。由于这里使用了结构体将 kind
与 address
值捆绑在一起,因此现在这个 IpAddrKind
的变种就与那个 String
值关联起来了。
不过,仅使用一个枚举来表示这同一概念,就会更加简练:与其将枚举放在结构体内部,可将数据直接放在各个枚举变种里头。那么这新的 IpAddr
枚举定义,就是说 V4
与 V6
两个变种,将同时有着关联的 String
值:
enum IpAddr {
V4(String),
V6(String),
}
fn main() {
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
值时,那就没法用结构体了,而枚举则能轻易处理这样的情况:
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
fn main() {
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
}
到这里,就已经给出了好几种定义用于存储版本四和版本六 IP 地址的数据结构了。然而事实表明,想要存储 IP 地址,及对这些 IP 地址所属类别进行编码是如此普遍,以致 标准库就有一个可加以使用的定义!下面就来看看,标准库是怎样定义 IpAddr
的:他有着与这里曾定义和使用过的相同枚举和变种,不过标准库是将地址数据,以两个不同结构体的形式,嵌入到变种里的,对两个枚举变种,定义了不同的结构体。
struct Ipv4Addr {
// --跳过--
}
struct Ipv4Addr {
// --跳过--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
这段代码说明可将任何类别的数据放在枚举变种里面:比如字符串、数字类型,或结构体等等。甚至可以包含另一枚举!还说明了,标准库类型,通常也并不比咱们自己编写的代码复杂多少。
请注意,由于这里不曾将标准库的 IpAddr
定义带入到这里的作用域,因此即使标准库包含了一个 IpAddr
的定义,这里也仍然可以毫无冲突地创建与使用自己的 IpAddr
定义。在第 7 章就会讲到有关带入类型到作用域的问题。
来看看下面清单 6-2 中另一个枚举的示例:这个枚举有着嵌入到其各个变种中的种类繁多的类型。
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
类型下组织在了一起。下面这些结构体,就可保存之前各个枚举变种所保存的那些同样数据:
struct QuitMessage; // 单元结构体
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // 元组结构体
struct ChangeColorMessage(i32, i32, i32); // 元组结构体
不过假如这里使用了不同的、有着各自类型的结构体,那么就无法轻易地定义出一个接收原本在清单 6-2 中定义的、单一类型的 Message
枚举那样的,接收全部这些类别消息的函数了。
枚举与结构体之间,还有另外一个相似点:正如在结构体上使用 impl
关键字定义出一些方法,在枚举上定义方法也是可以的。下面就是一个可定义在这里的 Message
枚举上、名为 call
的方法:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write (String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&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
及其超越空值的诸多优点
The Option
Enum and Its Advantages Over Null Values
本小节会探讨 Option
的案例研究,Option
是由标准库定义的另一个枚举。Option
类型编码了某个值可能会是某个物件,或可能什么都不属于的这种甚为常见的场景(the Option
type encodes the very common scenario in which a value could be something or it could be nothing)。比如在请求某个含有一些项目的清单的第一个项目时,就会得到一个值。而在请求某个空清单的第一个项目时,则会什么也得不到。以类型系统字眼,来表达这个概念,就表示编译器能够对,是否已处理了本应处理的全部情形,进行检查;此项功能可阻止那些在其他编程语言中极为常见的代码错误。
编程语言的设计,通常要考量包含哪些特性,而要排除哪些特性也至关重要。Rust 没有许多其他语言都有的空值特性。空值(Null) 是一个表示此处无值的值。在带有 null
的那些语言中,变量总是会处于下面两个状态之一:空值或非空值。
在 null
的发明人Tony Hoare 于 2009 年的演讲 “空值引用:10 亿美金代价失误(Null Reference: The Billion Dollar Mistake)” 中,就讲了这个问题:
我把他叫做我的 10 亿美元失误。那个时候我正在设计某门面向对象语言中的首个综合类型系统。目的是要在编译器自动执行的检查之下,确保全部引用使用,都应绝对安全。仅仅因为空值引用变量实现起来很容易,我当时就没能顶住诱惑,把他加入到特性集了。这个举动,业已造成了数不胜数的代码错误、漏洞及系统崩溃等等问题,在过去 40 余年里,这些问题可能已经造成大概 10 亿美金的痛苦和伤害。
null
值的问题在于,当尝试将 null
值用作非 null
值时,就会得到某种错误。由于这种 null
或非 null
的属性遍布各处,因此极容易犯下此类错误。
但 null
试图表达的概念,还是有用的:null
是个因某些原因,而当前为无效或空缺的值。
问题不是真的在于这个概念,而在于针对性的实现。由于这些原因,Rust 就没有空值,但他确实有一个可对值存在或空缺这个概念,进行编码的枚举。这个枚举就是 Option<T>
,而这个枚举 由标准库定义 为下面这样:
enum Option<T> {
None,
Some(T),
}
这个 Option<T>
是如此重要,以至于在 Rust 序曲(the prelude)中甚至都包含了;是不需要显式地将其带入到作用域的(注:原生类型、这里的 Option<T>
,以及前面的 String
类型等等,就是这样的包含在序曲中的类型,无需显式地带入到作用域,就可以直接使用)。该枚举的变种也已包含在 Rust 序曲中:可直接在不带前缀 Option::
的情况下直接使用 Some
与 None
(注:那么 Some
与 None
就被列为了 Rust 关键字了)。Option<T>
仍然只是常规枚举,而 Some<T>
与 None
仍然是类型 Option<T>
的变种。
这里的 <T>
语法,是个到目前为止还未讲到的 Rust 特性。他是个泛型参数,而在第 10 章将更详细的涉及到泛型。至于现在,只需明白 <T>
表示 Option
枚举的 Some
变种,可保存任意类型的一条数据,而在 T
位置处用到的各个具体类型,会让整个 Option<T>
类型成为各异的类型(for now, all you need to know is that <T>
means the Some
variant of the Option
enum can hold one piece of data of any type, and that each concrete type that gets used in place of T
makes the overall Option<T>
type a different type)。以下是使用 Option
来保存数字与字符串类型的一些示例:
let some_numer = Some(5);
let some_string = Some("一个字符串");
let absent_number: Option<i32> = None;
some_number
的类型为 Option<i32>
。some_string
的类型为 Option<&str>
,是个不同的类型。由于这里已在 Some
变种里面指定了值,因此 Rust 可推导出这些类型来。而对于 absent_number
,Rust 就要求注释整个 Option
类型:编译器无法通过仅查看一个 None
值,而推导出相应的 Some
变种的类型来。这里告诉了 Rust,这里计划的是 absent_number
为类型 Option<i32>
。
在有着一个 Some
值时,就知道存在着一个值,且该值是保存在 Some
内部的。而在有个 None
值时,某种意义上讲,这表示了与空值同样的情况:没有一个有效值。那么究竟为什么有着 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;
在运行这段代码时,就会得到下面这样的错误消息:
$ cargo run ✔
Compiling enum_demo v0.1.0 (/home/peng/rust-lang/projects/enum_demo)
error[E0277]: cannot add `Option<i8>` to `i8`
--> src/main.rs:24:17
|
24 | 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 `enum_demo` due to previous error
强悍!事实上,这个错误消息表示,由于 i8
与 Option<i8>
属于不同类型,因此 Rust 不知道怎样将一个 i8
值与一个 Option<i8>
值相加。当在 Rust 中有着一个类型好比 i8
这样的值时,编译器就会保证始终有个有效值。在对那个值进行使用前,可不必检查他是不是 null
,而可放心地加以处理。仅当有个 Option<i8>
类型(或其他任何正在使用的Option<T>
枚举类型值)的变量时,才真地必须关心可能并无值,同时编译器将确保在使用该值前,显式地处理无值的情况。
也就是说,在对 Option<T
执行类型参数 T
的那些操作前,必须将 Option<T>
类型转换为 T
类型。通常,这样做有助于捕获到 null
最常见问题之一:在某个东西实际上是 null
时,错误地将其设想为了他不是 null
。
这种消除了不正确的假定某个非 null
值的做法,有助于增强代码自信。为了使用一个可能为 null
的值,就必须显式地通过将那个值的构造为 Option<T>
,来带入这个值。在某个类型不为 Option<T>
值出现的每个地方,就都可以假定该值不是 null
。这是 Rust 有意的设计决定,用以限制 null
的无处不在,及提升 Rust 代码的安全性。
那么在有一个类型为 Option<T>
值的时候,该怎么从 Some
变种获取到 T
这个值,从而就可以用上那个值呢?枚举 Option<T>
有着大量的、在不同情形下有用的方法;在 Option<T>
文档 便可查看到这些方法。熟悉 Option<T>
上的这些方法,将对 Rust 编程生涯极为有用。
总的来说,为了使用某个 Option<T>
值,就要有将会处理各个变种的代码。要有一些只会在有着一个 Some<T>
的值时运行的代码,而此情况下就会允许这代码使用那个内部的 T
类型变量。在有着 None
值时,则还要有别的代码来允许了,而这代码就没有可用的 T
类型值了。在与枚举一起使用的时候,match
表达式正是实现此特性的控制流结构:match
表达式将依据枚举有着哪些变种,而运行相应的不同代码,以及哪些代码可使用匹配值内部的数据。