Rust权威指南之模式匹配

171 阅读13分钟

一. 简述

模式是Rust中一种用来匹配类型结构的特殊语法,它时而复杂,时而简单。将模式与match表达式或其他工具配合使用可以更好地控制程序流程。一个模式通常由以下组件组合而成:字面量、解构的数组/枚举/结构体或元组、变量、通配符和占位符。这些组件可以描述我们将要处理的数据形状,而数据形状可以被用来匹配值,进而判断出程序能否获得后续代码的处理的正确数据。

二. 所有可以使用模式的场景

实际上,我们已经不知不觉中使用很多模式了,它会出现在相当多不同的Rust语法中!

2.1. match分支

模式可以被用于match表达式的分支中。match表达式在形式上有match关键字、待匹配的值,以及至少一个匹配分支组合而成,而分支则由一个模式及匹配模式成功后应当执行的表达式组成。

match表达式必须穷尽匹配值的所有可能性。为了确保代码满足这一条件,我们可以在最后分支处使用全匹配模式。例子:

enum Week {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
    Sunday
}
fn main() {
    let day = Week::Monday;
    match day {
        Week::Saturday | Week::Sunday => println!("周末"),
        // _模式可以被用来匹配所有可能的值,且不讲它们绑定到任何一个变量上。
        _ => println!("工作日")
    }
}

2.2. if let条件表达式

if let表达式我们将它当作只匹配单个分支的match表达式来使用。但是if let还能够添加一个可选的else分支,如果if let对应的模式没有匹配成功,那么else分支的代码会得到执行。另外,我们同样可以混合使用if letelse if以及else if let表达式来进行匹配。例子:

fn main() {
    let favorite_color: Option<&str> = None;
    let is_tuesday = false;
    let age: Result<u8, _> = "34".parse();
    if let Some(color) = favorite_color {
        println!("Using your favorite color: {}, as the background", color);
    } else if is_tuesday {
        println!("Tuesday is green day!");
    } else if let Ok(age) = age {
        if age > 30 {
            println!("Using purple as the background color")
        } else {
            println!("Using orange as the background color");
        }
    } else {
        println!("Using blue as the background color!");
    }
}

match表达式不同,if let表达式的不利之处在于它不会强制开发者穷尽值的所有可能性。即便我们省略了随后可选的else块,并因此遗漏了某些需要处理的情形,编译器也不会在这里警告我们存在可能的逻辑性缺陷。

2.3. while let 条件循环

条件循环while let的构造与if let十分类似,但它会反复执行同一个模式匹配知道出现失败的情形。看例子:

fn main() {
    let mut stack = Vec::new();
    stack.push(1);
    stack.push(2);
    stack.push(3);
    while let Some(top) = stack.pop() {
        println!("出栈 => {}", top);
    }
}

上面的例子会依次打印出3、2、1。其中的pop方法会试图取出动态数组的最后一个元素并将它包裹在Some(value)中返回。如果动态数组为空,则pop返回Nonewhile循环会在pop返回Some时迭代循环体中的代码。并在pop返回None时结束循环。使用while let便可以将栈中每个元素逐一弹出了。

2.4. for循环

for循环时Rust代码中最为常见的循环结构,而你同样可以在for循环内使用模式。for语句中紧随关键字for的值就是一个模式。例子:

fn main() {
    let v = vec!['a', 'b', 'c'];
    // 在for循环中使用模式来解构元组
    for (index, value) in v.iter().enumerate() {
        println!("{} is at index {}", value, index);
    }
}

上面的代码使用了enumerate方法来作为迭代器的适配器,它会在每次迭代过程中生成一个包含值本身和值索引的元组。

2.5. let语句

我们只在前面的文章中了解过matchif let表达式中使用模式,但实际上,我们在其它的许多语句中(甚至是最基本的let语句)也同样用到了模式。

let x = 5;

