rust-lang-zh_CN/src/enums_and_pattern_matching/defining_an_enum.md
2023-11-30 14:44:34 +08:00

16 KiB
Raw Blame History

定义一个枚举

枚举是不同于结构体的第二种定义定制数据类型的方式。下面就来看看一种在代码中可能表达的情形,并见识一下为何在此情形下,相比于结构体,枚举是有用且更恰当的。假设说这里需要对 IP 地址进行处理。目前仅有两种用于 IP 地址的标准:版本四和版本六。由于这两个标准是程序将遇到的 IP 地址仅有的可能性,因此就可以 列举出enumerate 全部可能的变种这正是枚举enumeration 名字得来之处。

任何 IP 地址都只能是版本四或版本六的地址,而不会同时两个都是。由于枚举值只能是枚举变种之一,那么 IP 地址的这个属性令到枚举数据结构the enum data structure恰当起来。而版本四和版本六两种地址从根本上说都是 IP 地址,那么在代码对适用于任意类别 IP 地址的情形加以处理时,版本四和版本六地址都应当作同一类型对待。

在代码中,可通过定义一个 IpAddrKind 枚举,并列出 IP 地址可能的类别,即 V4V6,来表达这个概念。下面就是该枚举的变种:

enum IpAddrKind {
    V4,
    V6,
}

现在 IpAddrKind 就是一个可在代码中别的地方使用的定制数据类型了。

枚举取值

Enum Values

可像下面这样,创建出 IpAddrKind 两个变种的实例来:

    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

请注意,该枚举的两个变种,是在其标识符的命名空间之下的,且这里使用了双冒号将标识符和变种分隔开。由于现在这两个值 IpAddrKind::V4IpAddrKind::V6 都是这同一类型:IpAddrKind,因此这就变得有用了。随后就可以,比如,定义一个取任意 IpAddrKind 类型值的函数:

fn route(ip_kind: IpAddrKind) {}

进而就能以这两个变种对这个函数进行调用了:

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);

枚举的使用甚至还有更多好处。在还没有一种存储具体 IP 地址 数据data 的时候,就要进一步思考一下这里的 IP 地址类型;这是只知道 IP 地址数据为什么 类别king。根据在第 5 章中掌握的结构体知识,那么可能很想用下面清单 6-1 中的结构体来解决这个问题。

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"),
    };
}

清单 6-1使用结构体 struct 来存储 IP 地址的数据与 IpAddrKind 变种

这里已定义了有着两个字段的结构体 IpAddr:一个类型为 IpAddrKind (即先前定义的那个枚举)的 kind 字段,以及一个类型为 Stringaddress 字段。这里有该结构体的两个实例。第一个是 home,而他有着与地址数据 127.0.0.1 关联的 IpAddrKind::V4 作为其 kind 的值。第二个实例为 loopback。这个实例则有不同的 IpAddrKind 变种作为其 kind 的值,即 V6,与 kind 关联的是地址 ::1。由于这里使用了结构体将 kindaddress 值捆绑在一起,因此现在这个 IpAddrKind 的变种就与那个 String 值关联起来了。

不过,仅使用一个枚举来表示这同一概念,就会更加简练:与其将枚举放在结构体内部,可将数据直接放在各个枚举变种里头。那么这新的 IpAddr 枚举定义,就是说 V4V6 两个变种,将同时有着关联的 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"));
}

这里把数据直接附加到枚举的各个变种上,因此就无需额外的结构体了。这里还更易于发现枚举工作原理的另一细节:所定义的各个枚举变种的名字,还成为了构造该枚举实例的函数。那就是说,IpAddr::V4() 现在是个取 String 参数并返回该 IpAddr 类型实例的函数调用了。作为定义枚举的结果,这里让这个构造函数自动就定义好了。

这里还有另一个使用枚举而非结构体的好处:各个变种可以有不同类型及数量的关联数据。版本四类型的 IP 地址,将始终有着四个会有着 0255 之间值的数字部分。在希望将 V4 地址存储为四个 u8 值,而仍然将 V6 地址表示为一个 String 值时,那就没法用结构体了,而枚举则能轻易处理这样的情况:

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"));
}

到这里,就已经给出了好几种定义用于存储版本四和版本六 IP 地址的数据结构了。然而事实表明,想要存储 IP 地址,及对这些 IP 地址所属类别进行编码是如此普遍,以致 标准库就有一个可加以使用的定义!下面就来看看,标准库是怎样定义 IpAddr 的:他有着与这里曾定义和使用过的相同枚举和变种,不过标准库是将地址数据,以两个不同结构体的形式,嵌入到变种里的,对两个枚举变种,定义了不同的结构体。

struct Ipv4Addr {
    // --跳过--
}

