聊一聊 Rust 的枚举

楔子

枚举类型,通常也被简称为枚举,它允许我们列举所有可能的值来定义一个类型。我们知道 C 里面也有枚举,但 Rust 的枚举要远比 C 的枚举更加强大。

下面我们来学习一下 Rust 的枚举。

枚举值

让我们来尝试处理一个实际的编码问题,并接着讨论在这种情形下,为什么使用枚举要比结构体更加合适。假设我们需要对 IP 地址进行处理,而目前有两种被广泛使用的 IP 地址标准:IPv4 和 IPv6。因为我们只需要处理这两种情形,因此可以将所有可能的值枚举出来,这也正是枚举名字的由来。

另外,一个 IP 地址要么是 IPv4 的,要么是 IPv6 的,没有办法同时满足两种标准。这个特性使得 IP 地址非常适合使用枚举结构来进行描述,因为枚举的值最终只能是这些值当中的一个。但无论是 IPv4 还是 IPv6,它们都属于基础的 IP 地址协议,所以当我们需要在代码中处理 IP 地址时,应该将它们视作同一种类型。

enum IpAddrKind {
    v4,
    v6
}

我们通过定义枚举 IpAddrKind 来表达这样的概念,声明该枚举需要列举出所有可能的 IP 地址种类:V4 和 V6,这也就是所谓的枚举变体(variant),或者说人话就是枚举里面的成员。现在,IpAddrKind 就是一个可以在代码中随处使用的自定义数据类型了,我们可以像下面的代码一样分别使用 IpAddrKind 中的两个成员来创建实例:

enum IpAddrKind {
    v4,
    v6
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;
}

需要注意的是,枚举的成员全都位于其标识符的命名空间中,并使用两个冒号来将标识符和成员分隔开来。由于 IpAddrKind::V4 和 IpAddrKind::V6 拥有相同的类型 IpAddrKind,所以我们可以定义一个接收 IpAddrKind 类型参数的函数来统一处理它们。

fn route(ip_type: IpAddrKind) {
    //...
}

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

除此之外,使用枚举还有很多优势。让我们继续考察这个 IP 地址类型,到目前为止,我们只能知道 IP 地址的种类,却还没有办法去存储实际的 IP 地址数据。不过刚刚学习了结构体,我们可以这么做。

enum IpAddrKind {
    V4,
    V6,
}

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

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

办法总比困难多,我们将枚举类型和一个字符串组合成一个结构体不就可以了吗,这是一个解决问题的办法,不过实际上,枚举允许我们直接将其关联的数据嵌入枚举成员内。我们可以使用枚举来更简捷地表达出上述概念,而不用将枚举集成至结构体中。

在新的 IpAddr 枚举定义中,V4 和 V6 两个成员都被关联上了一个 String 值:

enum IpAddr {
    V4(String),
    V6(String),
}

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

我们直接将数据附加到了枚举的每个成员中,这样便不需要额外地使用结构体。另外一个使用枚举代替结构体的优势在于:每个成员可以拥有不同类型和数量的关联数据。

还是以 IP 地址为例,IPv4 地址总是由 4 个 0~255 之间的整数部分组成。假如我们希望使用 4 个 u8 值来代表 V4 地址,并依然使用 String 值来代表 V6 地址,那么结构体就无法轻易实现这一目的了,而枚举则可以轻松地处理此类情形:

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

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

可以看到非常方便,然后继续来看另外一个关于枚举的例子,它的成员包含了各式各样的数据类型。

enum Message {
    // 定义了空结构体,并且是空的普通结构体
    // 如果将 {} 换成 (),那么就是空的元组结构体
    Quit {},
    // 普通结构体
    Move { x: i32, y: i32 },
    // 元组结构体
    Write(String),
    // 元组结构体
    ChangeColor(i32, i32, i32),
}  // 枚举里面嵌入结构体的时候需要省略 struct

fn main() {
    // x、y、z、w 都是 Message 类型
    let x = Message::Quit {};
    let y = Message::Move {x: 8, y:4};
    let z = Message::Write(String::from("hello"));
    let w = Message::ChangeColor(255, 11, 184);
}

和单独定义结构体不同,如果我们使用了不同的结构体,那么每个结构体都会拥有自己的类型,我们无法轻易定义一个能够统一处理这些类型数据的函数。而我们上面定义的 Message 枚举则不同,因为它是单独的一个类型,也就是说变量 x, y, z, w 都是 Message 类型。

