定义枚举
结构体可以将相关字段和数据组合在一起,例如包含 width 和 height 的 Rectangle;枚举则让你能够表示一个值属于一组可能值中的某一种。例如,我们可以说 Rectangle 是一种形状,而其他可能的形状还包括 Circle 和 Triangle。Rust 允许我们用枚举来编码这些可能性。
来看一种可能需要在代码中表达的情况,并了解为什么此时枚举比结构体更有用、更合适。假设我们需要处理 IP 地址。目前,IP 地址有两种主要标准:IPv4 和 IPv6。由于程序可能遇到的 IP 地址只有这两种,我们可以枚举所有可能的变体,这也是“枚举”一词的由来。
任何 IP 地址要么是 IPv4,要么是 IPv6,但不能同时属于两者。IP 地址的这一特性使枚举成为合适的数据结构,因为枚举值只能是其变体之一。IPv4 和 IPv6 地址本质上仍然都是 IP 地址,因此在处理适用于任何 IP 地址的情况时,应将它们视为同一种类型。
我们可以通过定义 IpAddrKind 枚举来表达这一概念,并列出 IP 地址可能的类型 V4 和 V6。这些就是枚举的变体:
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) {}
现在,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) {}
请注意,枚举的变体位于枚举标识符的命名空间下,我们用双冒号将二者分隔开来。这样很有用,因为现在 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) {}
然后,可以使用任一变体调用这个函数:
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 地址的实际数据,只知道它属于哪种类型。既然你刚在第 5 章学习了结构体,可能会想用结构体来解决这个问题,如示例 6-1 所示。
fn main() {
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"),
};
}
struct 存储 IP 地址的数据和 IpAddrKind 变体这里定义了一个名为 IpAddr 的结构体,它有两个字段:kind 字段的类型是 IpAddrKind(之前定义的枚举),address 字段的类型是 String。我们创建了该结构体的两个实例。第一个是 home,它的 kind 值为 IpAddrKind::V4,关联的地址数据为 127.0.0.1。第二个实例是 loopback,它的 kind 值是 IpAddrKind 的另一个变体 V6,关联的地址为 ::1。我们用结构体将 kind 和 address 值组合在一起,因此现在变体与具体值关联起来了。
不过,只用枚举表示同一概念会更加简洁:我们不必在结构体中嵌套枚举,而可以直接将数据放入每个枚举变体中。下面这个新的 IpAddr 枚举定义表示 V4 和 V6 变体都关联了 String 值:
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 类型实例的函数。定义枚举时,Rust 会自动为我们定义这个构造函数。
与结构体相比,枚举还有另一个优点:每个变体可以关联不同类型和数量的数据。IPv4 地址始终由四个数值部分组成,每个数值都在 0 到 255 之间。如果我们想用四个 u8 值存储 V4 地址,同时用一个 String 值表示 V6 地址,结构体就无法满足需求。枚举可以轻松处理这种情况:
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"));
}
我们已经展示了几种定义数据结构以存储 IPv4 和 IPv6 地址的方式。不过,存储 IP 地址并标明其类型是如此常见,以至于标准库中已经有可供我们使用的定义!来看看标准库如何定义 IpAddr。它包含与我们定义和使用过的相同枚举及变体,但将地址数据嵌入变体中,并分别使用两个不同的结构体表示:
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
这段代码说明,枚举变体中可以包含任何类型的数据,例如字符串、数值类型或结构体,甚至还可以包含另一个枚举!此外,标准库中的类型通常并不比你自己设计的类型复杂多少。
请注意,尽管标准库中定义了 IpAddr,我们仍然可以创建并使用自己的同名定义而不会冲突,因为我们尚未将标准库中的定义引入当前作用域。第 7 章会进一步介绍如何将类型引入作用域。
再来看示例 6-2 中的另一个枚举:它的变体中嵌入了多种不同类型的数据。
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
Message 枚举的各个变体分别存储不同数量和类型的值这个枚举有四个变体,分别代表不同的数据类型:
Quit:不关联任何数据Move:像结构体一样包含具名字段Write:包含一个StringChangeColor:包含三个i32值
定义包含示例 6-2 中这些变体的枚举,类似于定义几种不同的结构体;区别在于枚举不使用 struct 关键字,并且所有变体都归在 Message 这一类型之下。下面这些结构体可以保存与前面枚举变体相同的数据:
struct QuitMessage; // unit struct
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct
fn main() {}
但是,如果使用这些各自拥有独立类型的结构体,就无法像使用示例 6-2 中属于单一类型的 Message 枚举那样,轻松定义一个接收任意一种消息的函数。
枚举和结构体还有一个相似之处:正如我们可以使用 impl 为结构体定义方法,也可以为枚举定义方法。下面是在 Message 枚举上定义的一个名为 call 的方法:
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
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 就是这个值。
接下来看看标准库中另一个非常常见且实用的枚举:Option。
Option 枚举
本节将通过案例介绍 Option,这是标准库定义的另一个枚举。Option 类型表达了一种非常常见的情况:一个值可能存在,也可能不存在。
例如,如果你请求一个非空列表中的第一项,就会得到一个值;如果请求空列表中的第一项,则什么也得不到。用类型系统表达这一概念后,编译器就能检查你是否处理了所有应当处理的情况;这项功能可以防止其他编程语言中极为常见的一类错误。
设计编程语言时,人们通常会考虑加入哪些功能,但排除哪些功能也同样重要。Rust 没有许多其他语言提供的 null 功能。Null 是表示“此处没有值”的值。在支持 null 的语言中,变量通常处于两种状态之一:null 或非 null。
在 2009 年题为“空引用:价值十亿美元的错误”的演讲中,null 的发明者 Tony Hoare 如此说道:
我称之为价值十亿美元的错误。当时,我正在为一种面向对象语言设计首个完整的引用类型系统。我的目标是确保所有引用的使用都绝对安全,并由编译器自动检查。但我没能抵挡住加入空引用的诱惑,因为它实现起来实在太简单了。这导致了无数错误、漏洞和系统崩溃;在过去四十年里,它们造成的痛苦和损失可能高达十亿美元。
Null 值的问题在于,如果你试图把 null 当作非 null 值使用,就会遇到某种错误。由于 null 或非 null 这一属性无处不在,这类错误极易发生。
然而,null 想要表达的概念仍然很有用:一个值可能因为某种原因当前无效或缺失。
问题并不在于这个概念本身,而在于具体实现。因此,Rust 没有 null,但它提供了一个枚举来表示值存在或缺失的概念。这个枚举是 Option<T>,由标准库定义,如下所示:
#![allow(unused)]
fn main() {
enum Option<T> {
None,
Some(T),
}
}
Option<T> 枚举非常实用,因此它被包含在预导入模块中;你不需要显式将它引入作用域。它的变体也包含在预导入模块中:可以直接使用 Some 和 None,而不必加上 Option:: 前缀。Option<T> 仍然是一个普通枚举,Some(T) 和 None 仍然是 Option<T> 类型的变体。
<T> 语法是我们尚未讨论过的 Rust 特性。它是一个泛型类型参数,我们将在第 10 章详细介绍泛型。目前只需知道,<T> 表示 Option 枚举的 Some 变体可以容纳任意类型的一项数据;而每一种用于替代 T 的具体类型,都会使整个 Option<T> 成为不同的类型。下面是使用 Option 值保存数字和字符的示例:
fn main() {
let some_number = Some(5);
let some_char = Some('e');
let absent_number: Option<i32> = None;
}
some_number 的类型是 Option<i32>。some_char 的类型是 Option<char>,这是另一种类型。由于我们在 Some 变体中指定了值,Rust 可以推断这些类型。对于 absent_number,Rust 要求我们标注整个 Option 类型:仅根据 None 值,编译器无法推断对应的 Some 变体会包含什么类型。这里我们告诉 Rust,absent_number 的类型是 Option<i32>。
当我们拥有 Some 值时,就知道值确实存在,并且保存在 Some 中。拥有 None 值时,从某种意义上说,它和 null 表示相同的情况:没有有效值。那么,为什么 Option<T> 比 null 更好呢?
简而言之,因为 Option<T> 和 T(T 可以是任意类型)是不同类型,编译器不会允许我们把 Option<T> 值当成确定有效的值来使用。例如,下面的代码无法编译,因为它试图将 i8 与 Option<i8> 相加:
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
}
运行这段代码时,会得到类似下面的错误消息:
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
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>`
--> /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/ops/arith.rs:98:8
|
= note: `i8` implements `Add`
::: /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/ops/arith.rs:113:0
|
= note: in this macro invocation
--> /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/internal_macros.rs:22:8
|
= note: `&i8` implements `Add<i8>`
::: /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/internal_macros.rs:33:8
|
= note: `i8` implements `Add<&i8>`
::: /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/internal_macros.rs:44:8
|
= note: `&i8` implements `Add`
= note: this error originates in the macro `add_impl` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error
信息量很大!实际上,这条错误消息表示 Rust 不知道如何将 i8 与 Option<i8> 相加,因为它们是不同的类型。在 Rust 中,如果我们拥有 i8 这类类型的值,编译器会确保它始终是有效值。使用该值前无需检查它是否为 null,我们可以放心地继续操作。只有当值的类型是 Option<i8>(或我们正在处理的其他 Option 类型)时,才需要考虑它可能不存在;编译器会确保我们在使用该值之前处理这种情况。
换句话说,必须先将 Option<T> 转换为 T,才能对它执行 T 类型的操作。一般来说,这有助于捕获 null 最常见的问题之一:误以为某个值不是 null,而实际上它是 null。
消除错误地假设值非 null 的风险,能让你对代码更有信心。若一个值可能为 null,就必须显式选择将其类型设为 Option<T>。随后使用该值时,你必须明确处理它为 null 的情况。凡是类型不是 Option<T> 的值,你都可以安全地假定它不是 null。这是 Rust 有意作出的设计决定,目的是限制 null 的泛滥并提高 Rust 代码的安全性。
那么,当你拥有一个 Option<T> 类型的值时,如何从 Some 变体中取出 T 值并使用它呢?Option<T> 枚举提供了大量适用于各种情形的方法,你可以在它的文档中查看。熟悉 Option<T> 的方法会对你的 Rust 学习之旅非常有帮助。
一般来说,要使用 Option<T> 值,就需要编写能够处理每个变体的代码。对于 Some(T) 值,需要运行一段代码,并允许它使用内部的 T;对于 None 值,则运行另一段代码,而这段代码没有可用的 T 值。将 match 表达式用于枚举时,它正是一种实现此目的的控制流结构:它会根据枚举值所属的变体运行不同代码,并允许这些代码使用匹配值中的数据。