struct Ipv4Addr {
    // --跳过--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

这段代码说明可将任何类别的数据放在枚举变种里面:比如字符串、数字类型,或结构体等等。甚至可以包含另一枚举!还说明了,标准库类型,通常也并不比咱们自己编写的代码复杂多少。

请注意,由于这里不曾将标准库的 IpAddr 定义带入到这里的作用域,因此即使标准库包含了一个 IpAddr 的定义,这里也仍然可以毫无冲突地创建与使用自己的 IpAddr 定义。在第 7 章就会讲到有关带入类型到作用域的问题。

来看看下面清单 6-2 中另一个枚举的示例:这个枚举有着嵌入到其各个变种中的种类繁多的类型。

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write (String),
    ChangeColor(i32, i32, i32),
}

清单 6-2每个变种都存储了不同数量和类型值的 Message 枚举

这个枚举有着四个带有不同类型数据的变种:

  • Quit 变种完全没有与其关联的数据;
  • Move 变种像结构体一样,有着两个命名的字段;
  • Write 变种包含了单个 String
  • ChangeColor 编程包含了三个 i32 的值。

定义一个有着一些如上面清单 6-2 中变种的枚举,与定义不同种类的结构体定义类似,不同在于枚举未使用关键字 struct,且所有变种在 Message 类型下组织在了一起。下面这些结构体,就可保存之前各个枚举变种所保存的那些同样数据:

struct QuitMessage; // 单元结构体
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String);    // 元组结构体
struct ChangeColorMessage(i32, i32, i32);   //  元组结构体

不过假如这里使用了不同的、有着各自类型的结构体,那么就无法轻易地定义出一个接收原本在清单 6-2 中定义的、单一类型的 Message 枚举那样的,接收全部这些类别消息的函数了。

枚举与结构体之间,还有另外一个相似点:正如在结构体上使用 impl 关键字定义出一些方法,在枚举上定义方法也是可以的。下面就是一个可定义在这里的 Message 枚举上、名为 call 的方法:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write (String),
    ChangeColor(i32, i32, i32),
}

impl Message {
    fn call(&self) {
        // 方法体将定义在这里
    }
}

fn main() {

    let m = Message::Write(String::from("hello"));
    m.call();
}

方法体将使用 self,来获取方法调用所在变种实例值。在此示例中,已创建了一个有着值 Message::Write(String::from("hello")) 的变量 m,而那就是在 m.call() 运行时,call 方法体中的那个 self

下面就来看看标准库中另一个甚为常见和有用的枚举:Option

枚举 Option 及其超越空值的诸多优点

The Option Enum and Its Advantages Over Null Values

本小节会探讨 Option 的案例研究,Option 是由标准库定义的另一个枚举。Option 类型编码了某个值可能会是某个物件或可能什么都不属于的这种甚为常见的场景the Option type encodes the very common scenario in which a value could be something or it could be nothing。比如在请求某个含有一些项目的清单的第一个项目时就会得到一个值。而在请求某个空清单的第一个项目时则会什么也得不到。以类型系统字眼来表达这个概念就表示编译器能够对是否已处理了本应处理的全部情形进行检查此项功能可阻止那些在其他编程语言中极为常见的代码错误。

编程语言的设计通常要考量包含哪些特性而要排除哪些特性也至关重要。Rust 没有许多其他语言都有的空值特性。空值Null 是一个表示此处无值的值。在带有 null 的那些语言中,变量总是会处于下面两个状态之一:空值或非空值。

null 的发明人Tony Hoare 于 2009 年的演讲 “空值引用10 亿美金代价失误Null Reference: The Billion Dollar Mistake” 中,就讲了这个问题:

我把他叫做我的 10 亿美元失误。那个时候我正在设计某门面向对象语言中的首个综合类型系统。目的是要在编译器自动执行的检查之下,确保全部引用使用,都应绝对安全。仅仅因为空值引用变量实现起来很容易,我当时就没能顶住诱惑,把他加入到特性集了。这个举动,业已造成了数不胜数的代码错误、漏洞及系统崩溃等等问题,在过去 40 余年里,这些问题可能已经造成大概 10 亿美金的痛苦和伤害。

null 值的问题在于,当尝试将 null 值用作非 null 值时,就会得到某种错误。由于这种 null 或非 null 的属性遍布各处,因此极容易犯下此类错误。

null 试图表达的概念,还是有用的:null 是个因某些原因,而当前为无效或空缺的值。

问题不是真的在于这个概念而在于针对性的实现。由于这些原因Rust 就没有空值,但他确实有一个可对值存在或空缺这个概念,进行编码的枚举。这个枚举就是 Option<T>,而这个枚举 由标准库定义 为下面这样:

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