因此 Rust 枚举非常强大,当我们需要处理的数据彼此独立、但类型又不同时,使用枚举再合适不过了。比如处理 Excel 的时候,每个单元格存储的数据可能是整数、浮点数、字符串,也就是类型是不同的,而且每个单元格之间也没啥关系,这个时候枚举是非常适合的。

enum Cell {
    Integer(i64),
    Float(f64),
    Text(String)
}

fn main() {
    // 每个单元格存储的数据的类型不同
    // 但是通过枚举,将它们都变成了 Cell 类型
    let cell1 = Cell::Integer(123);
    let cell2 = Cell::Float(3.14);
    let cell3 = Cell::Text(String::from("hello"));
}

枚举和结构体还有一点相似的地方在于:正如我们可以使用 impl 关键字为结构体定义方法一样,我们同样也可以给枚举定义方法。下面的代码在 Cell 枚举中实现了一个名为 call 的方法:

enum Cell {
    Integer(i64),
    Float(f64),
    Text(String)
}

impl Cell {
    fn call(&self) {
        println!("我是 call")
    }
}

fn main() {
    let cell1 = Cell::Integer(123);
    let cell2 = Cell::Float(3.14);
    let cell3 = Cell::Text(String::from("hello"));

    cell1.call();
    cell2.call();
    // 和结构体一样,枚举也可以像下面这么做
    Cell::call(&cell3);
    /*
    我是 call
    我是 call
    我是 call
    */
}

以上就是枚举的实现,然后标准库中也提供了一种非常常见并且大量使用的枚举:Option。

用于空值处理的 Option 枚举

在设计编程语言时往往会规划出各式各样的功能,但思考应该避免设计哪些功能也是一门非常重要的功课。Rust 并没有像许多其他语言一样支持空值,因为空值本身是一个值,但它的含义却是没有值。在设计有空值的语言中,一个变量往往处于这两种状态:空值或非空值。

Tony Hoare,空值的发明者,曾经在 2009 年的一次演讲 Null References: The Billion DollarMistake 中提到:这是一个价值数十亿美金的错误设计。当时我正在为一门面向对象语言中的引用设计一套全面的类型系统,我的目标是通过编译器自动检查来确保所有关于引用的操作都是百分之百安全的。但是我却没有抵挡住引入一个空引用概念的诱惑,仅仅是因为这样会比较容易去实现这套系统,这导致了无数的错误、漏洞和系统崩溃,并在之后的 40 多年中造成了价值数 10 亿美金的损失。

空值的问题在于,当你尝试像使用非空值那样使用空值时,就会触发某种程度上的错误。因为空或非空的属性被广泛散布在程序中,所以你很难避免引起类似的问题。但是不管怎么说,空值本身所尝试表达的概念仍然是有意义的:它代表了因为某种原因而变为无效或缺失的值。

所以引发这些问题的关键并不是概念本身,而是那些具体的实现措施。因此 Rust 中虽然没有空值,但却提供了一个拥有类似概念的枚举,我们可以用它来标识一个值无效或缺失。这个枚举就是 Option<T>,它在标准库中被定义为如下所示的样子:

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

由于 Option<T> 非常常见且很有用,所以它被包含在了预导入模块中,这意味着我们不需要显式地将它引入作用域。另外它的成员也是这样的:我们可以在不加 Option:: 前缀的情况下直接使用 Some 或 None,但 Option<T> 枚举依然只是一个普通的枚举类型,Some(T) 和 None 也依然只是 Option<T> 类型的成员。

然后里面的语法 <T> 是一个我们还没有学到的 Rust 功能,它是一个泛型参数,我们将会在后续讨论关于泛型的更多细节。现在只需要知道 <T> 意味着 Option 枚举中的 Some 成员可以包含任意类型的数据,或者说 Option<T> 表示变量类型为 T,但允许为空值,并且 T 可以代表任意类型。下面是一些使用 Option 包含数值类型和字符串类型的示例:

fn main() {
    let some_number = Some(5);
    let sum_string = Some(String::from("hello world"));

    let absent_number: Option<i32> = None;
}

比如一个整数、但可以为空值,那么类型就是 Option<i32>;字符串、但可以为空值,那么类型就是 Option<String>。然后我们在赋值的时候就可以使用 Some,比如 Some(5),编译器看到 Some 就知道这是一个 Option<T>,看到 5 就知道这是一个 i32,结合起来会将变量类型设置为 Option<i32>

使用 Some 则是我们已经想好变量的值了,而使用 None 则是我们还不知道要给变量赋什么值,但只知道它允许为空,所以先设置为 None。并且设置为 None 的时候我们需要显式地指明变量的类型,否则光凭一个 None 的话,Rust 无法推断。

