Rust 学习笔记:枚举与模式匹配
- Rust 学习笔记:枚举与模式匹配
- 定义枚举(Enum)
- 枚举变量
- Option 枚举及其相对于 NULL 的优势
- match 和枚举
- 与 Option\<T\> 匹配
- match 应该是详尽的
- Catch-all 模式和 _ 占位符
- 使用 if let 和 let else 简化控制流
- let else 的高阶用法
Rust 学习笔记:枚举与模式匹配
在本文中,首先,我们将定义和使用枚举。接下来,我们将探讨一个特别有用的枚举,称为 Option。然后,我们将了解 match 表达式中的模式匹配。最后,我们将介绍 if let 构造。
定义枚举(Enum)
结构体提供了一种将相关字段和数据分组在一起的方法,而枚举则提供了一种说明一个值是一组可能值中的一个的方法。
任何 IP 地址都可以是 IPv4 或者 IPv6,但不能同时是这两个地址。IP 地址的这个属性使得枚举数据结构非常合适,因为枚举值只能是它的一个变体。
定义 IpAddrKind 枚举:
enum IpAddrKind {V4,V6,
}
IpAddrKind 是一个自定义数据类型。
枚举变量
我们可以像这样创建 IpAddrKind 的两个变体的实例:
let four = IpAddrKind::V4;let six = IpAddrKind::V6;
注意,枚举的变体位于其标识符下的命名空间中,我们使用双冒号分隔两者。这是有用的,因为现在两个值 IpAddrKind::V4 和 IpAddrKind::V6 都是同一类型:IpAddrKind。
我们还可以定义一个接受任意 IpAddrKind 的函数:
fn route(ip_kind: IpAddrKind) {}
我们可以用任意一个变量调用这个函数:
route(IpAddrKind::V4);route(IpAddrKind::V6);
枚举可以作为结构体的字段:
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"),};
但是,仅使用枚举表示相同的概念更为简洁:我们可以将数据直接放入每个枚举变体中,而不是在结构体中使用枚举。这个枚举的新定义表明,V4 和 V6 的实例将具有相关的 String 值:
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 类型的实例。
使用 enum 而不是 struct 还有另一个好处:每个变量可以有不同的关联数据类型和数量。如果我们想要将 V4 地址存储为四个 u8 值,但仍然将 V6 地址表示为一个 String 值,那么我们将无法使用结构体。枚举可以轻松处理这种情况:
enum IpAddr {V4(u8, u8, u8, u8),V6(String),}let home = 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),
}
注意,即使标准库包含了 IpAddr 的定义,我们仍然可以创建和使用我们自己的定义而不会产生冲突,因为我们没有将标准库的定义引入我们的作用域。
我们也可以使用 impl 在枚举上定义方法:
impl Message {fn call(&self) {// method body would be defined here}}let m = Message::Write(String::from("hello"));m.call();
方法的主体会使用 self 来获取我们调用该方法的值。在这个例子中,我们创建了一个变量 m,它的值是 Message::Write(String::from(“hello”)),这就是 m.call() 运行时调用方法体中的 self 的值。
Option 枚举及其相对于 NULL 的优势
Option 是标准库定义的另一个枚举。Option 类型编码了一种非常常见的场景,在这种场景中,值可以是什么东西,也可以是空(什么都没有)。
Rust没有许多其他语言所具有的 null 特性。null 是一个表示没有值的值。在带有 null 的语言中,变量总是处于两种状态之一:null 或非 null。
空值的问题是,如果尝试将空值用作非空值,将得到某种错误。然而,null 试图表达的概念仍然是有用的:null 是由于某种原因当前无效或不存在的值。
问题不在于概念,而在于具体的实现。因此,Rust 没有空值,但它有一个枚举,可以编码值存在或不存在的概念。该 enum 为 Option<T>,由标准库定义如下:
enum Option<T> {None,Some(T),
}
可以直接使用 Some 和 None,而不使用 Option:: 前缀。Some(T) 和 None 是 Option<T> 类型的变体。
<T> 语法是 Rust 的一个我们还没有讨论的特性。它是一个泛型类型参数,意味着 Option 枚举的某些变体可以保存任何类型的数据。
示例:
let some_number = Some(5);let some_char = Some('e');let absent_number: Option<i32> = None;
some_number 的类型为 Option<i32>,some_char 的类型是 Option<char>。对于 None,Rust 要求必须提供具体的 Option 类型。
当我们有一个 None 值时,在某种意义上它和 null 的意思是一样的:我们没有一个有效值。那么为什么 Option<T> 比 null 好呢?因为 Option<T> 和 T (T 可以是任何类型)是不同的类型。
例如,这段代码无法编译,因为它试图将 i8 添加到 Option<i8>:
let x: i8 = 5;let y: Option<i8> = Some(5);let sum = x + y;
Rust 不理解如何添加 i8 和 Option<i8>,因为它们是不同的类型。
须先将 Option<T> 转换为 T,然后才能对其执行 T 操作。一般来说,这有助于抓住 null 最常见的问题之一:假设某些东西不是空的,而实际上是空的。为了拥有一个可能为空的值,必须显式地将该值的类型设置为 Option<T>。然后,当使用该值时,需要显式地处理该值为空的情况。只要值的类型不是 Option<T>,就可以放心地假设该值不为空。
这是 Rust 经过深思熟虑的设计决策,目的是限制 null 的普遍性,提高 Rust 代码的安全性。
match 和枚举
match 表达式是一个控制流结构,当与枚举一起使用时,它就是这样做的:它将运行不同的代码,这取决于它拥有的枚举的哪个变体,并且该代码可以使用匹配值中的数据。
我们可以编写一个函数,它接受一枚未知的美国硬币,并以与计数机类似的方式确定它是哪一枚硬币,并返回其以美分为单位的值:
enum Coin {Penny,Nickel,Dime,Quarter,
}fn value_in_cents(coin: Coin) -> u8 {match coin {Coin::Penny => {println!("Lucky penny!");1}Coin::Nickel => 5,Coin::Dime => 10,Coin::Quarter => 25,}
}
当匹配表达式执行时,它按顺序将结果值与每个模式进行比较。如果模式匹配该值,则执行与该模式关联的代码。如果该模式与值不匹配,则继续执行。
match 的的另一个有用特性是:它们可以绑定到与模式匹配的值部分。这就是从枚举变量中提取值的方法。
作为一个例子,让我们修改一个枚举变量,使其包含数据。
#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {Alabama,Alaska,// --snip--
}enum Coin {Penny,Nickel,Dime,Quarter(UsState),
}
在这段代码的匹配表达式中,我们将一个名为 state 的变量添加到匹配变量 Coin::Quarter 值的模式中。当一个 Coin::Quarter 匹配时,状态变量将绑定到该 Quarter 的状态值。然后我们可以在代码中使用 state,如下所示:
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}}
}
如果我们调用 value_in_cents(Coin::Quarter(UsState::Alaska)),Coin 将是 Coin::Quarter(UsState::Alaska)。当我们将该值与每个匹配进行比较时,在到达 Coin::Quarter(state) 之前,它们都不匹配。此时,州的绑定将是值 UsState::Alaska。然后我们可以使用 println! 打印该值。
与 Option<T> 匹配
我们还可以使用 match 来处理 Option<T>。
编写一个函数,它接受 Option<i32>,如果里面有一个值,则将该值加 1。如果里面没有值,函数应该返回 None 值,并且不尝试执行任何操作。
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);
match 应该是详尽的
match 中的模式必须涵盖所有可能性。
考虑一下这个版本的 plus_one 函数:
fn plus_one(x: Option<i32>) -> Option<i32> {match x {Some(i) => Some(i + 1),}}
报错:error[E0004]: non-exhaustive patterns: `None` not covered。
我们没有处理 None 的情况,所以无法编译。
Rust 中的匹配是详尽的:为了使代码有效,我们必须穷尽每一种可能性。特别是在 Option<T> 的情况下,当 Rust 防止我们忘记显式处理 None 情况时,它保护我们避免在可能为 null 的情况下假设我们有一个值。
Catch-all 模式和 _ 占位符
使用枚举,我们还可以对一些特定的值采取特殊的操作,但对所有其他值采取默认操作。
let dice_roll = 9;match dice_roll {3 => add_fancy_hat(),7 => remove_fancy_hat(),other => move_player(other),}fn add_fancy_hat() {}fn remove_fancy_hat() {}fn move_player(num_spaces: u8) {}
最后一个模式 other 将匹配所有没有明确列出的值,other 必须放在最后。
当我们想要捕获所有值,但又不想在捕获所有值的模式中使用值时可以使用 _
。这是一个特殊的模式,它匹配任何值,并且不绑定到该值。这告诉 Rust 我们不打算使用这个值,所以 Rust 不会警告我们一个未使用的变量。
let dice_roll = 9;match dice_roll {3 => add_fancy_hat(),7 => remove_fancy_hat(),_ => reroll(),}fn add_fancy_hat() {}fn remove_fancy_hat() {}fn reroll() {}
这个例子也满足穷竭性要求,因为我们显式地忽略了所有的其他值。
还可以有另外一种写法:
let dice_roll = 9;match dice_roll {3 => add_fancy_hat(),7 => remove_fancy_hat(),_ => (),}fn add_fancy_hat() {}fn remove_fancy_hat() {}
使用 () 作为与 _
匹配时的动作。这将告诉 Rust:不使用任何不匹配先前模式的值,并且不想在这种情况下运行任何代码。
使用 if let 和 let else 简化控制流
if let 语法以一种更简洁的方式来处理匹配一个模式的值,同时忽略其他模式。
let config_max = Some(3u8);match config_max {Some(max) => println!("The maximum is configured to be {max}"),_ => (),}
如果值是 Some,我们通过将值绑定到模式中的变量 max 来打印出 Some 变量中的值。不对 None 值做任何事情。
每次都要写 _ => () 确实很烦,可以用 if let 语法进行简化:
let config_max = Some(3u8);if let Some(max) = config_max {println!("The maximum is configured to be {max}");}
if let 的语法接受一个模式和一个用等号分隔的表达式。它的工作方式与匹配相同,将表达式提供给匹配,而模式是它的第一个臂。在本例中,模式是 Some(max),并且 max 绑定到 Some 内部的值。if let 块中的代码仅在值与模式匹配时运行。
使用 if let 意味着更少的输入、更少的缩进和更少的样板代码。但是,失去了 match 强制执行的详尽检查。
换句话说,可以将 if let 视为匹配的语法糖,当值匹配一个模式时运行代码,然后忽略所有其他值。
我们可以在 if 语句中包含 else 语句,该代码块相当于 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;}
let else 的高阶用法
let else 语法在左侧接受一个模式,在右侧接受一个表达式(变量),这与 if let 非常相似,但它没有 if 分支,只有 else 分支。如果模式匹配,它将在外部作用域中绑定来自模式的值。如果模式不匹配,程序将进入 else。
一种常见的模式是当值存在时执行一些计算,否则返回默认值。
fn describe_state_quarter(coin: Coin) -> Option<String> {if let Coin::Quarter(state) = coin {if state.existed_in(1900) {Some(format!("{state:?} is pretty old, for America!"))} else {Some(format!("{state:?} is relatively new."))}} else {None}
}
我们还可以利用表达式生成的值来从 if let 中生成状态或提前返回。
用 if let 来写:
fn describe_state_quarter(coin: Coin) -> Option<String> {let state = if let Coin::Quarter(state) = coin {state} else {return None;};if state.existed_in(1900) {Some(format!("{state:?} is pretty old, for America!"))} else {Some(format!("{state:?} is relatively new."))}
}
用 let else 来写,会更简单:
fn describe_state_quarter(coin: Coin) -> Option<String> {let Coin::Quarter(state) = coin else {return None;};if state.existed_in(1900) {Some(format!("{state:?} is pretty old, for America!"))} else {Some(format!("{state:?} is relatively new."))}
}