近期及未来的模式匹配改进

2020年3月4日 · Mazdak "Centril" Farrokhzad 代表 语言团队

编写软件的很大一部分工作围绕着检查某些数据是否具有特定形状(“模式”),从中提取信息,然后在匹配时作出反应。为了方便这一点,许多现代语言,包括 Rust,都支持所谓的“模式匹配”。

如果您是 Rust 新手或想复习知识,您可以先阅读 Rust Book 中的 第 6 章,“枚举和模式匹配”第 18 章,“模式和匹配”,或者在参考手册中阅读更多关于 match 表达式模式 的内容。

Rust 中的模式匹配工作原理是检查内存中的一个位置(place)(即“数据”)是否与特定模式(pattern)匹配。在这篇文章中,我们将介绍一些近期将在稳定版 Rust 中提供的模式改进,以及一些已经在 Nightly 版本中提供的改进。

如果您熟悉文中讨论的 Nightly 特性并想帮助推动它们稳定,请跳到 *我如何提供帮助?

子切片模式,[head, tail @ ..]

列表是软件中最基本和常见的数据结构之一。在 Rust 中,列表通常是内存中元素的连续序列,即一个切片(slice)

由于切片如此普遍,处理它们的重要性不言而喻。为此,我们在 Rust 1.26.0 中稳定了固定长度切片模式。因此,现在可以写诸如 let [a, b, c] = my_array; 来解构一个包含 3 个元素的数组。然而,很多时候我们处理的是长度未知的切片,因此如果只有固定长度切片模式,我们必须提供一个备用的 match 分支,例如使用 _ 作为模式。

在 Rust 1.42.0 中,我们正在稳定化子切片模式。为了引入子切片模式,我们使用 .. 来表示一个可变长度的间隙,它匹配 .. 前后模式未匹配到的尽可能多的元素。例如,在解析器中,我们希望在属性列表 attrs 后面没有项时报错,所以我们写

/// Recover if we parsed attributes and expected an item but there was none.
fn recover_attrs_no_item(&mut self, attrs: &[Attribute]) -> PResult<'a, ()> {
    let (start, end) = match attrs {
        [] => return Ok(()),
        [x0] => (x0, x0),
        [x0, .., xn] => (x0, xn),
    };
    let msg = if end.is_doc_comment() {
        "expected item after doc comment"
    } else {
        "expected item after attributes"
    };
    let mut err = self.struct_span_err(end.span, msg);
    if end.is_doc_comment() {
        err.span_label(end.span, "this doc comment doesn't document anything");
    }
    if let [.., penultimate, _] = attrs {
        err.span_label(start.span.to(penultimate.span), "other attributes here");
    }
    Err(err)
}

这里我们有两个子切片模式,第一个是 [x0, .., xn]。在这种情况下,模式绑定了第一个元素 x0 和最后一个元素 xn,忽略了中间的所有内容,匹配至少包含两个元素的切片。同时,[][x0] 匹配少于两个元素的情况,所以编译器知道我们已经覆盖了所有可能性。在后一种情况下,我们提取了切片的 penultimate(倒数第二个)元素,这正如其名称所示,也要求切片至少包含两个元素。

我们还可以将子切片绑定到变量。例如,假设我们希望除函数的最后一个参数外,禁止所有参数中使用 ...。如果是这样,我们可以写

match &*fn_decl.inputs {
    ... // other arms
    [ps @ .., _] => {
        for Param { ty, span, .. } in ps {
            if let TyKind::CVarArgs = ty.kind {
                self.err_handler().span_err(
                    *span,
                    "`...` must be the last argument of a C-variadic function",
                );
            }
        }
    }
}

在这里,ps @ .. 将切片的初始元素绑定到 ps 并忽略最后一个元素。

在 Nightly 中经过 7 年多的酝酿,经历了许多曲折后,子切片模式终于将稳定。为了实现这一点,我们不得不重新设计该特性,修补借用检查器中的健全性漏洞,并对穷尽性检查器进行大量重构。有关我们如何走到这一步的更多信息,请阅读稳定化报告Thomas Hartmann 的博客文章,并请关注 3 月 12 日发布的 1.42.0 版本公告。

嵌套 OR 模式

当对一个 enum 进行模式匹配时,某些变体的逻辑可能完全相同。为了避免重复,matchif letwhile let 表达式中的 | 分隔符可以用于表示如果任何一个由 | 分隔的模式匹配,就应该执行该分支。例如,我们可以写

// Any local node that may call something in its body block should be explored.
fn should_explore(tcx: TyCtxt<'_>, hir_id: hir::HirId) -> bool {
    match tcx.hir().find(hir_id) {
        Some(Node::Item(..))
        | Some(Node::ImplItem(..))
        | Some(Node::ForeignItem(..))
        | Some(Node::TraitItem(..))
        | Some(Node::Variant(..))
        | Some(Node::AnonConst(..))
        | Some(Node::Pat(..)) => true,
        _ => false,
    }
}

