跳转至

0x06 Enums and Pattern Matching*

枚举(enumerations)允许通过列举可能的成员(variants)来定义一个类型。首先定义并使用一个枚举来展示它如何连同数据一起编码信息的。然后探索一个特别有用枚举 Option,它代表一个值要么是某个值要么什么都不是。然后会介绍 match 表达式中使用模式匹配。最后介绍 if let 另一个简洁方便处理枚举的结构。

很多语言都有枚举功能,但功能各不相同。Rust 的枚举与 F#、OCaml 和 Haskell 这样的函数式编程语言中的代数数据类型(algebraic data types)最相似。

Defining an Enum*

设定一个场景,要记录一个 IP 地址是 v4 还是 v6,定义一个枚举来表示。

enum IpAddrKind {
    V4, 
    V6,
}

IpAddrKind 就是一个可以在代码中使用的自定义数据类型。

枚举值*

有了 IpAddrKind,就可以创建两个不同成员的实例。

let four = IpAddrKind::V4;
let six = IpAddrKind::V6;

枚举的成员位于标识符的命名空间中,使用 :: 获取。枚举成员的实例可以作为函数参数传入。

还是考虑 IP 地址,现在只知道是什么类型,但没有存储地址数据的方法,结合之前的结构体,就可以把枚举和数据放在一个结构体里。

struct IpAddr {
    kind: IpAddrKind,
    address: String
}
let localhost = IpAddr  {
    kind: IpAddrKind::V4,
    address: String::from("127.0.0.1"),
}

这样就把枚举成员与值相关联了。还可以使用一种更简洁的方式来表达相同的概念,仅使用枚举并将数据直接放进每一个枚举成员而不是将枚举作为结构体的一部分。

enum IpAddr {
    V4(String),
    V6(String),
}
let localhost = IpAddr::V4(String::from("127.0.0.1"));

直接将数据附加到枚举的每个成员上,就不需要一个额外的结构体了。这里的 V4/V6 就不是 IpAddrKind 中的成员,就是 IpAddr 的成员。用枚举代替结构体有一个优势,每个成员可以处理不同类型和数量的数据。IPv4 的地址是由 4 个 0~255 之间的数字组成,所以就可以单独处理 v4 地址。

enum IpAddr{
    V4(u8, u8, u8, u8),
    V6(String),
}
let localhost = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));

再看看标准库定义的 IpAddr

struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

可见,任意类型的数据都可以放入枚举成员中,字符串、数字、结构体甚至另一个枚举。

再看另一个枚举的例子,它的成员内嵌了多种类型。

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

一个 Message 枚举每个成员都存储了不同数量和类型的值。Quit 没有关联任何数据,Move 包含一个匿名结构体,Write 包含一个单独的 StringChangeColor 包含三个 i32。这七十九相当于定义四个结构体,QuitMessage 是类单元结构体,Write/ChangeColorMessage 是元组结构体。

和结构体相似,枚举也可以使用 impl 块在枚举上定义方法,使用 &self 获取调用方法的值。

Option 枚举*

Option 是标准库定义的一个枚举,它编码了一个非常普遍的场景,即一个值要么有值要么没值。

Rust 并没有很多其他语言中有的空值功能。空值(Null)是一个值,代表没有值。在有空值的语言中,变量总是两种状态之一:空值和非空值。

空值的问题在于尝试像使用非空值那样使用它时,就会出现某种形式的错误。

Rust 没有空值,但它有一个可以编码存在或不存在概念的枚举 Option<T>,定义在标准库中。

enum Option<T> {
    Some(T),
    None,
}

Option<T> 枚举非常有用且已经包含在 prelude 中,不需要显式引用。它的成员也可以不需要 Option:: 前缀而直接使用 SomeNone<T> 是泛型参数,第十章会详细讲解泛型。现在需要知道的就是 <T> 意味着 Option 枚举的 Some 成员可以包含任意类型的数据。

let some_number = Some(5);
let some_string = Some("a string");

let absent_number: Option<i32> = None;

如果使用 None,需要声明类型,因为编译器只通过 None 值无法推断出 Some 成员将保存值的类型。当有一个 Some 值时,我们知道存在一个值,并保存在 Some 中。当有一个 None 值时,某种意义上,它跟空值具有相同的意义:没有一个有效的值。

而因为 Option<T>T 是不同的类型,编译器不允许像一个肯定有效的值那样使用 Option<T>

let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

