第10章 枚举和模式
# 第10章 枚举和模式
令人惊讶的是,很多计算机相关概念,若视作对和类型(sum types)的严重缺失(可对比对lambda表达式的缺失)来理解,就说得通了。 ——格雷登·霍尔(Graydon Hoare)
本章的第一个主题很强大,由来已久,能让你高效完成许多任务(当然要付出一点代价),在不同文化中有许多不同的称呼。但它并非恶魔,而是一种用户自定义数据类型。在ML和Haskell程序员中,它早就被称为和类型(sum types)、带标记联合(discriminated unions)或代数数据类型(algebraic data types)。在Rust中,它们被称为枚举(enumerations),简称enum
。与恶魔不同,它们非常安全,而且所需的代价也不算大。
C++和C#中有枚举;你可以用它们定义自己的类型,其值是一组命名常量。例如,你可能定义一个名为Color
的类型,其值有Red
(红色)、Orange
(橙色)、Yellow
(黄色)等等。这种枚举在Rust中同样可用。不过,Rust对枚举的运用更为深入。Rust中的枚举还可以包含数据,甚至是不同类型的数据。例如,Rust的Result<String, io::Error>
类型就是一个枚举;这样的值要么是包含String
的Ok
值,要么是包含io::Error
的Err
值。这超出了C++和C#中枚举的能力范围,它更像是C语言中的联合(union)——但与联合不同,Rust中的枚举是类型安全的。
只要一个值可能是多种情况中的某一种,枚举就很有用。使用枚举的 “代价” 是,你必须使用模式匹配(这是本章后半部分的主题)来安全地访问数据。
如果你使用过Python中的解包(unpacking)或JavaScript中的解构(destructuring),那么对模式(Patterns)可能会有些熟悉,但Rust对模式的运用更深入。Rust模式有点像用于处理所有数据的正则表达式,用于测试一个值是否具有特定的期望结构。它们可以一次性从结构体或元组中提取多个字段并赋值给局部变量。和正则表达式一样,它们很简洁,通常只用一行代码就能完成所有操作 。
本章从枚举的基础知识开始,展示如何将数据与枚举变体关联,以及枚举在内存中的存储方式。然后我们将展示Rust的模式和match
语句如何基于枚举、结构体、数组和切片简洁地指定逻辑。模式还可以包含引用、移动操作和if
条件,这让它们的功能更强大。
# 枚举
简单的C风格枚举很直接:
enum Ordering {
Less,
Equal,
Greater,
}
2
3
4
5
这声明了一个名为Ordering
的类型,它有三个可能的值,称为变体(variants)或构造器(constructors):Ordering::Less
、Ordering::Equal
和Ordering::Greater
。这个特定的枚举是标准库的一部分,所以Rust代码可以这样导入它:
use std::cmp::Ordering;
fn compare(n: i32, m: i32) -> Ordering {
if n < m {
Ordering::Less
} else if n > m {
Ordering::Greater
} else {
Ordering::Equal
}
}
2
3
4
5
6
7
8
9
10
11
或者导入它的所有构造器:
use std::cmp::Ordering::{self, *}; // `*` 用于导入所有子项
fn compare(n: i32, m: i32) -> Ordering {
if n < m {
Less
} else if n > m {
Greater
} else {
Equal
}
}
2
3
4
5
6
7
8
9
10
导入构造器后,我们可以直接写Less
而不是Ordering::Less
,依此类推。但由于这样不太明确,所以一般认为,除非能让代码可读性大大提高,否则最好不要这样导入。
要导入当前模块中声明的枚举的构造器,可以使用self
导入:
enum Pet {
Orca,
Giraffe,
...
}
use self::Pet::*;
2
3
4
5
6
在内存中,C风格枚举的值存储为整数。偶尔需要告诉Rust使用哪些整数:
enum HttpStatus {
Ok = 200,
NotModified = 304,
NotFound = 404,
...
}
2
3
4
5
6
否则,Rust会从0开始为你分配这些数字。
默认情况下,Rust使用能容纳C风格枚举的最小内置整数类型来存储它们。大多数C风格枚举可以用一个字节表示:
use std::mem::size_of;
assert_eq!(size_of::<Ordering>(), 1);
assert_eq!(size_of::<HttpStatus>(), 2); // 404 无法用u8表示
2
3
你可以通过给枚举添加#[repr]
属性来覆盖Rust对内存表示的选择。详细内容见 “寻找通用数据表示形式”。
将C风格枚举转换为整数是允许的:
assert_eq!(HttpStatus::Ok as i32, 200);
然而,反过来从整数转换为枚举是不允许的。与C和C++不同,Rust保证枚举值只能是枚举声明中列出的值之一。从整数类型到枚举类型的未经检查的转换可能会破坏这个保证,所以是不允许的。你可以自己编写一个检查转换的函数:
fn http_status_from_u32(n: u32) -> Option<HttpStatus> {
match n {
200 => Some(HttpStatus::Ok),
304 => Some(HttpStatus::NotModified),
404 => Some(HttpStatus::NotFound),
...
_ => None,
}
}
2
3
4
5
6
7
8
9
或者使用enum_primitive
库。它包含一个宏,可以自动为你生成这类转换代码。
和结构体一样,编译器会为你实现一些功能,比如==
运算符,但你需要显式要求:
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum TimeUnit {
Seconds,
Minutes,
Hours,
Days,
Months,
Years,
}
2
3
4
5
6
7
8
9
枚举也可以像结构体一样有方法:
impl TimeUnit {
/// 返回这个时间单位的复数名词形式。
fn plural(self) -> &'static str {
match self {
TimeUnit::Seconds => "seconds",
TimeUnit::Minutes => "minutes",
TimeUnit::Hours => "hours",
TimeUnit::Days => "days",
TimeUnit::Months => "months",
TimeUnit::Years => "years",
}
}
/// 返回这个时间单位的单数名词形式。
fn singular(self) -> &'static str {
self.plural().trim_end_matches('s')
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
关于C风格枚举就介绍到这里。Rust中更有趣的枚举类型是其变体可以持有数据的枚举。我们将展示这些枚举在内存中的存储方式,如何通过添加类型参数使其成为泛型,以及如何用枚举构建复杂的数据结构。
# 包含数据的枚举
有些程序总是需要精确显示日期和时间,精确到毫秒,但对于大多数应用程序来说,使用大致的近似值,比如 “两个月前”,会更友好。我们可以用之前定义的枚举来实现这一点:
/// 故意取整的时间戳,这样我们的程序会显示 “6个月前”,而不是 “2016年2月9日上午9:49”。
#[derive(Copy, Clone, Debug, PartialEq)]
enum RoughTime {
InThePast(TimeUnit, u32),
JustNow,
InTheFuture(TimeUnit, u32),
}
2
3
4
5
6
7
这个枚举中的两个变体InThePast
和InTheFuture
接受参数,这些被称为元组变体(tuple variants)。和元组结构体一样,这些构造器是创建新RoughTime
值的函数:
let four_score_and_seven_years_ago =
RoughTime::InThePast(TimeUnit::Years, 4 * 20 + 7);
let three_hours_from_now =
RoughTime::InTheFuture(TimeUnit::Hours, 3);
2
3
4
枚举也可以有结构体变体(struct variants),它们包含具名字段,就像普通结构体一样:
enum Shape {
Sphere { center: Point3d, radius: f32 },
Cuboid { corner1: Point3d, corner2: Point3d },
}
let unit_sphere = Shape::Sphere { center: ORIGIN, radius: 1.0, };
2
3
4
5
6
总体而言,Rust有三种枚举变体,与我们在上一章介绍的三种结构体相对应。
没有数据的变体对应于类单元结构体。元组变体的外观和功能都与元组结构体类似。结构体变体有花括号和具名字段。一个枚举可以同时包含这三种变体:
enum RelationshipStatus {
Single,
InARelationship,
ItsComplicated(Option<String>),
ItsExtremelyComplicated {
car: DifferentialEquation,
cdr: EarlyModernistPoem,
},
}
2
3
4
5
6
7
8
9
枚举的所有构造器和字段与枚举本身具有相同的可见性。
# 枚举在内存中的存储
在内存中,包含数据的枚举存储为一个小整数标签,再加上足够容纳最大变体所有字段的内存空间。标签字段由Rust内部使用,它表明是哪个构造器创建了这个值,进而表明该值有哪些字段。
截至Rust 1.50,RoughTime
类型占用8个字节,如图10-1所示。
图10-1 RoughTime值在内存中的存储
不过,Rust没有对枚举的布局做出保证,以便为未来的优化留出空间。在某些情况下,枚举的存储方式可能比图中所示的更高效。例如,一些泛型结构体在存储时可以完全不使用标签,我们稍后会看到。
# 使用枚举构建丰富的数据结构
枚举在快速实现类似树状的数据结构时也很有用。例如,假设一个Rust程序需要处理任意的JSON数据。在内存中,任何JSON文档都可以用这个Rust类型的值来表示:
use std::collections::HashMap;
enum Json {
Null,
Boolean(bool),
Number(f64),
String(String),
Array(Vec<Json>),
Object(Box<HashMap<String, Json>>),
}
2
3
4
5
6
7
8
9
10
用英文来解释这个数据结构,也很难比Rust代码本身更清楚。JSON标准规定了JSON文档中可能出现的各种数据类型:null
、布尔值、数字、字符串、JSON值的数组,以及带有字符串键和JSON值的对象。Json
枚举只是将这些类型列举了出来。
这不是一个假设的例子。在serde_json
(一个用于Rust结构体的序列化库,是crates.io上下载量最高的库之一)中,就能找到一个非常类似的枚举。
表示Object
的HashMap
外面的Box
只是为了让所有Json
值更紧凑。在内存中,Json
类型的值占用四个机器字长。String
和Vec
值占用三个机器字长,Rust还会添加一个标签字节。Null
和布尔值的数据量不足以占用这么多空间,但所有Json
值的大小必须相同,多余的空间就被闲置了。图10-2展示了一些Json
值在内存中的实际存储示例。
图10-2 Json值在内存中的存储
HashMap
占用的空间更大。如果我们必须在每个Json
值中为它预留空间,那么Json
值会非常大,大约八个机器字长。但是Box<HashMap>
只占用一个机器字长:它只是一个指向堆上分配数据的指针。如果对更多字段使用Box
,我们可以让Json
类型更加紧凑。
这里值得注意的是,设置这样一个数据结构是多么容易。在C++中,人们可能会为此编写一个类:
class JSON {
private:
enum Tag {
Null, Boolean, Number, String, Array, Object
};
union Data {
bool boolean;
double number;
shared_ptr<string> str;
shared_ptr<vector<JSON>> array;
shared_ptr<unordered_map<string, JSON>> object;
Data() {}
~Data() {}
...
};
Tag tag;
Data data;
public:
bool is_null() const { return tag == Null; }
bool is_boolean() const { return tag == Boolean; }
bool get_boolean() const {
assert(is_boolean());
return data.boolean;
}
void set_boolean(bool value) {
this->~JSON(); // 清理字符串/数组/对象值
tag = Boolean;
data.boolean = value;
}
...
};
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
仅仅30行代码,我们的工作才刚开始。这个类还需要构造函数、析构函数和赋值运算符。另一种选择是创建一个类层次结构,有一个基类JSON
和子类JSONBoolean
、JSONString
等等。不管哪种方式,完成后的C++ JSON库都会有十几甚至更多的方法。其他程序员要花些时间阅读才能理解并使用它。而整个Rust枚举只用了八行代码。
# 泛型枚举
枚举可以是泛型的。标准库中的两个示例是Rust语言中使用最广泛的数据类型:
enum Option<T> {
None,
Some(T),
}
enum Result<T, E> {
Ok(T),
Err(E),
}
2
3
4
5
6
7
8
9
现在我们对这些类型已经足够熟悉了,泛型枚举的语法和泛型结构体的语法是一样的。
有一个不太明显的细节是,当类型T
是引用、Box
或其他智能指针类型时,Rust可以省略Option<T>
的标签字段。由于这些指针类型都不允许为零,所以Rust可以用一个机器字长来表示Option<Box<i32>>
,例如,用0表示None
,用非零值表示Some
指针。这使得这种Option
类型与C或C++中可能为null
的指针值非常相似。不同之处在于,Rust的类型系统要求你在使用Option
的内容之前,必须检查它是否为Some
。这有效地避免了空指针解引用的问题。
只用几行代码就能构建泛型数据结构:
// 一个有序的T类型元素集合。
enum BinaryTree<T> {
Empty,
NonEmpty(Box<TreeNode<T>>),
}
// BinaryTree的一部分。
struct TreeNode<T> {
element: T,
left: BinaryTree<T>,
right: BinaryTree<T>,
}
2
3
4
5
6
7
8
9
10
11
12
这几行代码定义了一个BinaryTree
类型,它可以存储任意数量的T
类型的值。
这两个定义中蕴含了大量信息,所以我们花点时间逐字逐句地把代码翻译成英文。每个BinaryTree
值要么是Empty
,要么是NonEmpty
。如果是Empty
,那么它根本不包含数据。如果是NonEmpty
,那么它有一个Box
,这是一个指向堆上分配的TreeNode
的指针。
每个TreeNode
值包含一个实际的元素,以及另外两个BinaryTree
值。这意味着一棵树可以包含子树,因此一个NonEmpty
树可以有任意数量的后代。
图10-3展示了一个BinaryTree<&str>
类型值的示意图。和Option<Box<T>>
一样,Rust省略了标签字段,所以一个BinaryTree
值只占用一个机器字长。
图10-3 包含六个字符串的BinaryTree
在这个树中构建任何一个特定的节点都很简单:
use self::BinaryTree::*;
let jupiter_tree = NonEmpty(Box::new(TreeNode {
element: "Jupiter",
left: Empty,
right: Empty,
}));
2
3
4
5
6
7
更大的树可以由较小的树构建而成:
let mars_tree = NonEmpty(Box::new(TreeNode {
element: "Mars",
left: jupiter_tree,
right: mercury_tree,
}));
2
3
4
5
自然地,这个赋值操作将jupiter_node
和mercury_node
的所有权转移到了它们的新父节点。
树的其余部分遵循相同的模式。根节点与其他节点没有区别:
let tree = NonEmpty(Box::new(TreeNode {
element: "Saturn",
left: mars_tree,
right: uranus_tree,
}));
2
3
4
5
在本章后面,我们将展示如何在BinaryTree
类型上实现一个add
方法,这样我们就可以这样写:
let mut tree = BinaryTree::Empty;
for planet in planets {
tree.add(planet);
}
2
3
4
无论你之前使用哪种语言,在Rust中创建像BinaryTree
这样的数据结构可能都需要一些练习。一开始,你可能不太清楚Box
应该放在哪里。一种找到可行设计的方法是画一张类似图10-3的图,展示你希望数据在内存中的布局。然后从图反向推导出代码。每个矩形集合代表一个结构体或元组;每个箭头代表一个Box
或其他智能指针。弄清楚每个字段的类型有点像解谜,但还是可以解决的。解开这个谜题的回报是能够控制程序的内存使用。
现在来说说我们在引言中提到的 “代价”。枚举的标签字段会占用一点内存,在最坏的情况下最多占用八个字节,但通常这可以忽略不计。枚举真正的缺点(如果可以这么说的话)是,Rust代码不能随意尝试访问字段,而不管这些字段在值中是否实际存在:
let r = shape.radius; // 错误:Shape类型没有radius字段
访问枚举中数据的唯一安全方式是使用模式。
# 模式
回想一下本章前面定义的RoughTime
类型:
enum RoughTime {
InThePast(TimeUnit, u32),
JustNow,
InTheFuture(TimeUnit, u32),
}
2
3
4
5
假设你有一个RoughTime
值,想要在网页上显示它。你需要访问这个值里面的TimeUnit
和u32
字段。Rust不允许你直接通过rough_time.0
和rough_time.1
来访问它们,因为毕竟这个值可能是RoughTime::JustNow
,它没有字段。那么,你要如何获取这些数据呢?
你需要使用match
表达式:
fn rough_time_to_english(rt: RoughTime) -> String {
match rt {
RoughTime::InThePast(units, count) =>
format!("{} {} ago", count, units.plural()),
RoughTime::JustNow =>
format!("just now"),
RoughTime::InTheFuture(units, count) =>
format!("{} {} from now", count, units.plural()),
}
}
2
3
4
5
6
7
8
9
10
match
执行模式匹配;在这个例子中,模式是出现在第3、5、7行=>
符号之前的部分。匹配RoughTime
值的模式看起来就像用于创建RoughTime
值的表达式,这并非巧合。表达式产生值,模式消费值,二者使用了很多相同的语法。
让我们逐步分析这个match
表达式运行时会发生什么。假设rt
的值是RoughTime::InTheFuture(TimeUnit::Months, 1)
。Rust首先尝试将这个值与第3行的模式进行匹配。如图10-4所示,它们不匹配。
图10-4 一个不匹配的RoughTime值和模式
对枚举、结构体或元组进行模式匹配时,就好像Rust在进行从左到右的简单扫描,检查模式的每个组件,看值是否与之匹配。如果不匹配,Rust就会继续尝试下一个模式。
第3行和第5行的模式都匹配失败,但第7行的模式匹配成功(图10-5)。
图10-5 匹配成功
当一个模式包含像units
和count
这样的简单标识符时,这些标识符会在模式后面的代码中成为局部变量。值中的相应内容会被复制或移动到这些新变量中。Rust将TimeUnit::Months
存储到units
中,将1
存储到count
中,执行第8行代码,并返回字符串"1 months from now"
。
这个输出存在一个小语法问题,可以通过在match
中添加另一个分支来修复:
RoughTime::InTheFuture(unit, 1) =>
format!("a {} from now", unit.singular()),
2
这个分支只有在count
字段恰好为1
时才会匹配。注意,这段新代码必须添加在第7行之前。如果我们把它添加在最后,Rust永远不会执行到它,因为第7行的模式会匹配所有InTheFuture
值。如果你犯了这种错误,Rust编译器会发出 “不可达模式” 的警告 。
即使添加了新代码,RoughTime::InTheFuture(TimeUnit::Hours, 1)
仍然存在问题:结果"a hour from now"
不太正确,这就是英语的特点。这个问题也可以通过在match
中再添加一个分支来解决。
这个例子表明,模式匹配与枚举配合得很好,甚至可以测试枚举中包含的数据,这使得match
成为C语言中switch
语句的强大、灵活的替代方案。
到目前为止,我们只看到了匹配枚举值的模式。实际上不止如此。Rust模式有一套自己的规则,总结在表10-1中。本章的剩余部分大部分内容将围绕表中展示的特性展开。 表10-1 模式
模式类型 | 示例 | 注释 |
---|---|---|
字面量 | 100 "name" | 匹配确切的值;也允许使用常量名 |
范围 | 0..=100 'a'..='k' | 匹配范围内的任何值,包括边界值 |
通配符变量 | _ | 匹配任何值并忽略它 |
变量 | name | 和_ 类似,但会将值移动或复制到一个新的局部变量中 |
可变绑定变量 | mut count | 绑定一个可变引用,而不是移动或复制值 |
带引用的模式 | ref field ref mut field | 借用匹配值的引用,而不是移动或复制它 |
带绑定的子模式 | val @ 0..=99 ref circle @ Shape::Circle { .. } | 使用@ 左边的变量名匹配右边的模式 |
枚举模式 | Some(value) None Pet::Orca | 匹配枚举值 |
元组模式 | (key, value) (r, g, b) | 匹配元组 |
数组模式 | [a, b, c, d, e, f, g] [first, second] [first, _, third] [first, .., nth] [] | 匹配数组 |
切片模式 | [first, second] [first, _, third] [first, .., nth] [] | 匹配切片 |
结构体模式 | Color(r, g, b) Point { x, y } Card { suit: Clubs, rank: n } Account { id, name, .. } | 匹配结构体 |
引用模式 | &value &(k, v) | 仅匹配引用值 |
多重模式 | 'a' | 'A' | 仅在可反驳模式中有效(match 、if let 、while let ) |
守卫表达式 | x if x * x <= r2 | 仅在match 中有效(在let 等中无效) |
# 模式中的字面量、变量和通配符
到目前为止,我们展示了match
表达式与枚举的配合使用。其他类型也可以进行匹配。当你需要类似C语言中switch
语句的功能时,可以对整数值使用match
。像0
和1
这样的整数字面量可以作为模式:
match meadow.count_rabbits() {
0 => {}, // 没什么可说的
1 => println!("A rabbit is nosing around in the clover."),
n => println!("There are {} rabbits hopping about in the meadow", n),
}
2
3
4
5
如果草地上没有兔子,模式0
就会匹配。如果只有一只兔子,1
会匹配。如果有两只或更多兔子,就会匹配到第三个模式n
。这个模式只是一个变量名,它可以匹配任何值,并且匹配的值会被移动或复制到一个新的局部变量中。所以在这个例子中,meadow.count_rabbits()
的值被存储在新的局部变量n
中,然后我们打印这个变量。
其他字面量也可以用作模式,包括布尔值、字符,甚至字符串:
let calendar = match settings.get_string("calendar") {
"gregorian" => Calendar::Gregorian,
"chinese" => Calendar::Chinese,
"ethiopian" => Calendar::Ethiopian,
other => return parse_error("calendar", other),
};
2
3
4
5
6
在这个例子中,other
和上一个例子中的n
一样,用作通配模式。这些模式和switch
语句中的default
情况起着相同的作用,匹配那些与其他模式都不匹配的值。
如果你需要一个通配模式,但又不关心匹配到的值,可以使用单个下划线_
作为模式,即通配符模式:
let caption = match photo.tagged_pet() {
Pet::Tyrannosaur => "RRRAAAAAHHHHHH",
Pet::Samoyed => "*dog thoughts*",
_ => "I'm cute, love me", // 通用的说明文字,适用于任何宠物
};
2
3
4
5
通配符模式匹配任何值,但不会将其存储在任何地方。由于Rust要求每个match
表达式都处理所有可能的值,所以通常在末尾需要一个通配符。即使你非常确定剩余的情况不会发生,也至少必须添加一个备用分支,也许是一个会触发panic
的分支:
// 有很多种Shape,但我们只支持“选择”文本或矩形区域内的所有内容。
// 你不能选择椭圆或梯形。
match document.selection() {
Shape::TextSpan(start, end) => paint_text_selection(start, end),
Shape::Rectangle(rect) => paint_rect_selection(rect),
_ => panic!("unexpected selection type"),
}
2
3
4
5
6
7
# 元组模式和结构体模式
元组模式用于匹配元组。当你想在一次匹配中获取多个数据时,它们非常有用:
fn describe_point(x: i32, y: i32) -> &'static str {
use std::cmp::Ordering::*;
match (x.cmp(&0), y.cmp(&0)) {
(Equal, Equal) => "at the origin",
(_, Equal) => "on the x axis",
(Equal, _) => "on the y axis",
(Greater, Greater) => "in the first quadrant",
(Less, Greater) => "in the second quadrant",
_ => "somewhere else",
}
}
2
3
4
5
6
7
8
9
10
11
结构体模式和结构体表达式一样,使用花括号。它们为每个字段包含一个子模式:
match balloon.location {
Point { x: 0, y: height } =>
println!("straight up {} meters", height),
Point { x: x, y: y } =>
println!("at ({}m, {}m)", x, y),
}
2
3
4
5
6
在这个例子中,如果第一个分支匹配,那么balloon.location.y
会被存储到新的局部变量height
中。
假设balloon.location
是Point { x: 30, y: 40 }
。和往常一样,Rust会依次检查每个模式的每个组件(图10-6)。
图10-6 结构体的模式匹配
第二个分支匹配,所以输出会是at (30m, 40m)
。
像Point { x: x, y: y }
这样的模式在匹配结构体时很常见,重复的名字会造成视觉干扰,所以Rust为此提供了一种简写形式:Point {x, y}
,含义是一样的。这个模式仍然会将点的x
字段存储到新的局部变量x
中,将y
字段存储到新的局部变量y
中。
即使使用了简写形式,当我们只关心大型结构体的几个字段时,匹配它仍然很麻烦:
match get_account(id) {
...
Some(Account {
name, language, // <- - - 我们关心的2个字段
id: _, status: _, address: _, birthday: _, eye_color: _,
pet: _, security_question: _, hashed_innermost_secret: _,
is_adamantium_preferred_customer: _,
}) =>
language.show_custom_greeting(name),
}
2
3
4
5
6
7
8
9
10
为了避免这种情况,可以使用..
告诉Rust你不关心其他任何字段:
Some(Account { name, language, .. }) =>
language.show_custom_greeting(name),
2
# 数组模式和切片模式
数组模式用于匹配数组。它们常被用来筛选出一些特殊情况的值,并且在处理那些值的含义因位置而异的数组时非常有用。
例如,在将色调、饱和度和亮度(HSL)颜色值转换为红、绿、蓝(RGB)颜色值时,亮度为零或全亮度的颜色分别是黑色或白色。我们可以使用match
表达式轻松处理这些情况。
fn hsl_to_rgb(hsl: [u8; 3]) -> [u8; 3] {
match hsl {
[_, _, 0] => [0, 0, 0],
[_, _, 255] => [255, 255, 255],
...
}
}
2
3
4
5
6
7
切片模式与之类似,但与数组不同,切片的长度是可变的,所以切片模式不仅会根据值进行匹配,还会根据长度进行匹配。切片模式中的..
可以匹配任意数量的元素:
fn greet_people(names: &[&str]) {
match names {
[] => { println!("Hello, nobody.") },
[a] => { println!("Hello, {}.", a) },
[a, b] => { println!("Hello, {} and {}.", a, b) },
[a, .., b] => { println!("Hello, everyone from {} to {}.", a, b) }
}
}
2
3
4
5
6
7
8
# 引用模式
Rust模式支持两种处理引用的特性。ref
模式用于借用匹配值的一部分,&
模式用于匹配引用。我们先介绍ref
模式。
匹配一个不可复制的值会移动该值。继续以账户为例,这段代码是无效的:
match account {
Account { name, language, .. } => {
ui.greet(&name, &language);
ui.show_settings(&account); // 错误: 借用了已移动的值: `account`
}
}
2
3
4
5
6
在这里,account.name
和account.language
字段被移动到了局部变量name
和language
中,account
的其余部分被丢弃。这就是为什么我们之后不能再借用它的引用。
如果name
和language
都是可复制的值,Rust会复制这些字段而不是移动它们,这样代码就没问题了。但假设它们是String
类型,我们该怎么办呢?
我们需要一种模式,它借用匹配的值而不是移动它们。ref
关键字就能做到这一点:
match account {
Account { ref name, ref language, .. } => {
ui.greet(name, language);
ui.show_settings(&account); // 没问题
}
}
2
3
4
5
6
现在,局部变量name
和language
是指向account
中相应字段的引用。由于account
只是被借用,而不是被消耗,所以继续对它调用方法是可行的。
你可以使用ref mut
来借用可变引用:
match line_result {
Err(ref err) => log_error(err), // `err` 是 &Error (共享引用)
Ok(ref mut line) => { // `line` 是 &mut String (可变引用)
trim_comments(line); // 就地修改 String
handle(line);
}
}
2
3
4
5
6
7
模式Ok(ref mut line)
匹配任何成功的结果,并借用其中存储的成功值的可变引用。
另一种引用模式是&
模式。以&
开头的模式匹配引用:
match sphere.center() {
&Point3d { x, y, z } => ...
}
2
3
在这个例子中,假设sphere.center()
返回一个指向sphere
私有字段的引用,这在Rust中是一种常见的模式。返回的值是一个Point3d
的地址。如果中心在原点,那么sphere.center()
返回&Point3d { x: 0.0, y: 0.0, z: 0.0 }
。
模式匹配的过程如图10-7所示。
图10-7 引用的模式匹配
这有点棘手,因为Rust在这里是在解引用,而我们通常将这种操作与*
运算符联系起来,而不是&
运算符。要记住的是,模式和表达式是相反的。表达式(x, y)
将两个值组合成一个新的元组,而模式(x, y)
则相反:它匹配一个元组并分解出两个值。&
也是如此。在表达式中,&
创建一个引用。在模式中,&
匹配一个引用。
匹配引用遵循我们所熟知的所有规则。生命周期会被强制执行。你不能通过共享引用获得可变访问权限。而且,即使是可变引用,你也不能从引用中移出值。当我们匹配&Point3d { x, y, z }
时,变量x
、y
和z
会得到坐标的副本,而原始的Point3d
值保持不变。这之所以可行,是因为这些字段是可复制的。如果我们对一个字段不可复制的结构体尝试同样的操作,就会得到一个错误:
match friend.borrow_car() {
Some(&Car { engine, .. }) => // 错误: 不能从借用中移出值
...
None => {}
}
2
3
4
5
拆解借来的汽车当零件不太合适,Rust也不允许这样做。你可以使用ref
模式借用对某个部分的引用,但你并不拥有它:
Some(&Car { ref engine, .. }) => // 没问题,engine是一个引用
我们再来看一个&
模式的例子。假设我们有一个迭代器chars
,用于遍历字符串中的字符,并且它有一个方法chars.peek()
,该方法返回一个Option<&char>
:如果有下一个字符,则返回对它的引用(实际上,可窥视的迭代器确实会返回一个Option<&ItemType>
,我们将在第15章看到)。
程序可以使用&
模式来获取指向的字符:
match chars.peek() {
Some(&c) => println!("coming up: {:?}", c),
None => println!("end of chars"),
}
2
3
4
# 匹配守卫
有时,match
分支在被视为匹配之前,还必须满足其他条件。假设我们正在实现一个有六边形格子的棋盘游戏,玩家刚刚点击以移动棋子。为了确认点击有效,我们可能会尝试这样做:
fn check_move(current_hex: Hex, click: Point) -> game::Result<Hex> {
match point_to_hex(click) {
None =>
Err("That's not a game space."),
Some(current_hex) => // 如果用户点击了当前的六边形格子,尝试匹配(但这不起作用:见下面的解释)
Err("You are already there! You must click somewhere else."),
Some(other_hex) =>
Ok(other_hex)
}
}
2
3
4
5
6
7
8
9
10
这段代码会失败,因为模式中的标识符会引入新的变量。这里的模式Some(current_hex)
创建了一个新的局部变量current_hex
,遮蔽了参数current_hex
。Rust会对此代码发出几个警告——特别是,match
的最后一个分支是不可达的。
一种修复方法是在match
分支中简单地使用if
表达式:
match point_to_hex(click) {
None => Err("That's not a game space."),
Some(hex) => {
if hex == current_hex {
Err("You are already there! You must click somewhere else")
} else {
Ok(hex)
}
}
}
2
3
4
5
6
7
8
9
10
不过,Rust还提供了匹配守卫(match guards),即一个match
分支要应用必须满足的额外条件,写在模式和分支的=>
符号之间,格式为if CONDITION
:
match point_to_hex(click) {
None => Err("That's not a game space."),
Some(hex) if hex == current_hex =>
Err("You are already there! You must click somewhere else"),
Some(hex) => Ok(hex)
}
2
3
4
5
6
如果模式匹配,但条件为假,则继续匹配下一个分支。
# 匹配多种可能性
竖线(|
)可以用于在单个match
分支中组合多个模式:
let at_end = match chars.peek() {
Some(&'\r') | Some(&'\n') | None => true,
_ => false,
};
2
3
4
在表达式中,|
是按位或运算符,但在这里,它的作用更像是正则表达式中的|
符号。
如果chars.peek()
匹配这三个模式中的任何一个,at_end
就会被设置为true
。
使用..=
来匹配一整个范围的值。范围模式包括起始值和结束值,所以'0'..='9'
匹配所有ASCII数字:
match next_char {
'0'..='9' => self.read_number(),
'a'..='z' | 'A'..='Z' => self.read_word(),
' ' | '\t' | '\n' => self.skip_whitespace(),
_ => self.handle_punctuation(),
}
2
3
4
5
6
Rust(目前)不允许在模式中使用不包含结束值的范围,如0..100
。
# 带@的模式绑定
最后,x @ pattern
的匹配方式与给定的模式完全相同,但匹配成功时,它不会为匹配值的各个部分创建变量,而是创建一个单独的变量x
,并将整个值移动或复制到其中。例如,假设你有这样一段代码:
match self.get_selection() {
Shape::Rect(top_left, bottom_right) => {
optimized_paint(&Shape::Rect(top_left, bottom_right))
}
other_shape => {
paint_outline(other_shape.get_outline())
}
}
2
3
4
5
6
7
8
注意,第一种情况解包了一个Shape::Rect
值,却在下一行又重建了一个相同的Shape::Rect
值。可以使用@
模式重写这段代码:
rect @ Shape::Rect(..) => {
optimized_paint(&rect)
}
2
3
@
模式在处理范围时也很有用:
match chars.next() {
Some(digit @ '0'..='9') => read_number(digit, chars),
...
},
2
3
4
# 模式的使用场景
虽然模式在match
表达式中最为常见,但它们也可以在其他几个地方使用,通常是用来代替标识符。其含义始终是相同的:Rust不是简单地将一个值存储在单个变量中,而是使用模式匹配来分解这个值。
这意味着模式可以用于……
// ...将一个结构体解包为三个新的局部变量
let Track { album, track_number, title, .. } = song;
// ...解包作为函数参数的元组
fn distance_to((x, y): (f64, f64)) -> f64 { ... }
// ...遍历HashMap的键值对
for (id, document) in &cache_map {
println!("Document #{}: {}", id, document.title);
}
// ...自动解引用闭包的参数
// (这很方便,因为有时其他代码会传递给你一个引用,而你更想要一个副本)
let sum = numbers.fold(0, |a, &num| a + num);
2
3
4
5
6
7
8
9
10
11
这些用法每种都节省了两三行样板代码。其他一些语言也有相同的概念:在JavaScript中,它被称为解构(destructuring);在Python中,它被称为解包(unpacking)。
请注意,在这四个示例中,我们使用的模式都是保证能匹配的。模式Point3d { x, y, z }
能匹配Point3d
结构体类型的任何可能值,(x, y)
能匹配任何(f64, f64)
对,依此类推。在Rust中,始终能匹配的模式很特殊。它们被称为不可反驳模式(irrefutable patterns),并且是上述四个地方(let
之后、函数参数中、for
之后以及闭包参数中)唯一允许使用的模式。
可反驳模式(refutable pattern)是可能无法匹配的模式,比如Ok(x)
,它无法匹配错误结果;或者'0'..='9'
,它无法匹配字符'Q'
。可反驳模式可以用在match
分支中,因为match
就是为处理它们而设计的:如果一个模式匹配失败,接下来会发生什么是很明确的。前面的四个示例展示了在Rust程序中,模式很有用,但语言不允许匹配失败的情况。
可反驳模式也可以用在if let
和while let
表达式中,这些表达式可以用于……
// ...特别处理一个枚举变体
if let RoughTime::InTheFuture(_, _) = user.date_of_birth() {
user.set_time_traveler(true);
}
// ...仅当表查找成功时运行一些代码
if let Some(document) = cache_map.get(&id) {
return send_cached_response(document);
}
// ...反复尝试某件事,直到成功
while let Err(err) = present_cheesy_anti_robot_task() {
log_robot_attempt(err);
// 让用户再试一次(可能仍然是人类在操作)
}
// ...手动遍历迭代器
while let Some(_) = lines.peek() {
read_paragraph(&mut lines);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
有关这些表达式的详细信息,请参阅 “if let” 和 “循环”。
# 填充二叉树
前面我们承诺展示如何实现BinaryTree::add()
方法,该方法用于向如下类型的二叉树中添加节点:
// 一个有序的T类型元素集合。
enum BinaryTree<T> {
Empty,
NonEmpty(Box<TreeNode<T>>),
}
// BinaryTree的一部分。
struct TreeNode<T> {
element: T,
left: BinaryTree<T>,
right: BinaryTree<T>,
}
2
3
4
5
6
7
8
9
10
11
12
现在你已经对模式有了足够的了解,可以编写这个方法了。关于二叉搜索树的解释超出了本书的范围,但对于已经熟悉这个主题的读者来说,看看它在Rust中是如何实现的是很有价值的。
impl<T: Ord> BinaryTree<T> {
fn add(&mut self, value: T) {
match *self {
BinaryTree::Empty => {
*self = BinaryTree::NonEmpty(Box::new(TreeNode {
element: value,
left: BinaryTree::Empty,
right: BinaryTree::Empty,
}))
}
BinaryTree::NonEmpty(ref mut node) => {
if value <= node.element {
node.left.add(value);
} else {
node.right.add(value);
}
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
第1行告诉Rust我们正在为有序类型的BinaryTree
定义一个方法。这与我们在 “使用impl定义方法” 中定义泛型结构体方法的语法完全相同。
如果现有的树*self
为空,那就很简单。第5 - 9行代码会执行,将空树变为非空树。这里对Box::new()
的调用会在堆中分配一个新的TreeNode
。完成后,树中包含一个元素,其左子树和右子树均为空。
如果*self
不为空,我们匹配第11行的模式:BinaryTree::NonEmpty(ref mut node) => {
。
这个模式借用了对Box<TreeNode<T>>
的可变引用,这样我们就可以访问和修改该树节点中的数据。这个引用名为node
,其作用域是从第12行到第16行。由于该节点中已经有一个元素,代码必须递归调用.add()
,将新元素添加到左子树或右子树中。
这个新方法可以这样使用:
let mut tree = BinaryTree::Empty;
tree.add("Mercury");
tree.add("Venus");
...
2
3
4
# 整体概述
Rust中的枚举对于系统编程来说可能是新事物,但它并不是一个新的概念。在各种听起来很学术的名称下,比如代数数据类型,它已经在函数式编程语言中使用了四十多年。目前还不清楚为什么C语言传统中的其他语言很少有枚举类型。也许只是因为对于编程语言设计者来说,将变体、引用、可变性和内存安全性结合起来极具挑战性。函数式编程语言摒弃了可变性。相比之下,C语言中的联合(unions)有变体、指针和可变性,但安全性极差,以至于即使在C语言中,它们也是最后的手段。Rust的借用检查器就像一种魔法,使得在不妥协的情况下将这四者结合成为可能。
编程就是数据处理。将数据处理成合适的形式,可能会让程序在小型、快速、优雅与缓慢、庞大且混乱之间产生天壤之别。
这就是枚举所解决的问题。它们是一种将数据处理成合适形式的设计工具。对于一个值可能是某一种情况、另一种情况,或者根本没有值的情况,枚举在各个方面都比类层次结构更好:速度更快、更安全、代码更少、更易于记录文档。
其限制因素在于灵活性。枚举的最终用户无法扩展它来添加新的变体。只有通过更改枚举声明才能添加变体。而一旦这样做,现有代码就会出错。每个单独匹配枚举每个变体的match
表达式都必须重新审视 —— 它需要一个新的分支来处理新的变体。在某些情况下,用灵活性换取简单性是明智之举。毕竟,JSON的结构预计不会改变。在某些情况下,当枚举发生变化时重新审视所有使用它的地方正是我们想要的。例如,当枚举在编译器中用于表示编程语言的各种运算符时,添加一个新运算符应该涉及修改所有处理运算符的代码。
但有时需要更高的灵活性。对于这些情况,Rust有特性(traits),这是我们下一章的主题。