Rust 中的枚举和模式匹配 - serokell


在 Rust 中创建自定义数据类型有两种方法:结构和枚举。
与结构相比,枚举构造一个具有多个变种、变体而不是多个字段的类型。

虽然结构是几乎所有编程语言的一部分,但枚举并不是那么主流,并且主要出现在 Haskell 或 OCaml 等静态类型的函数式语言中。
但是,正如我们将看到的,它们非常适合使用类型进行领域建模。

本文将向您展示如何在 Rust 中使用枚举。在文章的最后,你会知道这些问题的答案:

  • 如何定义和实例化枚举?
  • 何时使用枚举而不是结构?
  • 什么是模式匹配,为什么它如此有用?
  • Option和枚举是什么Result,以及如何使用它们?

Rust 中的枚举是什么?
Enums (枚举 enumerations简写) 是在 Rust 中创建复合数据类型的一种方式。它们让我们枚举一个类型的多个可能变种。
例如,我们可以使用枚举来重新创建Bool具有两个变种的数据类型:True和False.

//   [1]  
enum Bool {
//  [2]
    True,
    False,
}

// [1]: The name of the data type.
// [2]: The variants of the data type. 

我们可以通过使用两种变种之一构造它来获得这种类型的值。

let is_true = Bool::True;
let is_false = Bool::False;

枚举变体就像结构:它们可以包含可以未命名或命名的字段。在下面的示例中,Alive变种包含一个未命名的有符号 8 位整数。

enum HealthBar {
    Alive(i8),
    Dead,
}

let is_alive = HealthBar::Alive(100);
let is_dead = HealthBar::Dead;

如果我们想表示该字段代表life点,我们可以使用命名的“life”字段:

enum HealthBar {
    Alive{life: i8},
    Dead,
}

let is_alive = HealthBar::Alive{life: 100};
let is_dead = HealthBar::Dead;

枚举与结构
一个结构有多个字段。相反,一个枚举有多个变体变种。
但是,虽然结构类型的具体值有多个字段,但枚举类型的具体值只是一种变体。

枚举使您能够构建更清晰的类型并减少数据可以采用的非法状态的数量。
让我们看一个例子。我们想为游戏中的角色建模。
它可以有三种状态:

  • Alive。在这种情况下,它具有生命值。
  • Knocked out。在这种情况下,它有生命值和需要等待恢复意识的回合计数器。
  • Dead。在这种情况下,它不需要任何额外的字段。

仅使用结构对其进行建模非常尴尬。但我们可以尝试以下方法:

struct Player {
    state: String,
    life: i8,
    turns_to_wait: i8,
}

在这里,我们将状态值存储在一个字符串state中。如果玩家“死Dead”了,他们的生命life应该是0;如果他们“被击倒Knocked out”,他们应该有一些回合turns_to_wait等到他们可以行动。

let dead_player = Player {
    state: "dead".to_string(),
    life: 0,
    turns_to_wait: 0,
};

let knocked_out_player = Player {
    state:
"knocked out".to_string(),
    life: 50,
    turns_to_wait: 3,
};

这行得通,但它非常不稳定并且模糊了类型中的状态。您需要阅读代码或文档以了解可用状态,这会降低类型作为文档的价值。您还可以在代码中使用任何类型的字符串,例如“daed”或“knacked”,而不会出现编译器错误。
使用枚举,我们可以以可读的方式列出所有可能的状态。

enum Player {
    Alive{life: i8},
    KnockedOut{life: i8, turns_to_wait: i8},
    Dead,
}

let dead_player = Player::Dead;
let knocked_out_player = Player::KnockedOut { 
    life: 50, 
    turns_to_wait: 3, 
};

模式匹配
枚举类型可以有多个变体之一。因此,如果一个函数需要一个枚举,我们需要一种方法来根据它将遇到的数据变体来调整函数的行为。
这是通过模式匹配完成的。如果变体构造一个类型的值,模式匹配会解构它们。
回想一下我们的Bool数据类型:

enum Bool {
    True,
    False,
}

假设我们要创建一个调用的函数,该函数neg返回与我们提供的布尔值相反的值。

这就是我们在 Rust 中使用模式匹配的方式。

fn neg(value: Bool) -> Bool {
//        [1]
    match value {
//      [2]           [3]
        Bool::True => Bool::False,
        Bool::False => Bool::True,
    }
}

// [1]: The value we’re pattern matching on.
// [2]: Pattern we want to match. 
// [3]: Result.

match 语句的结果可以是表达式或语句。

fn print_value(value: Bool) {
    match value {
        Bool::True => println!("Clearly true!"),
        Bool::False => println!(
"Unfortunately false!"),
    }
}

如果您希望结果不仅仅是一个语句或表达式,您可以使用花括号。

fn print_and_return_value(value: Bool) -> Bool {
    match value {
        Bool::True => {
            println!("Clearly true!");
            Bool::True
        }
        Bool::False => {
            println!(
"Unfortunately false!");
            Bool::False
        }
    }
}

虽然这个基本案例非常类似于 case/switch 语句,但模式匹配可以做的更多。我们可以在选择要执行的代码路径时使用模式匹配来分配值。这使我们能够轻松地处理嵌套值。

fn take_five(player: Player) -> Player {
    match player {
        Player::Alive { life: life } => Player::Alive { life: life - 5 },
        Player::KnockedOut {
            life: life,
            turns_to_wait: turns_to_wait,
        } => Player::KnockedOut {
            life: life - 5,
            turns_to_wait: turns_to_wait,
        },
        Player::Dead => Player::Dead,
    }
}

