在 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 表达式的值是明确定义的。

因此,例如,以下代码在编译时被拒绝。

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 使用 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。无需检查 null。

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

  • 在我们运行函数末尾的 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 变量本身在所有到达使用 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 的输入是一个左值表达式;这意味着输入表达式会被求值为值所在的内存位置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_v2tree_weight_v1 非常相似。唯一的区别是:我们以借用的引用的方式获取 t(在其类型中的 &),我们添加了解引用 *t,并且重要的是,我们在 Node 情况中使用了 ref 绑定 leftright

解引用 *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_v2payload 的绑定类型均为 i32i32 类型实现了 Copy,因此在两个分支中,权重都被复制到 payload 中。

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

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

因此,在 tree_weight_v2Node 分支中,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 分支中同时绑定 leftright。编译器知道这两个值不能别名,因此它允许两个 &mut 引用同时存在。

结论

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

有关此处未涵盖的更多细节,例如

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

  • 定义新的命名常量,

  • 通过 ident @ pattern 绑定,或

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

请查阅 Rust 文档,或在 IRC 中的 #rust 或在 用户组中向我们出色的社区提问。

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