总结:当我们有了一个 Some 值时,我们就可以确定值是存在的;而当我们有了一个 None 值时,我们就知道当前并不存在一个有效的值,但它们都是 Option<T> 类型。那么问题来了,这看上去与空值没有什么差别,那为什么 Option<T> 的设计就比空值好呢?

简单来讲,因为 Option<T> 和 T(这里的 T 可以是任意类型)是不同的类型,所以编译器不会允许我们像使用普通值一样去使用 Option<T>。例如下面的代码在尝试将 i8 与 Option<i8> 相加时无法通过编译:

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

运行这段代码会编译错误,提示信息:no implementation for i8 + Option<i8>。这段错误提示信息指出了 Rust 无法理解 i8 和 Option<T> 相加的行为,因为它们拥有不同的类型。

如果我们在 Rust 中拥有一个 i8 类型的值,编译器是能够确保我们所持有的值是有效的,可以充满信心地去使用它而无须在使用前进行空值检查。而只有当我们持有的类型是 Option<i8>(将 i8 换成其它类型同理)时,我们才必须要考虑值不存在的情况,同时编译器会迫使我们在使用值之前正确地做出处理操作。

也就是说 Some(5) 虽然是有值的,但它的类型是 Option<T>,而该类型还包含了 None,如果为 None 则是无法相加的,所以 Rust 会迫使我们进行处理。换句话说,为了使用 Option<T> 中可能存在的 T,我们必须要将它转换为 T。一般而言,这能帮助我们避免使用空值时最常见的一个问题:假设某个值存在,实际上却为空。

其实说白了,Rust 就是将空值和数据类型结合起来了。比如类型 T,如果该类型的值允许为空,那么就变成 Option。那么当值不为空时就用 Some(值)、为空时就用 None,并且都是 Option 类型。

不过当我们持有了一个 Option<T> 类型的 Some 值时,应该怎样将其中的 T 值取出来使用呢?

总的来说,为了使用一个 Option<T> 值,我们必须要编写处理每个成员的代码,某些代码只会在持有 Some(T) 值时运行,它们可以使用成员中存储的 T;而另外一些代码则只会在持有 None 值时运行,这些代码将没有可用的 T 值。

match 表达式就是这么一个可以用来处理枚举的控制流结构:它允许我们基于枚举拥有的成员来决定运行的代码分支,并允许代码通过匹配值来获取成员内的数据。

控制流运算符 match

Rust 中有一个异常强大的控制流运算符:match,它允许将一个值与一系列的模式相比较,并根据匹配的模式执行相应代码。模式可由字面量、变量名、通配符和许多其它东西组成;后面会详细介绍所有不同种类的模式及它们的工作机制。match 的能力不仅来自模式丰富的表达力,也来自编译器的安全检查,它确保了所有可能的情况都会得到处理。

你可以将 match 表达式想象成一台硬币分类机:硬币滑入有着不同大小孔洞的轨道,并且掉入第一个符合大小的孔洞。同样,值也会依次通过 match 中的模式,并且在遇到第一个符合的模式时进入相关联的代码块,并在执行过程中被代码所使用。

enum Operator {
    LT,
    LE,
    EQ,
    NE,
    GT,
    GE,
}

fn value_in_operator(op: Operator) -> u32 {
    match op {
        Operator::LT => 0,
        Operator::LE => 1,
        Operator::EQ => 2,
        Operator::NE => 3,
        Operator::GT => 4,
        Operator::GE => 5,
    }
}

fn main() {
    println!("{}", value_in_operator(Operator::EQ));  // 2 
    println!("{}", value_in_operator(Operator::GT));  // 4
}

让我们先来逐步分析一下 value_in_operator 函数中的 match 块,首先我们使用的 match 关键字后面会跟随一个表达式,也就是本例中的 op。初看上去,这与 if 表达式的使用十分相似,但这里有个巨大的区别:在 if 语句中,表达式需要返回一个布尔值,而这里的表达式则可以返回任何类型,例子中 op 的类型正是我们在首行定义的 Operator 枚举。

接下来是 match 的分支,一个分支由模式和它所关联的代码组成。第一个分支采用了值 Operator::LT 作为模式,并紧跟着一个 => 运算符用于将模式和代码区分开来。这里的代码简单地返回了值 1,不同分支之间使用了逗号分隔。

当这个 match 表达式执行时,它会将产生的结果值依次与每个分支中的模式相比较。假如模式匹配成功,则与该模式相关联的代码就会被继续执行;而假如模式匹配失败,则会继续匹配下一个分支,就像上面提到过的硬币分类机一样。分支可以有任意多个,在上述示例中,match 有 6 个分支。

