在 Rust 中混合匹配、变异和移动

2015 年 4 月 17 日 · Felix S. Klock II

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 中使用 enummatch 可以帮助这个过程,因为 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 是一种表达式形式,因此穷举确保此类表达式总是要么评估为正确类型的值,要么跳转到程序中的其他位置。

跳出 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 使用 enumstruct 定义来实现此目的。

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。无需检查空值。

人们确实需要检查给定的 BinaryTreeLeaf 还是 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 编译器接受上述程序。这值得注意,因为它的静态分析确保了以下两点

  • suffix 始终在我们在函数末尾运行 format! 之前初始化,并且

  • 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本身在程序中到达使用borrowedprintln!的每个执行路径上都被初始化。

(编译器确保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的输入是一个L-值表达式;这意味着输入表达式被计算为一个内存位置,其中值存在。match通过执行此计算,然后检查该内存位置的数据来工作。

(如果输入表达式是一个变量名或一个字段/指针解引用,那么 L-值就是该变量或字段/内存的位置。如果输入表达式是一个函数调用或其他生成未命名临时值的运算,那么它将概念上存储在一个临时区域中,这就是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情况下对leftright使用ref绑定。

解引用*t,解释为一个 L-值表达式,只是提取BinaryTree被表示的内存地址(因为t: &BinaryTree只是一个对内存中该数据的引用)。这里的*t不会复制树,也不会将其移动到一个新的临时位置,因为match将其视为一个 L-值。

剩下的唯一部分是ref绑定,它是 L-值解构绑定工作方式的关键部分。

首先,让我们仔细说明非 ref绑定的含义。

  • 当匹配一个类型为T的值时,一个标识符模式i将在成功匹配时,将值移动出原始输入并进入i。因此,在这种情况下,我们总是可以得出结论,i的类型为T(或者更简洁地说,“i: T”)。

    对于某些类型T,称为可复制T(也称为“T实现了Copy”),对于此类标识符模式,该值实际上将被复制到i中。(请注意,一般来说,任意类型T不可复制。)

    无论哪种方式,此类模式绑定都意味着变量i拥有类型为T的值的所有权。

因此,tree_weight_v2payload的绑定都具有类型i32i32类型实现了Copy,因此权重在两个分支中都被复制到payload中。

现在我们准备说明什么是 ref 绑定。

  • 当匹配一个类型为T的 L-值时,一个ref模式ref i将在成功匹配时,仅仅借用一个指向匹配数据的引用。换句话说,一个类型为T的值的成功ref i匹配将意味着i具有指向T引用的类型(或者更简洁地说,“i: &T”)。

因此,在tree_weight_v2Node分支中,left将是一个指向左侧框(包含一个树)的引用,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分支中同时绑定leftright。编译器知道这两个值不能别名,因此它允许这两个&mut引用同时存在。

结论

Rust 借鉴了函数式编程语言开创的代数数据类型和模式匹配的思想,并将它们应用于命令式编程风格和 Rust 自己的所有权和借用系统。enummatch形式提供了干净的数据定义和表达能力,而静态分析确保生成的程序是安全的。

有关此处未涵盖的详细信息的更多信息,例如

  • 如何在模式中说Higher而不是Answer::Higher

  • 定义新的命名常量,

  • 通过ident @ pattern绑定,或者

  • { let id = expr; ... }match expr { id => { ... } }之间可能微妙的差异,

请查阅 Rust 文档,或向我们优秀的社区提问(在 IRC 上的#rust,或在用户组中)。

(非常感谢那些帮助审查这篇文章的人,尤其是 Aaron Turon 和 Niko Matsakis,以及来自#rustMutabahproclibfudasQuirrelannodomini。)