在 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` 称为“match 臂”,或简称“臂”。

模式可以匹配简单的值,如整数或字符;它们也可以匹配用户定义的符号数据,通过 `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` 中至少一个臂的模式所覆盖。

这有助于捕获程序逻辑中的 bug,并确保 `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 使用 `enum` 和 `struct` 定义来实现此目的。

enum 类型允许定义互斥的值类别。上面显示的示例使用 `enum` 表示简单的符号标签,但在 Rust 中,枚举可以定义更丰富的数据类别。

例如,二叉树要么是一个叶子,要么是一个内部节点,带有对两个子树的引用。这是在 Rust 中编码整数树的一种方式

enum BinaryTree {
    Leaf(i32),
    Node(Box<BinaryTree>, i32, Box<BinaryTree>)
}

(`Box` 类型描述了对堆分配的 `V` 实例的所有权引用;如果你拥有一个 `Box`,那么你也拥有它包含的 `V`,并且可以对其进行修改、借出引用等。当你使用完 box 并让它超出作用域时,它会自动清理与堆分配的 `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`,编译器就会强制我们将其标记为可变)。

需要明确的是,上面的程序当然*可以*在 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` 中的数据,因为当我们通过第一个 match 臂时,一个对该数据的引用被 `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` 的输入是一个*L-value 表达式*;这意味着输入表达式被评估为一个值所在的*内存位置*。`match` 的工作原理是执行这个评估,然后检查该内存位置的数据。

(如果输入表达式是一个变量名或字段/指针解引用,则 L-value 就是该变量或字段/内存的位置。如果输入表达式是一个函数调用或其他生成未命名临时值的操作,则它在概念上会存储在临时区域,而 `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` 情况中对 `left` 和 `right` 使用了 `ref`-绑定。

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

剩下的唯一部分是 `ref`-绑定,它是 L-values 解构绑定的关键部分。

首先,让我们仔细陈述*非 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` 的 L-value 时,`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`。)