每个分支所关联的代码同时也是一个表达式,而这个表达式运行所得到的结果值,同时也会被作为整个 match 表达式的结果返回。如果分支代码足够短,就像上述示例中仅返回一个值的话,那么通常不需要使用花括号。但如果我们想要在一个匹配分支中包含多行代码,那么就可以使用花括号将它们包起来。举个例子:

fn value_in_operator(op: Operator) -> u32 {
    match op {
        Operator::LT => {
            println!("这是小于等于");
            0
        },
        Operator::LE => 1,
        Operator::EQ => 2,
        Operator::NE => 3,
        Operator::GT => 4,
        Operator::GE => 5,
    }
}

需要注意的是,使用 match 的时候,所有可能出现的情况都需要被处理。比如上面的 op,它是 Operator 枚举类型,该枚举有 6 个成员,那么 match 里面就应该有 6 个分支。

匹配值

匹配分支另外一个有趣的地方在于它们可以绑定被匹配对象的部分值,而这也正是我们用于从枚举变体中提取值的方法。上面的 Operator 枚举是不带数据的,如果带数据了该怎么办呢?以我们之前的 IP 地址为例。

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

fn get_addr(ip: IpAddr) {
    match ip {
        // 匹配的时候不能只写 IpAddr::V4
        // 因为该成员带有值,所以这里也应该带上 4 个变量
        // 如果是 IpAddr::V4,那么会匹配成功,值就会赋给这里的 a、b、c、d
        // 并且这里我们无需声明、也不能声明 a、b、c、d 的类型
        // 因为定义枚举的时候里面的类型就已经写死了
        IpAddr::V4(a, b, c, d) => {
            println!("v4:{} {} {} {}", a, b, c, d);
        }
        IpAddr::V6(s) => {
            println!("v6:{}", s);
        }
    }
}

fn main() {
    // 所有的值都是 IpAddr 类型,但是成员不同、所以会走不同的分支
    get_addr(IpAddr::V4(127, 0, 0, 1));  // v4:127 0 0 1
    get_addr(IpAddr::V4(196, 168, 0, 1));  // v4:196 168 0 1
    get_addr(IpAddr::V6(String::from("::1")));  // v6:::1
}

然后我们就可以处理之前的 Option<T> 了,做法也很简单:

fn get_number(x: Option<i32>) -> Option<i32> {
    match x {
        // 如果是 Some(值),那么就会走这个分支,并且值也会传递给这里的 i
        // 可能有人好奇,Some(i) + Some(30) 是否可以
        // 答案是不行,因为两个 Option<i32> 之间不可以相加
        Some(i) => {
            if i > 100 {
                Some(i + 30)
            } else if i > 0 {
                Some(i * 2)
            } else {
                Some(0)
            }
        }
        // None 的话则还是返回 None
        None => None,
    } // 此处不可以加分号,因为 match 整体要作为表达式返回
}

fn main() {
    let x = Some(150);
    let y = Some(80);
    let z: Option<i32> = None;
    println!("{:?}", get_number(x)); // Some(180)
    println!("{:?}", get_number(y)); // Some(160)
    println!("{:?}", get_number(z)); // None
}

将 match 与枚举相结合在许多情形下都是非常有用的,后续会在 Rust 代码中看到许多类似的套路:使用 match 来匹配枚举值,并将其中的值绑定到某个变量上,接着根据这个值执行相应的代码。这初看起来可能会有些复杂,不过一旦你习惯了它的用法,就会希望在所有的语言中都有这个特性,这一特性一直以来都是社区用户的最爱。

_ 通配符

先看个例子:

fn f(x: Option<i32>) {
    match x {
        Some(i) => {
            if i == 666 {
                println!("bingo")
            }
        },
        None => ()
    }
}

如果传过来的是 Some(666),那么打印 bingo,否则什么也不做。注意:None 这个分支要返回空元组,因为所有分支的最后一个表达式返回的值都要是同一种类型。但是上面这种做法有些麻烦,可以简化一下:

fn f(x: Option<i32>) {
    match x {
        Some(666) => println!("bingo"),
        // _ 分支,就类似于 C 里面 switch 语句中的 default
        // 如果上面所有的分支都没有走,那么会走这里的默认分支
        _ => ()  
    }
}

Some(i) 里面的 i 可以是任意值,所以 Some(i) 相当于包含了所有可能出现的情况,只要不为 None 就都会走这个分支,然后值会传递给 i,再对 i 进行操作。而 Some(666) 则表示只有当 Some(666) 的时候才会走这个分支,如果为 None 或者 Some 里面的值不为 666 时,那么走默认分支。所以此时需要有 _ 分支,因为 match 要处理所有可能出现的情况。

