The Rust programming language 读书笔记——枚举类型

枚举类型(enum),通常也被简称为枚举,它允许我们列举所有可能的值来定义一个类型。
枚举搭配 match 表达式使用模式匹配,可以根据不同的枚举值来执行不同的代码。
Rust 中的枚举更类似于 Haskell 这类函数式编程语言中的代数数据类型(ADT)

定义枚举

假设我们需要对 IP 地址进行处理。目前只有两种广泛被使用的 IP 地址标准:IPv4 和 IPv6。
我们只需要处理这两种情形,且一个地址要么是 IPv4,要么是 IPv6,因此可以使用枚举将所有可能的值(IPv4 和 IPv6)列举出来,作为一种新的数据类型。

1
2
3
4
enum IpAddrKind {
V4,
V6,
}

现在,IpAddrKind 就是一个可以在代码中随处使用的自定义数据类型了。

枚举值

可以参照下面的代码使用 IpAddrKind 中的两个变体(V4V6)创建实例:

1
2
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;

由于 IpAddrKind:V4IpAddrKind:V6 拥有相同的类型(都是 IpAddrKind),我们可以定义一个接收 IpAddrKind 类型参数的函数来统一处理它们:

1
fn route(ip_type: IpAddrKind) { }

现在,我们可以使用任意一个变体来调用这个函数了:

1
2
route(IpAddrKind::V4);
route(IpAddrKind::V6);

当前定义的枚举类型 IpAddrKind,还只能区分 IP 地址的种类,没有办法去存储实际的 IP 地址数据。
可以使用结构体来解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 枚举,V4V6 两个变体都被关联上了一个 String 值:

1
2
3
4
5
6
7
8
enum IpAddr {
V4(String),
V6(String),
}

let home = IpAddr::V4(String::from("127.0.0.1"));

let loopback = IpAddr::V6(String::from("::1"));

我们直接将数据附加到枚举的每个变体中,就不需要额外地使用结构体了。

另外一个枚举替代结构体的优势在于,每个变体可以拥有不同类型和数量的关联数据,同时所有变体仍属于同一个枚举类型

1
2
3
4
5
6
7
8
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);

let loopback = IpAddr::V6(String::from("::1"));

参考下面代码中定义的一个 Message 枚举:

1
2
3
4
5
6
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}

该枚举拥有 4 个内嵌了不同类型数据的变体:

  • Quit 没有关联任何数据
  • Move 包含了一个匿名结构体
  • Write 包含了一个 String
  • ChangeColor 包含了 3 个 i32 值

枚举有些类似于定义多个不同类型的结构体。但枚举除了不会使用 struct 关键字,还将变体们组合到了同一个 Message 类型中。
下面代码中的结构体可以存储与这些变体完全一样的数据:

1
2
3
4
5
6
7
struct QuitMessage; // 空结构体
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // 元组结构体
struct ChangeColorMessage(i32, i32, i32); // 元组结构体

两种实现方式的差别在于,如果使用了不同的结构体,则每个结构体都会拥有自己的类型,无法轻易定义一个统一处理这些类型的函数。而前面的 Message 枚举是单独的一个类型

正如我们可以用 impl 关键字定义结构体的方法一样,我们同样可以为 Message 定义自己的方法:

1
2
3
4
5
6
7
8
9
10
impl Message {
fn call(&self) {
// 方法在这里定义
}
}

fn main() {
let m = Message::Write(String::from("hello"));
m.call();
}

Option 枚举及空值处理

Option 是一种定义于标准库中的枚举类型,它描述了一种值可能不存在的情形。借助类型系统,编译器可以自动检查我们是否妥善地处理了所有应该被处理的情况。

Rust 没有像其他语言一样支持空值(Null)。空值本身是一个值,但它的含义却是没有值。
空值的问题在于,当你尝试像使用非空值那样使用空值时,就会触发某种程度上的错误。由于空或非空的属性广泛散布在程序中,因此很难避免引起此类问题。
但空值本身所尝试表达的概念仍是有意义的,它代表了因为某种原因而变得无效或缺失的值。

Rust 中虽然没有空值,但提供了一个拥有类似概念的枚举 Option<T>,它可以用来标识一个值无效或缺失。
Option<T> 在标准库中的定义如下:

1
2
3
4
enum Option<T> {
Some(T),
None,
}

Option<T> 是一个普通的枚举类型,Some<T>None 是该类型的变体。

1
2
3
4
let some_number = Some(5);
let some_string = Some("a string");

let absent_number: Option<i32> = None;

