枚举枚举(enum或enumeration)允许你通过列举可能的成员来定义一个枚举类型。
枚举(enum 或 enumeration)允许你通过列举可能的成员来定义一个枚举类型,例如扑克牌花色:
enum PokerSuit {
Clubs,
Spades,
Diamonds,
Hearts,
}
如果在此之前你没有在其它语言中使用过枚举,那么可能需要花费一些时间来理解这些概念,一旦上手,就会发现枚举的强大,甚至对它爱不释手。
再回到之前创建的 PokerSuit
,扑克总共有四种花色,而这里我们枚举出所有的可能值,这也正是 枚举
名称的由来。
任何一张扑克,它的花色肯定会落在四种花色中,而且也只会落在其中一个花色上,这种特性非常适合枚举的使用,因为枚举值只可能是其中某一个成员。抽象来看,四种花色尽管是不同的花色,但是它们都是扑克花色这个概念,因此当某个函数处理扑克花色时,可以把它们当作相同的类型进行传参。
细心的读者应该注意到,我们对之前的 枚举类型
和 枚举值
进行了重点标注,这是因为对于新人来说容易混淆相应的概念,总而言之: 枚举类型是一个类型,它会包含所有可能的枚举成员, 而枚举值是该类型中的具体某个成员的实例。
现在来创建 PokerSuit
枚举类型的两个成员实例:
let heart = PokerSuit::Hearts;
let diamond = PokerSuit::Diamonds;
我们通过 ::
操作符来访问 PokerSuit
下的具体成员,从代码可以清晰看出,heart
和 diamond
都是 PokerSuit
枚举类型的,接着可以定义一个函数来使用它们:
fn main() {
let heart = PokerSuit::Hearts;
let diamond = PokerSuit::Diamonds;
print_suit(heart);
print_suit(diamond);
}
fn print_suit(card: PokerSuit) {
// 需要在定义 enum PokerSuit 的上面添加上 #[derive(Debug)],否则会报 card 没有实现 Debug
println!("{:?}",card);
}
print_suit
函数的参数类型是 PokerSuit
,因此我们可以把 heart
和 diamond
传给它,虽然 heart
是基于 PokerSuit
下的 Hearts
成员实例化的,但是它是货真价实的 PokerSuit
枚举类型。
接下来,我们想让扑克牌变得更加实用,那么需要给每张牌赋予一个值:A
(1)-K
(13),这样再加上花色,就是一张真实的扑克牌了,例如红心 A。
目前来说,枚举值还不能带有值,因此先用结构体来实现:
enum PokerSuit {
Clubs,
Spades,
Diamonds,
Hearts,
}
struct PokerCard {
suit: PokerSuit,
value: u8
}
fn main() {
let c1 = PokerCard {
suit: PokerSuit::Clubs,
value: 1,
};
let c2 = PokerCard {
suit: PokerSuit::Diamonds,
value: 12,
};
}
这段代码很好的完成了它的使命,通过结构体 PokerCard
来代表一张牌,结构体的 suit
字段表示牌的花色,类型是 PokerSuit
枚举类型,value
字段代表扑克牌的数值。
可以吗?可以!好吗?说实话,不咋地,因为还有简洁得多的方式来实现:
enum PokerCard {
Clubs(u8),
Spades(u8),
Diamonds(u8),
Hearts(u8),
}
fn main() {
let c1 = PokerCard::Spades(5);
let c2 = PokerCard::Diamonds(13);
}
直接将数据信息关联到枚举成员上,省去近一半的代码,这种实现是不是更优雅?
不仅如此,同一个枚举类型下的不同成员还能持有不同的数据类型,例如让某些花色打印 1-13
的字样,另外的花色打印上 A-K
的字样:
enum PokerCard {
Clubs(u8),
Spades(u8),
Diamonds(char),
Hearts(char),
}
fn main() {
let c1 = PokerCard::Spades(5);
let c2 = PokerCard::Diamonds('A');
}
回想一下,遇到这种不同类型的情况,再用我们之前的结构体实现方式,可行吗?也许可行,但是会复杂很多。
再来看一个来自标准库中的例子:
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
这个例子跟我们之前的扑克牌很像,只不过枚举成员包含的类型更复杂了,变成了结构体:分别通过 Ipv4Addr
和 Ipv6Addr
来定义两种不同的 IP 数据。
从这些例子可以看出,任何类型的数据都可以放入枚举成员中: 例如字符串、数值、结构体甚至另一个枚举。
增加一些挑战?先看以下代码:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {
let m1 = Message::Quit;
let m2 = Message::Move{x:1,y:1};
let m3 = Message::ChangeColor(255,255,0);
}
该枚举类型代表一条消息,它包含四个不同的成员:
Quit
没有任何关联数据Move
包含一个匿名结构体Write
包含一个 String
字符串ChangeColor
包含三个 i32
当然,我们也可以用结构体的方式来定义这些消息:
struct QuitMessage; // 单元结构体
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // 元组结构体
struct ChangeColorMessage(i32, i32, i32); // 元组结构体
由于每个结构体都有自己的类型,因此我们无法在需要同一类型的地方进行使用,例如某个函数它的功能是接受消息并进行发送,那么用枚举的方式,就可以接收不同的消息,但是用结构体,该函数无法接受 4 个不同的结构体作为参数。
而且从代码规范角度来看,枚举的实现更简洁,代码内聚性更强,不像结构体的实现,分散在各个地方。
最后,再用一个实际项目中的简化片段,来结束枚举类型的语法学习。
例如我们有一个 WEB 服务,需要接受用户的长连接,假设连接有两种:TcpStream
和 TlsStream
,但是我们希望对这两个连接的处理流程相同,也就是用同一个函数来处理这两个连接,代码如下:
fn new (stream: TcpStream) {
let mut s = stream;
if tls {
s = negotiate_tls(stream)
}
// websocket是一个WebSocket<TcpStream>或者
// WebSocket<native_tls::TlsStream<TcpStream>>类型
websocket = WebSocket::from_raw_socket(
s, ......)
}
此时,枚举类型就能帮上大忙:
enum Websocket {
Tcp(Websocket<TcpStream>),
Tls(Websocket<native_tls::TlsStream<TcpStream>>),
}
在其它编程语言中,往往都有一个 null
关键字,该关键字用于表明一个变量当前的值为空(不是零值,例如整型的零值是 0),也就是不存在值。当你对这些 null
进行操作时,例如调用一个方法,就会直接抛出null 异常,导致程序的崩溃,因此我们在编程时需要格外的小心去处理这些 null
空值。
Rust中决定抛弃null,而改为使用 Option
枚举变量来表述这种结果。
Option
枚举包含两个成员,一个成员表示含有值:Some(T)
, 另一个表示没有值:None
,定义如下:
其中 T
是泛型参数,Some(T)
表示该枚举成员的数据类型是 T
,换句话说,Some
可以包含任何类型的数据。
Option<T>
枚举是如此有用以至于它被包含在了 prelude
(prelude 属于 Rust 标准库,Rust 会将最常用的类型、函数等提前引入其中,省得我们再手动引入)之中,你不需要将其显式引入作用域。另外,它的成员 Some
和 None
也是如此,无需使用 Option::
前缀就可直接使用 Some
和 None
。总之,不能因为 Some(T)
和 None
中没有 Option::
的身影,就否认它们是 Option
下的卧龙凤雏。
再来看以下代码:
let some_number = Some(5);
let some_string = Some("a string");
let absent_number: Option<i32> = None;
如果使用 None
而不是 Some
,需要告诉 Rust Option<T>
是什么类型的,因为编译器只通过 None
值无法推断出 Some
成员保存的值的类型。
当有一个 Some
值时,我们就知道存在一个值,而这个值保存在 Some
中。当有个 None
值时,在某种意义上,它跟空值具有相同的意义:并没有一个有效的值。那么,Option<T>
为什么就比空值要好呢?
简而言之,因为 Option<T>
和 T
(这里 T
可以是任何类型)是不同的类型,例如,这段代码不能编译,因为它尝试将 Option<i8>
(Option<T>
) 与 i8
(T
) 相加:
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
如果运行这些代码,将得到类似这样的错误信息:
error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is
not satisfied
-->
|
5 | let sum = x + y;
| ^ no implementation for `i8 + std::option::Option<i8>`
|
事实上,错误信息意味着 Rust 不知道该如何将 Option<i8>
与 i8
相加,因为它们的类型不同。当在 Rust 中拥有一个像 i8
这样类型的值时,编译器确保它总是有一个有效的值,我们可以放心使用而无需做空值检查。只有当使用 Option<i8>
(或者任何用到的类型)的时候才需要担心可能没有值,而编译器会确保我们在使用值之前处理了为空的情况。
换句话说,在对 Option<T>
进行 T
的运算之前必须将其转换为 T
。通常这能帮助我们捕获到空值最常见的问题之一:期望某值不为空但实际上为空的情况。
为了拥有一个可能为空的值,你必须要显式的将其放入对应类型的 Option<T>
中。接着,当使用这个值时,必须明确的处理值为空的情况。只要一个值不是 Option<T>
类型,你就 可以 安全的认定它的值不为空。这是 Rust 的一个经过深思熟虑的设计决策,来限制空值的泛滥以增加 Rust 代码的安全性。
总的来说,为了使用 Option<T>
值,需要编写处理每个成员的代码。你想要一些代码只当拥有 Some(T)
值时运行,允许这些代码使用其中的 T
。也希望一些代码在值为 None
时运行,这些代码并没有一个可用的 T
值。match
表达式就是这么一个处理枚举的控制流结构:它会根据枚举的成员运行不同的代码,这些代码可以使用匹配到的值中的数据。
这里先简单看一下 match
的大致模样,在模式匹配中,我们会详细讲解:
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);
plus_one
通过 match
来处理不同 Option
的情况。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!