这个 Option<T> 是如此重要,以至于在 Rust 序曲the prelude中甚至都包含了是不需要显式地将其带入到作用域的原生类型、这里的 Option<T>,以及前面的 String 类型等等,就是这样的包含在序曲中的类型,无需显式地带入到作用域,就可以直接使用)。该枚举的变种也已包含在 Rust 序曲中:可直接在不带前缀 Option:: 的情况下直接使用 SomeNone(注:那么 SomeNone 就被列为了 Rust 关键字了)。Option<T> 仍然只是常规枚举,而 Some<T>None 仍然是类型 Option<T> 的变种。

这里的 <T> 语法,是个到目前为止还未讲到的 Rust 特性。他是个泛型参数,而在第 10 章将更详细的涉及到泛型。至于现在,只需明白 <T> 表示 Option 枚举的 Some 变种,可保存任意类型的一条数据,而在 T 位置处用到的各个具体类型,会让整个 Option<T> 类型成为各异的类型for now, all you need to know is that <T> means the Some variant of the Option enum can hold one piece of data of any type, and that each concrete type that gets used in place of T makes the overall Option<T> type a different type。以下是使用 Option 来保存数字与字符串类型的一些示例:

    let some_numer = Some(5);
    let some_string = Some("一个字符串");

    let absent_number: Option<i32> = None;

some_number 的类型为 Option<i32>some_string 的类型为 Option<&str>,是个不同的类型。由于这里已在 Some 变种里面指定了值,因此 Rust 可推导出这些类型来。而对于 absent_numberRust 就要求注释整个 Option 类型:编译器无法通过仅查看一个 None 值,而推导出相应的 Some 变种的类型来。这里告诉了 Rust这里计划的是 absent_number 为类型 Option<i32>

在有着一个 Some 值时,就知道存在着一个值,且该值是保存在 Some 内部的。而在有个 None 值时,某种意义上讲,这表示了与空值同样的情况:没有一个有效值。那么究竟为什么有着 Option<T> 就是要比有着空值 null 好呢?

简而言之,由于 Option<T>T (其中的 T 可以是任意类型) 为不同类型,因此编译器就不会允许将一个 Option<T> 值,当作一个必然的有效值来使用。比如,由于下面这段代码是在尝试将一个 i8 值,添加到某个 Option<i8> 上,因此这段代码不会编译:

    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;

在运行这段代码时,就会得到下面这样的错误消息:

$ cargo run                                                                                     ✔
   Compiling enum_demo v0.1.0 (/home/peng/rust-lang/projects/enum_demo)
error[E0277]: cannot add `Option<i8>` to `i8`
  --> src/main.rs:24:17
   |
24 |     let sum = x + y;
   |                 ^ no implementation for `i8 + Option<i8>`
   |
   = help: the trait `Add<Option<i8>>` is not implemented for `i8`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enum_demo` due to previous error

强悍!事实上,这个错误消息表示,由于 i8Option<i8> 属于不同类型,因此 Rust 不知道怎样将一个 i8 值与一个 Option<i8> 值相加。当在 Rust 中有着一个类型好比 i8 这样的值时,编译器就会保证始终有个有效值。在对那个值进行使用前,可不必检查他是不是 null,而可放心地加以处理。仅当有个 Option<i8> 类型(或其他任何正在使用的Option<T> 枚举类型值)的变量时,才真地必须关心可能并无值,同时编译器将确保在使用该值前,显式地处理无值的情况。

也就是说,在对 Option<T 执行类型参数 T 的那些操作前,必须将 Option<T> 类型转换为 T 类型。通常,这样做有助于捕获到 null 最常见问题之一:在某个东西实际上是 null 时,错误地将其设想为了他不是 null

这种消除了不正确的假定某个非 null 值的做法,有助于增强代码自信。为了使用一个可能为 null 的值,就必须显式地通过将那个值的构造为 Option<T>,来带入这个值。在某个类型不为 Option<T> 值出现的每个地方,就都可以假定该值不是 null。这是 Rust 有意的设计决定,用以限制 null 的无处不在,及提升 Rust 代码的安全性。

那么在有一个类型为 Option<T> 值的时候,该怎么从 Some 变种获取到 T 这个值,从而就可以用上那个值呢?枚举 Option<T> 有着大量的、在不同情形下有用的方法;在 Option<T> 文档 便可查看到这些方法。熟悉 Option<T> 上的这些方法,将对 Rust 编程生涯极为有用。

总的来说,为了使用某个 Option<T> 值,就要有将会处理各个变种的代码。要有一些只会在有着一个 Some<T> 的值时运行的代码,而此情况下就会允许这代码使用那个内部的 T 类型变量。在有着 None 值时,则还要有别的代码来允许了,而这代码就没有可用的 T 类型值了。在与枚举一起使用的时候,match 表达式正是实现此特性的控制流结构:match 表达式将依据枚举有着哪些变种,而运行相应的不同代码,以及哪些代码可使用匹配值内部的数据。