若使用 None 而不是 Some 变体来进行赋值,则需要明确声明这个 Option<T> 的具体类型,否则编译器无法进行类型推导。

当我们有了一个 Some 值时,就可以确定值是存在的,并且被 Some 所持有;当我们有了一个 None 值时,就知道当前并不存在一个有效的值。
Option<T> 的设计相对于空值的优势在于,Option<T>T 是不同的类型,编译器不会允许我们像使用普通值一样直接去使用 Option<T> 的值。如:

1
2
3
4
let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

运行上述代码会导致编译器报错,因为 i8Option<i8> 是不同的类型。
当我们持有的类型是 i8 时,编译器可以确保该值是有效的。但是当我们持有的类型是 Option<i8> 时,我们必须要考虑值不存在的情况,编译器会迫使我们在使用值之前正确地做出处理操作

为了持有一个可能为空的值,我们总是需要将其显式地放入对应类型的 Option<T> 值当中。当我们随后使用这个值时,也必须显式地处理它可能为空的情况
即在处理 Option<T> 时,必须编写应对每个变体的代码。某些代码只会在持有 Some(T) 值时运行,它们可以使用变体中存储的 T;另外一些代码则只会在持有 None 值时运行,这些代码没有可用的 T 值。

match 表达式就是一种可以用来处理 Option<T> 这类枚举的控制流结构。它允许我们基于枚举拥有的变体来决定运行的代码分支,并允许代码通过模式匹配来获取变体内的数据。

控制流运算符 match

match 是 Rust 中一个强大的控制流运算符,它允许将一个值与一系列模式相比较,并根据匹配的模式执行相应的代码。这些模式可以由字面量、变量名、通配符及许多其他东西组成。

下面的代码会接收一个美国的硬币作为输入,确定硬币的类型并返回其分值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}

fn value_in_cents(coin: Coin) -> u32 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}

fn main() {
let coin = Coin::Dime;
println!("{}", value_in_cents(coin));
}

每个 match 分支所关联的代码同时也是一个表达式,这个表达式运行的结果同时也会作为整个 match 表达式的结果返回。

绑定值的模式

匹配分支还可以绑定匹配对象的部分值,这使得我们能够从枚举变体中提取特定的值。

比如美国的 25 美分硬币 50 个州采用了不同的设计。现在将这些信息添加至枚举中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#[derive(Debug)] // 方便打印输出默认不支持打印的类型
enum UsState {
Alabama,
Alaska,
// ...
}

enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u32 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("State quarter from {:?}.", state);
25
}
}
}

fn main() {
let alaska = UsState::Alaska;
let coin = Coin::Quarter(alaska);
value_in_cents(coin);
// => State quarter from Alaska.
}

上面的代码中,我们在模式中加入了一个名为 state 的变量用于匹配变体 Coin::Quarter 中的值。当匹配到 Coin::Quarter 时,变量 state 就会绑定到 25 美分所包含的值上。
比如代码中 Coin::Quarter(UsState::Alaska) 作为 coin 的值传入 value_in_cents 函数,最终值 UsState::Alaska 被绑定到变量 state 上。

匹配 Option

可以使用 match 表达式来处理 Option<T>,从 Some 中取出内部的 T 值。
比如编写一个接收 Option<i32> 的函数,若其中有值存在,则将这个值加 1;若其中不存在值,则直接返回 None

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => {
println!("The result is None");
None
}
Some(i) => {
println!("The result is {}", i + 1);
Some(i + 1)
}
}
}

fn main() {
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}

需要注意的是,匹配必须穷举所有的可能。尤其是 Option<T> 这个例子中,Rust 会强迫我们明确地处理值为 None 的情形。

简单控制流 if let

if let 能让我们通过一种不那么繁琐的语法结合使用 iflet,处理那些只关心某一种匹配而忽略其他匹配的情况。
下面的代码会匹配一个 Option<u32> 的值,并只在值为 3 时执行代码:

1
2
3
4
5
6
7
fn main() {
let some_number = Some(3);
match some_number {
Some(3) => println!("three"),
_ => (),
}
}

为了满足 match 表达式穷尽性的要求,我们不得不在处理完 Some(3) 变体后额外加上一句 _ => ()
可以使用 if let 以一种更简单的方式实现上述代码:

1
2
3
if let Some(3) = some_number {
println!("three");
}

还可以在 if let 中搭配使用 else

1
2
3
4
5
6
7
8
fn main() {
let some_number = Some(8);
if let Some(3) = some_number {
println!("three");
} else {
println!("other number");
}
}

参考资料

The Rust Programming Language