类似这样语句出现过很多次,虽然我们可能没有意识到,但我们其实已经在上面的语句中使用了模式了。Rust会将表达式与模式进行比较,并为所有找到名称赋值。下面我们看一下使用let模式来解构元组的语句:

fn main() {
    let (x, y, z) = (1, 2, 3);
    println!("x: {}, y: {}, z: {}", x, y, z);
}

如果我们仅仅想要解构出来的其中几个值可以使用_符号忽略模式中的值。

fn main() {
    let (x, _, z) = (1, 2, 3);
    println!("x: {}, z: {}", x, z);
}

2.6. 函数参数

函数参数同样也是模式。下面我们看一下函数参数解构元组的例子:

fn print_coordinates(&(x, y): &(i32, i32)) {
    println!("current location: ({},{})", x, y)
}

fn main() {
    let print = (1, 2);
    print_coordinates(&print);
}

类似的函数参数列表,我们同样可以在闭包的参数列表中使用模式。

虽然我们已经见识过许多不同的模式了用法,但模式在不同上下文中的运行机制却不尽相同。在某些场景中,模式必须时不可失败的形式;而另一些场景下,模式却被允许是可失败的形式。

三. 模式是否会匹配失败

模式可以被分为不可失败和可失败两种类型。不可失败的模式能够匹配任何传入的值。可失败模式则可能因为某些特定的值而匹配失败。

函数参数、let语句及for循环只接收不可失败模式,因为这些场合下,我们程序无法在值不匹配时执行任何有意义的行为。if letwhile let表达式则只接收可失败模式,因为它们在设计时就将匹配失败的场景考虑在内了:条件表达式的功能就是根据条件的成功与否执行不同的操作。

一般而言,我们不用在编写代码时过多的考虑模式的可失败性,但你还是需要熟悉可失败性这个概念本身,因为你需要能够识别出错误提示信息中关于它描述,进而做出正确的应对。在遇到此类问题时,要么改变用于匹配的模式,要么改变被模式匹配的值的构造,这取决于代码期望实现的行为。

四. 模式语法

下面我们将介绍多种不同的模式示例,系统地整理所有可用的模式语法,并讨论每一种语法的用武之地。

4.1. 匹配字面量

在如前面的介绍例子,我们可以使用模式来匹配字面量,如下:

