Rust 项目的主要目标之一是实现安全的系统编程。系统编程通常意味着命令式编程,而命令式编程又常常意味着副作用、对共享状态的推理等等。
同时,为了提供安全性,Rust 程序和数据类型必须以允许静态检查来确保健全性的方式进行结构化。Rust 具有协同工作的特性和限制,以简化编写可以通过这些检查并从而确保安全的程序。例如,Rust 将所有权的概念深入到语言中。
Rust 的 match
表达式是一种结构,它提供了这些特性和限制的有趣组合。match
表达式接受一个输入值,对其进行分类,然后跳转到为处理已识别的数据类而编写的代码。
在这篇文章中,我们将探讨 Rust 如何通过 match
处理这些数据。match
及其对应物 enum
结合在一起的关键要素是:
-
结构模式匹配:与 C 或 Java 风格的
switch
语句相比,案例分析的人体工程学得到了极大改善。 -
穷举案例分析:确保在处理输入时不会遗漏任何案例。
-
match
兼顾了命令式和函数式编程风格:您可以继续使用break
语句、赋值等等,而不是被迫采用面向表达式的思维方式。 -
match
根据需要“借用”或“移动”:Rust 鼓励开发人员仔细考虑所有权和借用。为了确保不会被迫过早地放弃一个值的所有权,match
的设计支持仅借用子结构(而不是总是移动此类子结构)。
我们将在下面详细介绍上述每个项目,但首先我们将为讨论建立一个基础:match
的外观如何,以及它是如何工作的?
match
的基础知识
Rust 中的 match
表达式具有以下形式:
match INPUT_EXPRESSION {
PATTERNS_1 => RESULT_EXPRESSION_1,
PATTERNS_2 => RESULT_EXPRESSION_2,
...
PATTERNS_n => RESULT_EXPRESSION_n
}
其中每个 PATTERNS_i
都包含至少一个模式。模式描述了 INPUT_EXPRESSION
可能求值的一组可能值的子集。语法 PATTERNS => RESULT_EXPRESSION
称为“匹配臂”,或简称为“臂”。
模式可以匹配简单的值,如整数或字符;它们还可以匹配用户定义的符号数据,通过 enum
定义。
下面的代码演示了在猜数字游戏中,给定先前猜测的答案,如何生成下一个(糟糕的)猜测。
enum Answer {
Higher,
Lower,
Bingo,
}
fn suggest_guess(prior_guess: u32, answer: Answer) {
match answer {
Answer::Higher => println!("maybe try {} next", prior_guess + 10),
Answer::Lower => println!("maybe try {} next", prior_guess - 1),
Answer::Bingo => println!("we won with {}!", prior_guess),
}
}
#[test]
fn demo_suggest_guess() {
suggest_guess(10, Answer::Higher);
suggest_guess(20, Answer::Lower);
suggest_guess(19, Answer::Bingo);
}
(顺便说一下,这篇文章中的几乎所有代码都是可以直接执行的;您可以将代码片段剪切粘贴到一个文件 demo.rs
中,用 --test
编译该文件,然后运行生成的二进制文件以查看测试运行情况。)
模式还可以通过相应的模式匹配结构化数据(例如,元组、切片、用户定义的数据类型)。在这样的模式中,人们通常将输入的一部分绑定到局部变量;然后这些变量可以在结果表达式中使用。
特殊的 _
模式匹配任何单个值,通常用作通配符;特殊的 ..
模式通过匹配任何系列的值或名称/值对来概括这一点。
此外,可以通过垂直条 (|
) 分隔模式,将多个模式折叠到一个臂中;因此,该臂匹配此模式或该模式等等。
以下是对猜数字游戏答案生成策略的修改说明了这些功能:
struct GuessState {
guess: u32,
answer: Answer,
low: u32,
high: u32,
}
fn suggest_guess_smarter(s: GuessState) {
match s {
// First arm only fires on Bingo; it binds `p` to last guess.
GuessState { answer: Answer::Bingo, guess: p, .. } => {
// ~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~ ~~
// | | | |
// | | | Ignore remaining fields
// | | |
// | | Copy value of field `guess` into local variable `p`
// | |
// | Test that `answer field is equal to `Bingo`
// |
// Match against an instance of the struct `GuessState`
println!("we won with {}!", p);
}
// Second arm fires if answer was too low or too high.
// We want to find a new guess in the range (l..h), where:
//
// - If it was too low, then we want something higher, so we
// bind the guess to `l` and use our last high guess as `h`.
// - If it was too high, then we want something lower; bind
// the guess to `h` and use our last low guess as `l`.
GuessState { answer: Answer::Higher, low: _, guess: l, high: h } |
GuessState { answer: Answer::Lower, low: l, guess: h, high: _ } => {
// ~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~ ~~~~~~ ~~~~~~~~ ~~~~~~~
// | | | | |
// | | | | Copy or ignore
// | | | | field `high`,
// | | | | as appropriate
// | | | |
// | | | Copy field `guess` into
// | | | local variable `l` or `h`,
// | | | as appropriate
// | | |
// | | Copy value of field `low` into local
// | | variable `l`, or ignore it, as appropriate
// | |
// | Test that `answer field is equal
// | to `Higher` or `Lower`, as appropriate
// |
// Match against an instance of the struct `GuessState`
let mid = l + ((h - l) / 2);
println!("lets try {} next", mid);
}
}
}
#[test]
fn demo_guess_state() {
suggest_guess_smarter(GuessState {
guess: 20, answer: Answer::Lower, low: 10, high: 1000
});
}
这种同时执行案例分析和绑定输入子结构的能力导致了强大、清晰和简洁的代码,将读者的注意力直接集中在手头案例相关的数据上。
这就是 match
的简要介绍。
那么,这种结构与 Rust 在所有权和安全性方面的方法之间有什么相互作用呢?
穷举案例分析
...当你排除所有不可能的情况时,剩下的无论多么不可能,都一定是真相。
-- 夏洛克·福尔摩斯(阿瑟·柯南·道尔,“漂白的士兵”)
解决复杂问题的一种有用方法是将其分解为各个案例,并分别分析每个案例。为了使这种解决问题的方法有效,分解必须是集体穷举的;您确定的所有案例都必须实际涵盖所有可能的场景。
在 Rust 中使用 enum
和 match
可以帮助实现此过程,因为 match
强制执行穷举案例分析:match
的每个可能的输入值都必须由匹配中的至少一个臂中的模式覆盖。
这有助于捕捉程序逻辑中的错误,并确保 match
表达式的值是明确定义的。
因此,例如,以下代码在编译时被拒绝。
fn suggest_guess_broken(prior_guess: u32, answer: Answer) {
let next_guess = match answer {
Answer::Higher => prior_guess + 10,
Answer::Lower => prior_guess - 1,
// ERROR: non-exhaustive patterns: `Bingo` not covered
};
println!("maybe try {} next", next_guess);
}
许多其他语言都提供了模式匹配结构(ML 和 Scheme 中的各种基于宏的 match
实现都浮现在脑海中),但并非所有语言都具有此限制。
Rust 出于以下原因具有此限制:
-
首先,如上所述,只有当案例穷举时,将问题划分为案例才能产生通用解决方案。穷举检查会暴露逻辑错误。
-
其次,穷举检查可以充当重构辅助工具。在开发过程中,我经常为特定的
enum
定义添加新的变体。穷举检查有助于指出我仅从先前版本的enum
类型中编写案例的所有match
表达式。 -
第三,由于
match
是一种表达式形式,因此穷举性确保此类表达式始终计算为正确类型的值,或者跳转到程序中的其他位置。
跳出匹配
以下代码是我们在上面看到的 suggest_guess_broken
函数的修复版本;它直接说明了“跳转到其他位置”:
fn suggest_guess_fixed(prior_guess: u32, answer: Answer) {
let next_guess = match answer {
Answer::Higher => prior_guess + 10,
Answer::Lower => prior_guess - 1,
Answer::Bingo => {
println!("we won with {}!", prior_guess);
return;
}
};
println!("maybe try {} next", next_guess);
}
#[test]
fn demo_guess_fixed() {
suggest_guess_fixed(10, Answer::Higher);
suggest_guess_fixed(20, Answer::Lower);
suggest_guess_fixed(19, Answer::Bingo);
}
suggest_guess_fixed
函数说明 match
可以提前处理某些案例(然后立即从函数返回),同时从其余案例计算所需的值,并让它们传递到函数体的其余部分。
我们可以通过 match
添加此类特殊情况处理,而无需担心忽略某个案例,因为 match
会强制案例分析是穷举的。
代数数据类型和结构不变式
代数数据类型简洁地描述了数据类,并允许对丰富的结构不变式进行编码。Rust 使用 enum
和 struct
定义来实现此目的。
enum
类型允许定义互斥的值类。上面显示的示例使用了 enum
来表示简单的符号标记,但在 Rust 中,枚举可以定义更丰富的数据类。
例如,二叉树要么是一个叶子,要么是一个具有对两个子树的引用的内部节点。这是在 Rust 中编码整数树的一种方法:
enum BinaryTree {
Leaf(i32),
Node(Box<BinaryTree>, i32, Box<BinaryTree>)
}
(Box<V>
类型描述了对 V
的堆分配实例的拥有引用;如果您拥有 Box<V>
,那么您也拥有它包含的 V
,并且可以对其进行变异、借出对它的引用等等。当您完成对该框的操作并使其超出范围时,它会自动清理与堆分配的 V
关联的资源。)
上面的 enum
定义确保,如果我们给定一个 BinaryTree
,它将始终属于上述两种情况之一。永远不会遇到不具有左侧子级的 BinaryTree::Node
。无需检查 null。
确实需要检查给定的 BinaryTree
是 Leaf
还是 Node
,但是编译器静态地确保执行此类检查:您不能意外地将 Leaf
的数据解释为 Node
的数据,反之亦然。
这是一个使用 match
对树中的所有整数求和的函数。
fn tree_weight_v1(t: BinaryTree) -> i32 {
match t {
BinaryTree::Leaf(payload) => payload,
BinaryTree::Node(left, payload, right) => {
tree_weight_v1(*left) + payload + tree_weight_v1(*right)
}
}
}
/// Returns tree that Looks like:
///
/// +----(4)---+
/// | |
/// +-(2)-+ [5]
/// | |
/// [1] [3]
///
fn sample_tree() -> BinaryTree {
let l1 = Box::new(BinaryTree::Leaf(1));
let l3 = Box::new(BinaryTree::Leaf(3));
let n2 = Box::new(BinaryTree::Node(l1, 2, l3));
let l5 = Box::new(BinaryTree::Leaf(5));
BinaryTree::Node(n2, 4, l5)
}
#[test]
fn tree_demo_1() {
let tree = sample_tree();
assert_eq!(tree_weight_v1(tree), (1 + 2 + 3) + 4 + 5);
}
代数数据类型建立了由语言严格执行的结构不变式。(甚至可以通过使用模块和隐私来维护更丰富的表示不变式;但让我们不要偏离主题。)
面向表达式和面向语句
与许多提供模式匹配的语言不同,Rust兼顾了面向语句和面向表达式的编程。
许多提供模式匹配的函数式语言鼓励人们以“面向表达式的风格”编写,其中重点始终是评估表达式组合所返回的值,并且不鼓励副作用。这种风格与命令式语言形成对比,后者鼓励面向语句的风格,该风格侧重于仅为副作用而执行的命令序列。
Rust 擅长支持这两种风格。
考虑编写一个函数,该函数将非负整数映射到一个字符串,该字符串将其呈现为序数(“1st”、“2nd”、“3rd”,...)。
以下代码使用范围模式来简化事情,但是,它的编写风格类似于面向语句的语言(如 C(或 C++、Java 等))中的 switch
,其中 match
的臂仅为其副作用而执行:
fn num_to_ordinal(x: u32) -> String {
let suffix;
match (x % 10, x % 100) {
(1, 1) | (1, 21...91) => {
suffix = "st";
}
(2, 2) | (2, 22...92) => {
suffix = "nd";
}
(3, 3) | (3, 23...93) => {
suffix = "rd";
}
_ => {
suffix = "th";
}
}
return format!("{}{}", x, suffix);
}
#[test]
fn test_num_to_ordinal() {
assert_eq!(num_to_ordinal( 0), "0th");
assert_eq!(num_to_ordinal( 1), "1st");
assert_eq!(num_to_ordinal( 12), "12th");
assert_eq!(num_to_ordinal( 22), "22nd");
assert_eq!(num_to_ordinal( 43), "43rd");
assert_eq!(num_to_ordinal( 67), "67th");
assert_eq!(num_to_ordinal(1901), "1901st");
}
Rust 编译器接受上述程序。这是值得注意的,因为它的静态分析确保了以下两点:
-
在我们运行函数末尾的
format!
之前,suffix
始终被初始化,并且 -
suffix
在函数执行期间最多被赋值一次(因为如果我们可以多次赋值suffix
,编译器会强制我们将suffix
标记为可变的)。
需要明确的是,上面的程序当然可以用 Rust 中面向表达式的风格编写;例如,像这样:
fn num_to_ordinal_expr(x: u32) -> String {
format!("{}{}", x, match (x % 10, x % 100) {
(1, 1) | (1, 21...91) => "st",
(2, 2) | (2, 22...92) => "nd",
(3, 3) | (3, 23...93) => "rd",
_ => "th"
})
}
有时,面向表达式的风格可以产生非常简洁的代码;而其他时候,这种风格需要通过编写面向语句的风格来避免的曲解。(在 suggest_guess_fixed
函数中,从一个 match
臂 提前返回的能力就是这方面的一个例子。)
每种风格都有其用例。至关重要的是,在 Rust 中切换到面向语句的风格并不会牺牲 Rust 提供的所有其他功能,例如保证非 mut
绑定最多被赋值一次。
当人们想要初始化某些状态,然后在某些控制流分支上从中借用时,就会出现一个重要的案例。
fn sometimes_initialize(input: i32) {
let string: String; // a dynamically-constructed string value
let borrowed: &str; // a reference to string data
match input {
0...100 => {
// Construct a String on the fly...
string = format!("input prints as {}", input);
// ... and then borrow from inside it.
borrowed = &string[6..];
}
_ => {
// String literals are *already* borrowed references
borrowed = "expected between 0 and 100";
}
}
println!("borrowed: {}", borrowed);
// Below would cause compile-time error if uncommented...
// println!("string: {}", string);
// ...namely: error: use of possibly uninitialized variable: `string`
}
#[test]
fn demo_sometimes_initialize() {
sometimes_initialize(23); // this invocation will initialize `string`
sometimes_initialize(123); // this one will not
}
上面代码有趣的地方在于,在 match
之后,我们不能直接访问 string
,因为编译器要求变量在程序的所有执行路径中都必须先初始化才能访问。与此同时,我们可以通过 borrowed
访问可能存储在 string
内部的数据,因为当我们执行第一个匹配分支时,borrowed
变量持有了该数据的引用,并且我们确保了 borrowed
变量本身在所有到达使用 borrowed
的 println!
的执行路径上都进行了初始化。
(编译器确保了 string
数据的任何未完成的借用都不可能比 string
本身存活更久,并且生成的代码确保了在 string
作用域结束时,如果数据之前被初始化过,则会被释放。)
简而言之,为了保证健全性,Rust 语言确保数据在被引用之前总是被初始化,但设计者们一直在努力避免仅仅为了迎合 Rust 的静态分析而采用人为的编码模式(例如,要求你用一些虚拟数据初始化上面的 string
,或要求使用面向表达式的风格)。
不移动的匹配
匹配输入可以借用输入的子结构,而无需获取所有权;这对于匹配引用(例如,类型为 &T
的值)至关重要。
上面的“代数数据类型”部分描述了一种树数据类型,并展示了一个计算树实例中整数和的程序。
然而,tree_weight
的这个版本有一个很大的缺点:它通过值来获取输入的树。一旦你将一棵树传递给 tree_weight_v1
,这棵树就消失了(如被释放)。
#[test]
fn tree_demo_v1_fails() {
let tree = sample_tree();
assert_eq!(tree_weight_v1(tree), (1 + 2 + 3) + 4 + 5);
// If you uncomment this line below ...
// assert_eq!(tree_weight_v1(tree), (1 + 2 + 3) + 4 + 5);
// ... you will get: error: use of moved value: `tree`
}
然而,这不是使用 match
的结果;而是由于选择的函数签名造成的。
fn tree_weight_v1(t: BinaryTree) -> i32 { 0 }
// ^~~~~~~~~~ this means this function takes ownership of `t`
事实上,在 Rust 中,match
的设计目的是在不获取所有权的情况下也能很好地工作。特别是,match
的输入是一个左值表达式;这意味着输入表达式会被求值为值所在的内存位置。match
的工作原理是进行此求值,然后检查该内存位置的数据。
(如果输入表达式是一个变量名或字段/指针解引用,那么左值就是该变量或字段/内存的位置。如果输入表达式是一个函数调用或其他生成未命名临时值的操作,那么它将在概念上存储在一个临时区域中,这就是 match
将检查的内存位置。)
因此,如果我们想要一个只借用树而不是获取其所有权的 tree_weight
版本,那么我们需要利用 Rust 的 match
的这一特性。
fn tree_weight_v2(t: &BinaryTree) -> i32 {
// ^~~~~~~~~~~ The `&` means we are *borrowing* the tree
match *t {
BinaryTree::Leaf(payload) => payload,
BinaryTree::Node(ref left, payload, ref right) => {
tree_weight_v2(left) + payload + tree_weight_v2(right)
}
}
}
#[test]
fn tree_demo_2() {
let tree = sample_tree();
assert_eq!(tree_weight_v2(&tree), (1 + 2 + 3) + 4 + 5);
}
函数 tree_weight_v2
与 tree_weight_v1
非常相似。唯一的区别是:我们以借用的引用的方式获取 t
(在其类型中的 &
),我们添加了解引用 *t
,并且重要的是,我们在 Node
情况中使用了 ref
绑定 left
和 right
。
解引用 *t
,被解释为一个左值表达式,只是提取了表示 BinaryTree
的内存地址(因为 t: &BinaryTree
只是对内存中该数据的引用)。这里的 *t
不是在复制树,也不是将其移动到新的临时位置,因为 match
将其视为左值。
剩下的唯一部分是 ref
绑定,这是左值的解构绑定如何工作的关键部分。
首先,让我们仔细说明非 ref 绑定的含义。
-
当匹配类型为
T
的值时,标识符模式i
将在成功匹配时,将值从原始输入移动到i
中。因此,在这种情况下,我们始终可以得出结论,i
的类型为T
(或者更简洁地说,“i: T
”)。对于某些类型
T
,称为可复制T
(也发音为“T
实现Copy
”),该值实际上将被复制到此类标识符模式的i
中。(请注意,一般来说,任意类型T
都不可复制。)无论哪种方式,这种模式绑定都意味着变量
i
拥有类型为T
的值的所有权。
因此,tree_weight_v2
中 payload
的绑定类型均为 i32
; i32
类型实现了 Copy
,因此在两个分支中,权重都被复制到 payload
中。
现在我们准备好说明什么是 ref 绑定了。
- 当匹配类型为
T
的左值时,ref
模式ref i
将在成功匹配时,仅仅借用对匹配数据的引用。换句话说,对类型为T
的值的成功ref i
匹配将意味着i
的类型为对T
的引用(或更简洁地说,“i: &T
”)。
因此,在 tree_weight_v2
的 Node
分支中,left
将是对左侧 box 的引用(其中包含一个树),right
将同样引用右侧的树。
我们可以将这些借用的树引用传递给 tree_weight_v2
的递归调用,正如代码所示。
同样,ref mut
模式 (ref mut i
) 将在成功匹配时,借用对输入的可变引用:i: &mut T
。这允许修改,并确保同时没有其他对该数据的活动引用。像 match
这样的解构绑定形式允许同时获取对数据不相交部分的可变引用。
这段代码通过递增给定树中的所有值来演示这个概念。
fn tree_grow(t: &mut BinaryTree) {
// ^~~~~~~~~~~~~~~ `&mut`: we have exclusive access to the tree
match *t {
BinaryTree::Leaf(ref mut payload) => *payload += 1,
BinaryTree::Node(ref mut left, ref mut payload, ref mut right) => {
tree_grow(left);
*payload += 1;
tree_grow(right);
}
}
}
#[test]
fn tree_demo_3() {
let mut tree = sample_tree();
tree_grow(&mut tree);
assert_eq!(tree_weight_v2(&tree), (2 + 3 + 4) + 5 + 6);
}
请注意,上面的代码现在通过 ref mut
模式绑定了 payload
; 如果它没有使用 ref
模式,那么 payload
将绑定到整数的本地副本,而我们想要修改树本身中的实际整数。因此,我们需要一个对该整数的引用。
还要注意,代码能够在 Node
分支中同时绑定 left
和 right
。编译器知道这两个值不能别名,因此它允许两个 &mut
引用同时存在。
结论
Rust 吸收了函数式编程语言开创的代数数据类型和模式匹配的思想,并将其适应于命令式编程风格和 Rust 自己的所有权和借用系统。enum
和 match
形式提供了清晰的数据定义和表达能力,而静态分析则确保了生成的程序的安全性。
有关此处未涵盖的更多细节,例如
-
如何在模式中说
Higher
而不是Answer::Higher
, -
定义新的命名常量,
-
通过
ident @ pattern
绑定,或 -
{ let id = expr; ... }
与match expr { id => { ... } }
之间可能存在的细微差异,
请查阅 Rust 文档,或在 IRC 中的 #rust
或在 用户组中向我们出色的社区提问。
(非常感谢那些帮助审阅这篇文章的人,特别是 Aaron Turon 和 Niko Matsakis,以及来自 #rust
的 Mutabah
、proc
、libfud
、asQuirrel
和 annodomini
。)