fn f(x: Option<i32>) {
    match x {
        Some(666) => println!("value = 666"),
        Some(i) => println!("value != 666"),        
        None => ()
    }
}

Some(666) 里面是一个常量,因此它只能处理 Some(666) 的情况;而 Some(i) 里面的 i 不是一个具体的常量,所以它包含了包括 666 在内的所有可能出现的值(这里是 i32)。

但注意 Some(666) 应该放在 Some(i) 的上面,否则永远不会匹配到。另外,既然有了 Some(i),那么也就不需要有默认分支了,因为 i 是一个变量,它已经包含了所有的情况,所以此时只剩 None 这一个分支。当然把 None 换成 _ 也可以,只不过此时的默认分支只可能匹配 None。

另外值得一提的是,非枚举类型也可以使用 match,比如整型。

fn get_num(num: i32) -> i32 {
    match num {
        // num 具体是多少会传递给这里的 n
        // 因为 n 是一个变量,它可以表示 i32 所有的值
        // 所以此时这一个分支就足够了
        n => {
          // 如果 n 为 1、5、33、78、106,那么返回 n + n;否则直接返回 n
          if n == 1 || n == 5 || n == 33 || n == 78 || n == 106 {
              n + n
          } else {
              n
          }
        }
    }
}

或者换一种方式:

fn get_num(num: i32) -> i32 {
    match num {
        1 => 1 + 1,
        5 => 5 + 5,
        33 => 33 + 33,
        78 => 78 + 78,
        106 => 106 + 106,
        // 如果是这种方式的话也行
        // 但上面只包含了 num 为 1、5、33、78、106 的情况
        // 而 match 要求每一个分支都要处理,所以此时需要默认分支
        // 因为我们不可能把每一个数字都写一遍
        _ => num  // 当 num 不为 1、5、33、78、106 时,执行此分支
        // 或者把最后一个分支换成 n => n 也是可以的
    }
}

不过说实话,我们使用 match 基本上都是用来匹配枚举类型,像整型这些使用 if else 不香吗?

简单控制流 if let

if let 能让我们通过一种不那么烦琐的语法结合使用 if 与 let,并处理那些只用关心某一种匹配而忽略其他匹配的情况。回到之前的例子:

fn f(x: Option<i32>) {
    match x {
        Some(666) => println!("bingo"),
        _ => ()
    }
}

这个例子已经够简单了,但我们还可以更进一步简化:

fn f(x: Option<i32>) {
    // 等价于 if x == Some(666)
    if let x = Some(666) {
        println!("bingo");
    }
}

所以我们可以将 if let 视作 match 的语法糖,它只在值满足某一特定模式时运行代码,而忽略其他所有的可能性。因此使用 if let 意味着可以编写更少的代码,使用更少的缩进,使用更少的模板代码。但同时也放弃了 match 所附带的穷尽性检查,因为 match 需要处理所有可能出现的分支,否则编译不通过。究竟应该使用 match 还是 if let 取决于你当时所处的环境,这是一个在代码简捷性与穷尽性检查之间进行取舍的过程。

不过问题来了,我们记得非枚举也可以使用 match,而 if let 又是 match 的语法糖,那么非枚举类型是不是同样也可以使用 if let 呢?

fn f(x: i32) {
    if let x = 666 {
        println!("bingo");
    }
}

答案是可以的,if let x = 666 和 if x == 666 作用是相似的,并且都可以搭配 else 语句。如果你在编写程序的过程中,觉得在某些情形下使用 match 会过分烦琐,要记得在 Rust 工具箱中还有 if let 的存在。

小结

前面介绍的结构体可以让我们基于特定领域创建有意义的自定义类型,通过使用结构体可以将相关联的数据组合起来,并为每条数据赋予名字,从而使代码变得更加清晰。方法可以让我们为结构体实例指定行为,而关联函数则可以将那些不需要实例的特定功能放置到结构体的命名空间中。

而枚举可以包含一系列可被列举的值,在面对数据类型单一、但不同数据的类型可能不同的场景时非常有用。同时我们也展示了如何使用标准库中的 Option<T> 类型,以及它会如何帮助我们利用类型系统去避免错误。

最后当枚举中包含数据时,我们可以使用 match 或 if let 来抽取并使用这些值,具体应该使用哪个工具则取决于我们想要处理的情形有多少。

posted @ 2023-10-12 16:44  古明地盆  阅读(424)  评论(0编辑  收藏  举报