fn main() {
    let x = 1;
    match x {
        1 => println!("one"),
        2 => println!("two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

当我们需要根据特定的具体值来决定下一步行为时,就可以在代码中使用这一语法。

4.2. 匹配命名变量

命名变量是一种可以匹配任何值的不可失败模式,我们在前面文章中相当频繁的使用了这一模式。值得一提的是,当你在match表达式中使用命名变量时,情况会变得稍微复杂点,由于match开启了一个新的作用域,所有被定义在match表达式内作为模式一部分的变量会覆盖掉match结构外的同名变量,正如覆盖其他普通便利那个一样。例子:

fn main() {
    let x = Some(5);
    let y = 10;
    match x {
        Some(50) => println!("Got: 50"),
        Some(y) => println!("Matched, y = {:?}", y), // @1
        _ => println!("Default case, x = {:?}", x)
    }
    println!("at the end: x = {:?}, y = {:?}", x, y); // 
}

@1位置的代码引入了新的变量y,它会匹配Some变体中携带的任何值。因此我们处于match表达式创建的新的作用域中,这里的y是一个新的变量,而不是我们在程序起始处申明的那个存储了10y。这个新的y会绑定到x变量中Some内部值。

4.3. 多重模式

这个我们在上面的例子中其实已经演示过了,就是我们可以在match表达式的分支匹配中使用来表达或的意思,它可以用来一次性匹配多个模式。例子:

fn main() {
    let x = 1;
    match x { 
        1 | 2 => println!("one or two"),
        3 => println!("three"),
        _ => println!("anything")
    }
}

4.4. 使用...来匹配值区间

我们可以使用...来匹配区间的值。例子:

fn main() {
    let x = 5;
    match x {
        1 ... 5 => println!("one through ten"),
        _ => println!("anything")
    }
}

上面的代码和使用多重模式的|来列举1、2、3、4、5是一个意思,但是使用...比较简单方便。

范围模式只被允许使用树值或char值来进行定义,因为编译器需要在编译时确保范围的区间不为空,而char和数值正是Rust仅有的可以判断区间是否为空的类型。

4.5. 使用解构来分解值

我们可以使用模式来分解结构体、枚举、元组或引用,从而使用这些值中的不同部分。

4.5.1. 解构结构体

下面我们看一下如何去解构结构体:

struct Point {
    x: i32,
    y: i32
}

fn main() {
    let p = Point {x: 0, y: 7};
    let Point {x: a, y: b} = p;
    assert_eq!(0, a);
    assert_eq!(7, b);
}

上面的let Point {x: a, y: b} = p有点麻烦,Rust提供了我们简单的写法如下:

struct Point {
    x: i32,
    y: i32
}

fn main() {
    let p = Point {x: 0, y: 7};
    let Point {x, y} = p;
    assert_eq!(0, x);
    assert_eq!(7, y);
}

这里我们还能配合match一起使用,看例子:

struct Point {
    x: i32,
    y: i32
}

fn main() {
    let p = Point {x: 0, y: 7};
    match p {
        Point {x, y: 0} => println!("On the x axis at {}", x),
        Point {x: 0, y} => println!("On the y axis at {}", y),
        Point {x, y} => println!("On neither axis: ({}, {})", x, y),
    }
}

4.5.2. 解构枚举

这部分我们在前面的例子中已经演示过了。这里我们仅演示下嵌套枚举在match中的使用。

enum Color {
    Rgb(i32, i32, i32),
    Hsv(i32, i32, i32),
}

enum Message {
    Quit,
    Move{ x: i32, y: i32 },
    Write(String),
    ChangeColor(Color),
}

fn main() {
    let msg = Message::ChangeColor(Color::Hsv(0, 160, 255));
    match msg {
        Message::ChangeColor(Color::Rgb(r, g, b)) => {
            println!("Change the color to red {}, green {}, and blue {}", r, g, b)
        },
        Message::ChangeColor(Color::Hsv(h, s, v)) => {
            println!("Change the color to hue {}, saturation {}, and value {}", h, s, v)
        },
        _ => ()
    }
}

4.5.3. 解构元组

我们甚至可以按照某种更为复杂的方法来将模式混合、匹配或嵌套在一起。如下:

let ((feet, inches), Point {x, y}) = ((3, 10), Point{x: 3, y: -10});

4.6. 忽略模式中的值

在某些场景下忽略模式中的值是有意义的,例如match表达式的最后一个分支中,代码可以匹配剩余所有可能的值而不需要执行什么操作。有几种不同的方法可以让我们在模式中忽略全部或部分值:使用_模式、在另一个模式中使用_模式、使用以下划线开头的名称,或者使用..忽略值的剩余部分。

4.6.1. 使用_忽略整个值

虽然_模式常常被用在match表达式的最后一个分支中,但是实际上我们可以把它用于包含函数参数在内的一切模式中。如下:

fn foo(_: i32, y: i32) {
    println!("This code only uses y parameter: {}", y);
}

fn main() {
    foo(3, 4)
}

当不再需要函数中的某个参数时,你可以修改函数签名来移除那个不会被使用的参数。忽略函数参数在某些情况下会变得相当有用。

4.6.2. 使用嵌套的_忽略值的某些部分

我们还可以在另一个模式中使用_来忽略值的某些部分。例如,在要运行的代码中,当你需要检查值的某一部分切不会用到其他部分时,就可以使用这一模式。如下:

fn main() {
    let mut setting_value = Some(5);
    let new_setting_value = Some(10);
    match (setting_value, new_setting_value) {
        (Some(_), Some(_)) => println!("Can't overwrite an existing customized value"),
        _ => {
            setting_value = new_setting_value;
        }
    }
}

4.6.3. 通过以_开头的名称来忽略未使用的变量

Rust会在我们创建一个变量却没有使用过它时给出相应的警告,但是在一些场景中创建一个暂时不会用到的变量仍然是合理的,此时我们可以这样做:

fn main() {
    let _x = 5; // 忽略未使用的变量
    let y = 10;
    println!("y = {}", y);
}

这里我们需要注意一点就是使用下划线开头的变量名与仅仅使用_作为变量名存在一个细微的差别:_x语法仍然将值绑定到变量上;而_则完全不会进行绑定。

4.6.4. 使用..忽略值的剩余部分

对于拥有多个部分的值,我们可以使用..语法来使用其中的某一部分并忽略剩余的那些部分。这使我们不必为每一个需要忽略的值都添加对应的_模式来进行占位。..模式可以忽略一个值中没有被我们显示匹配的那些部分。

struct Point {
    x: i32,
    y: i32,
    z: i32
}

fn main() {
    let origin = Point {x: 0, y: 0, z: 0};
    match origin {
        // 忽略除了x之外的所有字段
        Point {x, ..} => println!("x is {}", x),
    }
}

我们也可以使用..忽略处理处理第一个和最后一个元组之外的元素:

fn main() {
    let numbers = (2, 4, 8, 16, 32);
    match numbers {
        (first, .., last) => println!("Some numbers: {}, {}", first, last),
    }
}

但是需要注意不要产生歧义:

fn main() {
    let numbers = (2, 4, 8, 16, 32);
    match numbers {
        // 这就无法确定具体是哪个值了
        (.., second, ..) => println!("Some numbers: {}, {}", first, last),
    }
}

4.7. 使用匹配守卫添加额外条件

匹配守卫是附加在match分支模式后的if条件语句,分支中的模式只有在该条件被同时满足是才能匹配成功。相比于单独使用模式匹配,匹配守卫可以表达出更为负责的意图。

fn main() {
    let num = Some(4);
    match num {
        Some(x) if x < 5 => println!("less then five: {}", x),
        Some(x) => println!("{}", x),
        None => ()
    }
}

在上面例子中有一个关于模式匹配变量覆盖的问题,下面看一下匹配守卫解决这个问题!

fn main() {
    let x = Some(5);
    let y = 10;
    match x {
        Some(50) => println!("Got: 50"),
        Some(n) if n == y => println!("Matched, y = {:?}", y),
        _ => println!("Default case, x = {:?}", x)
    }
    println!("at the end: x = {:?}, y = {:?}", x, y);
}

最后我们看一下匹配守卫与多重模式组合使用!

fn main() {
    let x = 4;
    let y = false;
    match x {
        // (4 | 5 | 7) if y 这里不要弄错优先级了
        4 | 5 | 6 if y => println!("Yes"),
        _ => println!("No")
    }
}

4.8. @绑定

@运算符允许我们在测试一个值是否匹配模式的同时创建存储该值的变量。

enum Message {
    Hello { id: i32 },
}

fn main() {
    let msg = Message::Hello { id: 5 };
    match msg {
        Message::Hello { id: id_variable @ 3..=7 } => println!("Found an id in range: {}", id_variable),
        Message::Hello { id: 10..=12 } => println!("Found an id in another range"),
        Message::Hello { id } => println!("Found some other id: {}", id),
    }
}

下面解释下第一分支:匹配值是否在3...7之间,可以匹配的话将匹配到的值赋值给变量id_variable,并输出;第二个分支:仅仅在模式中指定了值的区间,而这个分支关联的代码块中却没有一个包含了id字段的值的可用变量;第三个分支则指定了一个没有区间约束的变量,这个变量可以被用于随后的分支代码中。

@使我们可以在模式中测试一个值的同时将它保存在变量中。