为了使上面的代码例子更加简单,我们可以使用字段init速记(field init shorthand.)。它可以让我们在模式匹配和结构构建中用life:life代替life。

fn take_five(player: Player) -> Player {
    match player {
        Player::Alive { life } => Player::Alive { life: (life - 5) },
        Player::KnockedOut {
            life,
            turns_to_wait,
        } => Player::KnockedOut {
            life: (life - 5),
            turns_to_wait,
        },
        Player::Dead => Player::Dead,
    }
}

上面的代码有一个问题:它没有考虑到如果玩家的健康状况降低到0就会死亡的事实。我们可以在匹配防护和通配符的帮助下解决这个问题。

匹配守卫使你能够在模式中添加如果条件。所以我们可以让模式在Player::Alive的生命值大于5的情况下进行匹配,比如说。

如果你在模式中放入通配符(用下划线标记),它将匹配任何东西。它可以用在匹配语句的末尾来处理所有剩余的情况。

fn take_five_advanced(player: Player) -> Player {
    match player {
        Player::Alive { life } if life > 5 => Player::Alive { life: (life - 5) },
        Player::KnockedOut {
            life,
            turns_to_wait,
        } if life > 5 => Player::KnockedOut {
            life: (life - 5),
            turns_to_wait,
        },
        _ => Player::Dead,
    }
}

匹配语句必须是详尽的--它需要涵盖所有可能的值。如果你没有涵盖一些选项,程序将无法编译。

例如,如果我们不在语句末尾使用下划线,就会有两种状态没有被涵盖。活着和少于5点生命值的KnockedOut。编译器可以检测到这一点并拒绝该代码。

//error[E0004]: non-exhaustive patterns: `Alive { .. }` and `KnockedOut { .. }` not covered

fn take_five_advanced(player: Player) -> Player {
    match player {
        Player::Alive { life } if life > 5 => Player::Alive { life: (life - 5) },
        Player::KnockedOut {
            life,
            turns_to_wait,
        } if life > 5 => Player::KnockedOut {
            life: (life - 5),
            turns_to_wait,
        },
        Player::Dead => Player::Dead,
    }
}

枚举方法和特征trait
像结构一样,枚举允许创建关联的方法和实现特征。它们是清理代码和减少样板代码的强大工具。

定义枚举的方法
看看我们之前实现的否定函数。

fn neg(value: Bool) -> Bool {
  match value {
    Bool::True => Bool::False,
    Bool::False => Bool::True,
  }
}

最好让它成为一种方法,以便我们可以通过点符号访问它。

let is_true = Bool::True;
let not_true = is_true.neg();

为此,我们需要为 Bool 创建一个实现块。在其中,我们使用self关键字来指代Bool手头的值。Self代表我们正在为其编写实现的类型。

impl Bool {
  fn neg(self) -> Self {
    match self {
      Self::True => Self::False,
      Self::False => Self::True,
    }
  }
}

为枚举定义特征trait
像结构一样,枚举也可以具有特征trait——Rust 的接口版本,可以在类型之间启用通用功能。

您可以使用属性派生出常见的特征,例如Debug、Clone和。Eqderive

#[derive(Debug, Eq, PartialEq)]
enum Bool {
  True,
  False,
}

例如,在导出Debugand和PartialEq之后,我们现在可以打印和比较 的值Bool。

let is_true = Bool::True;
let is_false = Bool::False;
println!("{:?}", is_true); // Prints “True”. 
let are_same = is_true == is_false;
// false 

也可以创建自己的自定义特征和实现。

选项option和结果Result枚举
在Rust中你会经常遇到两个枚举,一个是Option,Rust对null的安全替代,另一个是Result,Rust对异常的安全替代。

let impossible = 4/0;

事实上,Rust不会让你编译一行除以0的代码。但你仍然可以用其他多种方式来做。
它们可以用在哪里呢?嗯,有时一个函数不能为给定的输入返回一个结果。举个简单的例子,你不能用一个数字除以0。

fn main() {
  println!("Choose what to divide 4 with!");
  let mut input = String::new();
  std::io::stdin().read_line(&mut input).unwrap();
  let divisor: i32 = input.trim().parse().unwrap();

  let result = 4 / divisor;
}

但是如果我们不想让程序在遇到除以零时崩溃呢?我们可以让函数返回 Option 类型,并在代码中进一步处理这个问题。

Option有两种变体。无None或有Some。第一个表示没有输出--你通常会用null或nil来表示。Some可以包裹任何类型,并表示该函数已经成功返回了一个值。

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

例如,我们可以用Option包住除法,这样当我们除以0时它就不会panic。

fn safe_division(a: i32, b: i32) -> (Option<i32>) {
    match b {
        0 => None,
        _ => Some(a / b),
    }
}

let possible = safe_division(4, 0); // None

结果类似,但它返回遇到的错误,而不是返回None无:

enum Result<T, E> {
   Ok(T),
   Err(E),
}

下面是safe_division与结果的关系:

#[derive (Debug, Clone)]
struct DivideByZero; // custom error type

fn safe_division_result(a: i32, b: i32) -> (Result<i32, DivideByZero>) {
    match b {
        0 => Err(DivideByZero),
        _ => Ok(a / b),
    }
}

let also_possible = safe_division_result(4, 0);
// Err(DivideByZero)

正如你所看到的,我们为这个错误创建了一个自定义的错误类型。可以使用字符串来表示错误,但不建议这样做。

更多Option and Result methods点击标题