定义枚举
在哪里结构体(structs)为你提供了一种将相关字段和数据组合在一起的方式,比如一个带有width
和height
的Rectangle
,枚举(enums)则为你提供了一种说明一个值是可能值集中的一个值的方式。例如,我们可能希望说明Rectangle
是包括Circle
和Triangle
在内的一组可能形状中的一个。为了实现这一点,Rust允许我们将这些可能性编码为一个枚举。
让我们来看一个我们可能想要在代码中表达的情况,并看看为什么枚举比结构体在这种情况下更有用和更合适。假设我们需要处理 IP 地址。目前,IP 地址使用两个主要标准:第四版和第六版。因为这些是我们程序会遇到的唯一可能的 IP 地址,我们可以枚举所有可能的变体,这就是枚举名称的由来。
任何IP地址都可以是版本四或版本六的地址,但不能同时是两者。这一IP地址的特性使得枚举数据结构变得合适,因为枚举值只能是其变体之一。无论是版本四还是版本六的地址,它们本质上仍然是IP地址,因此在代码处理适用于任何类型IP地址的情况时,应将它们视为同一类型。
我们可以用代码表示这个概念,通过定义一个 IpAddrKind
枚举并列出 IP 地址可以是的类型,V4
和 V6
。这些是枚举的变体:
enum IpAddrKind { V4, V6, } fn main() { let four = IpAddrKind::V4; let six = IpAddrKind::V6; route(IpAddrKind::V4); route(IpAddrKind::V6); } fn route(ip_kind: IpAddrKind) {}
IpAddrKind
现在是一个自定义数据类型,我们可以在代码的其他地方使用。
枚举值
我们可以像这样创建 IpAddrKind
的两个变体的实例:
enum IpAddrKind { V4, V6, } fn main() { let four = IpAddrKind::V4; let six = IpAddrKind::V6; route(IpAddrKind::V4); route(IpAddrKind::V6); } fn route(ip_kind: IpAddrKind) {}
请注意,枚举的变体在其标识符下命名空间化,我们使用双冒号将两者分隔。这很有用,因为现在两个值IpAddrKind::V4
和IpAddrKind::V6
都是同一类型:IpAddrKind
。然后,例如,我们可以定义一个接受任何IpAddrKind
的函数:
enum IpAddrKind { V4, V6, } fn main() { let four = IpAddrKind::V4; let six = IpAddrKind::V6; route(IpAddrKind::V4); route(IpAddrKind::V6); } fn route(ip_kind: IpAddrKind) {}
并且我们可以用任一变体调用此函数:
enum IpAddrKind { V4, V6, } fn main() { let four = IpAddrKind::V4; let six = IpAddrKind::V6; route(IpAddrKind::V4); route(IpAddrKind::V6); } fn route(ip_kind: IpAddrKind) {}
使用枚举还有更多优势。更多地思考我们的 IP 地址类型,目前我们没有一种方法来存储实际的 IP 地址数据;我们只知道它的类型。鉴于你刚刚在第 5 章学习了结构体,你可能会倾向于用结构体来解决这个问题,如列表 6-1 所示。
这里,我们定义了一个结构体 IpAddr
,它有两个字段:一个 kind
字段,类型为 IpAddrKind
(我们之前定义的枚举类型)和一个 address
字段,类型为 String
。我们有两个这个结构体的实例。第一个是 home
,它的 kind
值为 IpAddrKind::V4
,关联的地址数据为 127.0.0.1
。第二个实例是 loopback
。它的 kind
值为 IpAddrKind
的另一个变体 V6
,关联的地址为 ::1
。我们使用一个结构体将 kind
和 address
值捆绑在一起,因此现在变体与值关联。
然而,仅使用枚举表示相同的概念更为简洁:
而不是在结构体内使用枚举,我们可以直接将数据放入每个枚举
变体中。这个新的 IpAddr
枚举定义说明 V4
和 V6
变体都将具有关联的 String
值:
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 地址将始终具有四个数值组件,其值在 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")); }
我们展示了多种定义数据结构的方法来存储版本四和版本六的IP地址。然而,事实证明,希望存储IP地址并编码它们的类型是如此普遍,以至于标准库提供了一个我们可以使用的定义! 让我们看看标准库是如何定义IpAddr
的:它具有我们定义和使用的确切枚举和变体,但它在变体内部以两种不同的结构体形式嵌入地址数据,每种变体的定义方式不同:
#![allow(unused)] fn main() { struct Ipv4Addr { // --snip-- } struct Ipv6Addr { // --snip-- } enum IpAddr { V4(Ipv4Addr), V6(Ipv6Addr), } }
这段代码说明你可以在枚举变体中放入任何类型的数据:
例如字符串、数字类型或结构体。你甚至可以包含另一个枚举!此外,标准库类型通常并不比你可能想到的复杂多少。
请注意,即使标准库中包含 IpAddr
的定义,我们仍然可以创建并使用我们自己的定义而不会发生冲突,因为我们还没有将标准库的定义引入到我们的作用域中。我们将在第 7 章中更详细地讨论将类型引入作用域的问题。
让我们来看另一个枚举的例子,如清单 6-2 所示:这个枚举在其变体中嵌入了各种类型的值。
这个枚举有四个不同类型的变体:
Quit
没有任何与之关联的数据。Move
有命名字段,就像结构体一样。Write
包含一个String
。ChangeColor
包含三个i32
值。
定义如清单 6-2 中的枚举变体类似于定义不同类型的结构体,不同之处在于枚举不使用 struct
关键字,并且所有变体都归类在 Message
类型下。以下结构体可以持有与前面枚举变体相同的數據:
struct QuitMessage; // unit struct struct MoveMessage { x: i32, y: i32, } struct WriteMessage(String); // tuple struct struct ChangeColorMessage(i32, i32, i32); // tuple struct fn main() {}
但是,如果我们使用了不同的结构体,每个结构体都有自己的类型,我们就不能像使用在清单 6-2 中定义的 Message
枚举那样轻松地定义一个可以接受这些消息中任何一种的函数,因为 Message
枚举是一个单一类型。
枚举和结构体之间还有一个相似之处:就像我们可以在结构体上使用 impl
定义方法一样,我们也可以在枚举上定义方法。这是一个可以在我们的 Message
枚举上定义的方法,名为 call
:
fn main() { enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } 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()
运行时,这将是 call
方法主体中的 self
。
让我们看看标准库中的另一个非常常见且有用的枚举:Option
。
枚举 Option
及其相对于空值的优势
这一节探讨了Option
的案例研究,这是标准库定义的另一个枚举。Option
类型编码了非常常见的情况,即一个值可能是某个值,也可能什么都不是。
例如,如果您请求非空列表中的第一个项目,您将获得一个值。如果您请求空列表中的第一个项目,您将一无所获。将这个概念用类型系统表达意味着编译器可以检查您是否已经处理了所有应该处理的情况;此功能可以防止在其他编程语言中非常常见的错误。
编程语言设计通常被认为是在于你包含哪些特性,但你排除的特性也同样重要。Rust 没有其他许多语言都有的 null 特性。 null 是一个表示没有值的值。在有 null 的语言中,变量总是可以处于两种状态之一:null 或 not-null。
在他的2009年演讲“空引用:十亿美元的错误”中,空引用的发明者Tony Hoare如是说:
我称之为我的十亿美元错误。当时,我正在设计第一个面向对象语言中的引用的全面类型系统。我的目标是确保所有引用的使用都是绝对安全的,由编译器自动进行检查。但我无法抗拒添加空引用的诱惑,仅仅因为它实现起来非常简单。这导致了无数的错误、漏洞和系统崩溃,在过去的四十年中可能造成了十亿美元的痛苦和损失。
空值的问题在于,如果你试图将空值用作非空值,你会得到某种错误。由于这种空或非空的属性是普遍存在的,因此非常容易犯这种错误。
然而,null试图表达的概念仍然是有用的:一个null是一个由于某种原因当前无效或缺失的值。
问题不在于概念本身,而在于特定的实现。因此,Rust 没有 null,但它确实有一个可以编码值存在或不存在概念的枚举。这个枚举是 Option<T>
,并且它是由 标准库定义的 如下:
#![allow(unused)] fn main() { enum Option<T> { None, Some(T), } }
Option<T>
枚举非常有用,甚至被包含在前言中;你无需显式地将其引入作用域。它的变体也被包含在前言中:你可以直接使用 Some
和 None
,而无需 Option::
前缀。Option<T>
枚举仍然是一个普通的枚举,Some(T)
和 None
仍然是 Option<T>
类型的变体。
<T>
语法是 Rust 的一个我们尚未讨论过的特性。它是一个泛型类型参数,我们将在第 10 章中更详细地介绍泛型。目前,你只需要知道 <T>
表示 Option
枚举的 Some
变体可以持有一个任何类型的单个数据,并且每个用于替代 T
的具体类型都会使整体的 Option<T>
类型成为一个不同的类型。以下是一些使用 Option
值来持有数字类型和字符串类型的示例:
fn main() { let some_number = Some(5); let some_char = Some('e'); let absent_number: Option<i32> = None; }
some_number
的类型是 Option<i32>
。some_char
的类型是
Option<char>
,这是一个不同的类型。Rust 可以推断这些类型,因为
我们在 Some
变体中指定了一个值。对于 absent_number
,Rust
要求我们注解整个 Option
类型:编译器无法仅通过查看 None
值来推断
相应的 Some
变体将持有的类型。在这里,我们告诉 Rust absent_number
应该是 Option<i32>
类型。
当我们有一个 Some
值时,我们知道存在一个值,并且该值被包含在 Some
中。当我们有一个 None
值时,在某种意义上它与 null 的意思相同:我们没有一个有效的值。那么,为什么拥有 Option<T>
比拥有 null 更好呢?
简而言之,因为 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;
}
如果我们运行这段代码,我们会得到一个类似的错误消息:
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
--> src/main.rs:5:17
|
5 | let sum = x + y;
| ^ no implementation for `i8 + Option<i8>`
|
= help: the trait `Add<Option<i8>>` is not implemented for `i8`
= help: the following other types implement trait `Add<Rhs>`:
`&'a i8` implements `Add<i8>`
`&i8` implements `Add<&i8>`
`i8` implements `Add<&i8>`
`i8` implements `Add`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error
紧张!实际上,这个错误信息意味着 Rust 不知道如何将 i8
和 Option<i8>
相加,因为它们是不同的类型。当我们有一个像 i8
这样的类型的值时,编译器会确保我们始终有一个有效的值。我们可以自信地使用该值,而无需在使用之前检查是否为 null。只有当我们有一个 Option<i8>
(或我们正在处理的任何类型的值)时,我们才需要担心可能没有值,编译器会确保我们在使用该值之前处理这种情况。
换句话说,你必须将一个 Option<T>
转换为 T
才能使用 T
进行操作。通常,这有助于捕获与空值最常见的问题之一:假设某物不是空值而实际上它是。
消除错误地假设非空值的风险有助于您对代码更加自信。为了拥有一个可能为空的值,您必须通过将该值的类型设置为Option<T>
来显式选择加入。然后,在使用该值时,您需要显式处理值为空的情况。在所有值的类型不是Option<T>
的地方,您可以安全地假设该值不是空的。这是Rust的一个故意设计决定,旨在限制空值的普遍性并提高Rust代码的安全性。
那么,当你有一个类型为 Option<T>
的值时,如何从 Some
变体中获取 T
值以便使用该值呢?Option<T>
枚举有许多在各种情况下都非常有用的方法;你可以在 其文档 中查看这些方法。熟悉 Option<T>
的方法将对你的 Rust 之旅极为有用。
一般来说,为了使用 Option<T>
值,你希望有能够处理每个变体的代码。你希望有一些代码仅在你有一个 Some(T)
值时运行,并且这段代码可以使用内部的 T
。你希望有其他代码仅在你有一个 None
值时运行,而这段代码没有 T
值可用。match
表达式是一种控制流结构,当与枚举一起使用时,它会根据枚举的哪个变体运行不同的代码,并且这些代码可以使用匹配值中的数据。