这虽然可用,但 Some(_) 仍然重复了几次。有了 #![feature(or_patterns)],该特性最近在 Nightly 中变得可用,可以避免这种重复

// Any local node that may call something in its body block should be explored.
fn should_explore(tcx: TyCtxt<'_>, hir_id: hir::HirId) -> bool {
    match tcx.hir().find(hir_id) {
        Some(
            Node::Item(..)
            | Node::ImplItem(..)
            | Node::ForeignItem(..)
            | Node::TraitItem(..)
            | Node::Variant(..)
            | Node::AnonConst(..)
            | Node::Pat(..),
        ) => true,
        _ => false,
    }
}

以前,在 match 表达式中使用 | 时,| 语法是 match 本身的一部分。有了 or_patterns,它现在成了模式本身的一部分,所以你可以任意嵌套 OR 模式,并在 let 语句中使用它们

let Ok(x) | Err(x) = foo();

OR 模式覆盖了所有由 | 连接(“or-ed”)的模式的并集(union)。为了确保无论匹配到哪个候选项,所有绑定都是一致且已初始化的,每个 or-ed 模式必须包含完全相同的绑定集,具有相同的类型和相同的绑定模式。

@ 后面的绑定

当匹配某个子结构时,有时你希望保留整个结构。例如,给定 Some(Expr { .. }),你可能想绑定外层的 Some(_) 层。在 Rust 中,这可以通过使用诸如 expr @ Some(Expr { .. }) 来实现,它将匹配到的位置绑定到 expr,同时确保它匹配 Some(Expr { .. })

假设 Expr 也有一个字段 span 你想使用。在过去,也就是 Rust 1.0 之前,这是可能的,但现在,它会导致错误

error[E0303]: pattern bindings are not allowed after an `@`
 --> src/lib.rs:L:C
  |
L |         bar @ Some(Expr { span }) => {}
  |                           ^^^^ not allowed after `@`

这在 #16053 中被改为错误,主要是因为在基于旧 AST 的借用检查器中,难以以健全的方式编码借用规则。

从那时起,我们移除了旧的借用检查器,转而使用基于 MIR 的新借用检查器,MIR 是一种更简单、更适合借用检查的数据结构。具体来说,对于像 let ref x @ ref y = a; 这样的语句,我们会得到大致与 let x = &a; let y = &a; 相同的 MIR。

所以现在,将绑定放在 @ 右边的情况可以由新的借用检查器统一且正确地处理(例如,编译器不会允许 ref x @ ref mut y),我们决定在 #![feature(bindings_after_at)] 特性门控下允许这样做,该特性现已在 Nightly 中可用。启用此特性后,你可以写例如

#![feature(bindings_after_at)]

fn main() {
    if let x @ Some(y) = Some(0) {
        dbg!(x, y);
    }
}

我们希望通过提供此特性,消除语言中一个令人困惑的角落。

组合 by-move 和 by-ref 绑定

出于与 @ 后面的绑定类似的原因,Rust 目前不允许将正常的 by-move 绑定与 by-ref 绑定组合使用。例如,如果您写...

fn main() {
    let tup = ("foo".to_string(), 0);
    let (x, ref y) = tup;
}

... 您将收到一个错误

error[E0009]: cannot bind by-move and by-ref in the same pattern
 --> src/main.rs:3:10
  |
3 |     let (x, ref y) = tup;
  |          ^  ----- by-ref pattern here
  |          |
  |          by-move pattern here

然而,与此同时,编译器却完全允许...

fn main() {
    let tup = ("foo".to_string(), 0);
    let x = tup.0;
    let ref y = tup.1;
}

... 即使这些程序之间没有语义上的差异。

现在我们已经切换到新的借用检查器(如前一节所述),我们也在 Nightly 中放宽了这一限制,所以在 #![feature(move_ref_pattern)] 特性门控下,你可以写

#![feature(move_ref_pattern)]

fn main() {
    let tup = ("foo".to_string(), 0);
    let (x, ref y) = tup;
}

我如何提供帮助?

总结一下,我们有三个不稳定的特性,它们以不同的方式改进了模式匹配

为了帮助我们将这些特性过渡到稳定的 Rust,我们需要您的帮助以确保它们符合预期的质量标准。要提供帮助,请考虑

  • 在适用的地方使用这些特性,如果您可以使用 Nightly 编译器,并将任何错误、问题、诊断不足等作为 issue 进行报告。
  • 查看在特性门控标签下报告的问题(例如 F-or_patterns),看看您是否可以帮助解决其中的任何问题。
    • 特别是,如果您能帮忙编写测试,将不胜感激。

感谢您的阅读,祝您模式匹配愉快!