这段代码无法编译,因为 i8Option<i8> 类型不同而无法相加。在 Rust 中拥有一个像 i8 类型的值时,编译器确保它总是一个有效的值,无需做空值检查。只有当使用 Option<i8> 时需要担心可能没有值,而编译器会确保使用值之前处理了为空的情况。

在对 Option<T> 进行 T 的运算之前必须将其转换为 T,通常这能捕获到空值最常见的问题之一:假设某值不为空但实际为空的情况。

为了拥有一个可能为空的值,必须显式地将其放入对应类型的 Option 中,接着当使用这个值时,必须明确的处理值为空的情况。只要一个值不是 Option<T> 类型,就可以安全的认定它的值不为空。

那么问题来了,当有一个 Option<T> 的值时,如何从 Some 成员中取出 T 的值来使用它呢?Option 枚举拥有大量各种情况的方法,可以查看文档 std::option::Option - Rust (rust-lang.org)

总的来说,为了使用 Option<T> 值,需要编写处理每个成员的代码。match 表达式就是处理枚举的控制流结构,它会根据枚举的成员允许不同的代码,这些代码可以使用匹配到的值中的数据。

Match*

Rust 中的 match 是强大的控制流运算符,它允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行相应的代码。模式可由字面值、变量、通配符和许多其他内容构成,第十八章会涉及到所有不同种类的模式以及它们的作用。match 的实现主要来源于模式和编译器的检查, 确保所有可能的情况都得到处理。

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

上面就是一个使用 match 表达式处理枚举实例的例子。match 表达式执行时会按顺序与每个分支的模式比较。如果模式匹配,则相关联的代码将被执行。

每个分支相关联的代码是一个表达式,表达式的结果值将作为整个 match 表达式的返回值。

如果想在一个分支中运行多行代码,可以使用大括号。

绑定值的模式*

匹配分支的另一个功能是可以绑定匹配的模式的部分值,也就是如何从枚举成员中提取值。

修改上面定义的枚举。

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

Quarter 成员上附加了一个 UsState 枚举值。对应地修改 match 表达式。

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {:?}!", state);
            25
        },
    }
}

当匹配到 Coin::Quarter 时,变量 state 将会绑定 Quarter 附加的 UsState 成员值,并在接下里的分支代码中使用 state

匹配 Option<T>*

对于 Option 枚举,也可以通过绑定值的方式处理 Some 内部的 T 值。

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,
        Some(i) => Some(i + 1),
    }
}

let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);

绑定 i 然后直接计算并返回一个 Some(i + 1)

如果对于 Option 枚举的 match 表达式没有处理 None 的情况,就会编译报错。

Rust 要求必须覆盖所有可能的情况,不仅仅是 Option 枚举。上面那个 Coin 枚举,如果在 match 中去掉一个模式,也会编译报错 not covered。所以,Rust 中的匹配是穷尽的(exhaustive):必须穷举到最后的可能性来使代码有效。特例就是 None,Rust 防止忘记处理 None 的情况,从而规避错误。

_ 通配符*

Rust 也提供了不想列举出所有可能值的场景,提供了通配符 _,其实就类似 C switch 语句中的 default_ 会匹配所有的值,把它放到最后可以匹配所有之前没有指定的可能值。

这就发现,match 在只关心一种情况的场景中就比较啰嗦,为此 Rust 提供了 if let

if let*

对于只需要匹配一种模式而不关心其他模式的情况,可以使用 if let 简化代码。

let some_u8_value = Some(0u8)
if let Some(3) = some_u8_value {
    println!("three");
}

if let 获取一个模式和一个由等号分隔的表达式,if let 意味着失去 match 强制要求的穷尽性检查。

可以在 if let 中包含一个 else,相当于 match 表达式中的 _ 分支。这样就可以只对一种模式进行特殊处理,对其他所有模式进行相同处理。

let mut count = 0;
match coin {
    Coin::Quarter(state) => println!("State quarter from {:?}!", state),
    _ => count += 1,
}
// 转换成 if let else 表达式
let mut count = 0;
if let Coin::Quarter(state) = coin {
    println!("State quarter from {:?}!", state);
} else {
    count += 1;
}

总结*

本章涉及到使用枚举创建有一系列可列举值的自定义类型。也展示了标准库的 Option<T> 枚举帮助避免空值错误。枚举值包含数据时,还可以使用 matchif let 来获取并使用这些值。

下面将介绍 Rust 的模块系统,它可以保证只向用户暴露他们确实需要的部分。


最后更新: May 20, 2021