【Rust学习之旅】强大的枚举类型与模式匹配(六)

lxf2023-04-16 18:44:02

上一期我们学到了在typescript里没有的一种新类型结构体,了解了他的定义与使用,简单来说可以当做typescript中的类来使用。本期我们来学习枚举类型,这和我们typescript中的枚举区别就很大了,typescript中的枚举基本上没有什么功能。

定义与使用

枚举enumerations),也被称作 enums。枚举允许你通过列举可能的 成员variants)来定义一个类型。

rust 也和typescript一样使用enum作为关键词定义枚举。下面我们定义一个枚举,分别表示IP地址,V6,V6

enum IpAddrKind {
    V4,
    V6,
}

枚举的成员位于其标识符的命名空间中,并使用两个冒号分开。这么设计的好处是现在 IpAddrKind::V4 和 IpAddrKind::V6 都是 IpAddrKind 类型

fn route(ip_kind: IpAddrKind) {}

现在我们可以传递任一成员调用函数

fn main() {
    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

进一步考虑一下我们的 IP 地址类型,目前没有一个存储实际 IP 地址 数据 的方法;只知道它是什么 类型 的。

对比起typescript的枚举,默认需要初始化一个值,如果没有初始化,默认值是从0开始的依次赋值。但是我们思考一下,ip地址可能是各种不同的数据。typescript或许就只能用interface,来声明这个类型了。

enum IpAddrKind {
    V4,
    V6
}
interface IpAddrKind {
    v4:string;
    v6:string
}

前面我们学过结构体,你也许会想到这样做,类似于typescript的接口一样使用。

fn main() {
    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

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

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
}

但是这样写起来不够简洁,rust给枚举赋予了更简单的写法。

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }
    let home = IpAddr::V4(String::from("127.0.0.1"));
    let loopback = IpAddr::V6(String::from("::1"));
}

我们直接将数据附加到枚举的每个成员上,这样就不需要一个额外的结构体了。这里也很容易看出枚举工作的另一个细节:每一个我们定义的枚举成员的名字也变成了一个构建枚举的实例的函数。也就是说,IpAddr::V4() 是一个获取 String 参数并返回 IpAddr 类型实例的函数调用。作为定义枚举的结果,这些构造函数会自动被定义。

枚举替代结构体还有另一个优势:每个成员可以处理不同类型和数量的数据。

举个例子:IPv4 版本的 IP 地址总是含有四个值在 0 和 255 之间的数字部分。如果我们想要将 V4 地址存储为四个 u8 值而 V6 地址仍然表现为一个 String,这就不能使用结构体了。

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

现在到这里看起来,枚举就像typescript的接口一样。但是又有一些区别。来看下面的例子。

结构体和枚举还有另一个相似点:就像可以使用 impl 来为结构体定义方法那样,也可以在枚举上定义方法。这是一个定义于我们 Message 枚举上的叫做 call 的方法:

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

    impl Message {
        fn call(&self) {
            // 在这里定义方法体
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
}

这个例子中,创建了一个值为 Message::Write(String::from("hello")) 的变量 m,而且这就是当 m.call() 运行时 call 方法中的 self 的值。这里和javascript有点相似,谁调用this就指向谁。

Option 枚举

Rust 没有null的类型,然而,空值尝试表达的概念仍然是有意义的:空值是一个因为某种原因目前无效或缺失的值。

那我们来看看rust怎么处理这种情况,它提供了一个可以编码存在或不存在概念的枚举。这个枚举是 Option<T>,存在于标准库中。

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

Option<T> 枚举非常有用,所以官方使其开箱即用,无需引入就可以直接使用。它的成员也是如此,可以不需要 Option:: 前缀来直接使用 Some 和 None。即便如此 Option<T> 也仍是常规的枚举,Some(T) 和 None 仍是 Option<T> 的成员。

<T> 语法是一个我们还未讲到的 Rust 功能。它是一个泛型类型参数,后面会详细讲解。

下面我们可以直接使用成员定义两个变量。大部分情况rust编译器都可以推断出类型,编译器只通过 None 值无法推断出 Some 成员保存的值的类型。所以我们需要手动声明absent_number的类型。

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

当有一个 Some 值时,我们就知道存在一个值,而这个值保存在 Some 中。当有个 None 值时,在某种意义上,它跟空值具有相同的意义:并没有一个有效的值。

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

    let sum = x + y;
}

这段代码不能编译,因为它尝试将 Option<i8> 与 i8 相加。

这里Rust 不知道该如何将 Option<i8> 与 i8 相加,因为它们的类型不同。当在 Rust 中拥有一个像 i8 这样类型的值时,编译器确保它总是有一个有效的值。我们可以自信使用而无需做空值检查。只有当使用 Option<i8>(或者任何用到的类型)的时候需要担心可能没有值,而编译器会确保我们在使用值之前处理了为空的情况。

所以在对 Option<T> 进行运算之前必须将其转换为 T

match 控制流结构

上面我们提到了Option<T> 进行运算之前必须将其转换为 T,那我们怎么去做转换?

Rust 提供了一个叫做 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,
    }
}

fn main() {}

看上面的例子。我们使用match,关键字匹配了coin、是一个枚举类型的值,这里和typescript中的switch作用有点相似。rust match 强大的地方在于这里可以的多种类型的值。

当 match 表达式执行时,它将结果值按顺序与每一个分支的模式相比较。如果模式匹配了这个值,这个模式相关联的代码将被执行。如果模式并不匹配这个值,

和typescript switch一样,如果分支代码较短的话通常不使用大括号

绑定值的模式

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

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

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

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
        }
    }
}

fn main() {
    value_in_cents(Coin::Quarter(UsState::Alaska));
}

如果调用 value_in_cents(Coin::Quarter(UsState::Alaska))coin 将是 Coin::Quarter(UsState::Alaska)。当将值与每个分支相比较时,没有分支会匹配,直到遇到 Coin::Quarter(state)。这时,state 绑定的将会是值 UsState::Alaska。接着就可以在 println! 表达式中使用这个绑定了,像这样就可以获取 Coin 枚举的 Quarter 成员中内部的州的值。

匹配 Option<T>

现在我们回到正题怎么将 Option<T>转换为 T

比如我们想要编写一个函数,它获取一个 Option<i32> ,如果其中含有一个值,将其加一。如果其中没有值,函数应该返回 None 值,而不尝试执行任何操作。

fn main() {
    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);
}

匹配 Some(T)

Some(5) 与 Some(i) 匹配吗?当然匹配!它们是相同的成员。i 绑定了 Some 中包含的值,所以 i 的值是 5。接着匹配分支的代码被执行,所以我们将 i 的值加一并返回一个含有值 6 的新 Some

match 第有一个分支就匹配到了,其他的分支将不再比较。

匹配是穷尽的

match 还有另一方面需要讨论:这些分支必须覆盖了所有的可能性。

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

这段代码就会报错,因为没有处理none的情况。

通配模式和 _ 占位符

穷尽匹配下,我们不可能每一个模式都去写出来,所以rust提供了other,我们不关心的就叫做其他。