rust-枚举
定义枚举
结构体(struct)为你提供了一种将相关字段和数据组合在一起的方法,比如一个包含宽度和高度的矩形,而枚举(enum)则让你能够表示某个值是可能取自一组值中的一种。例如,我们可能想说矩形是可能的几何图形之一,其他还包括圆形和三角形。为此,Rust 允许我们用枚举来编码这些可能性。
让我们来看一个代码中可能需要表达的场景,并看看为什么在这种情况下枚举比结构体更有用且更合适。假设我们需要处理 IP 地址。目前,有两个主要标准用于 IP 地址:版本四(IPv4)和版本六(IPv6)。因为这就是程序会遇到的所有 IP 地址类型,我们可以列出所有可能的变体,这也是“枚举”名称的由来。
任何 IP 地址要么是版本四,要么是版本六,但不可能同时两者兼具。这一属性使得 enum 数据结构非常合适,因为 enum 的值只能是其变体之一。无论是 IPv4 还是 IPv6,它们本质上都是 IP 地址,因此当代码处理任何类型的 IP 地址时,应被视作同一种类型。
我们可以通过定义一个 IpAddrKind 枚举并列出 IP 地址可选种类 V4 和 V6 来表达这个概念。这些就是该枚举的变体:
enum IpAddrKind {V4,V6,
}
IpAddrKind 现在成为了一个自定义数据类型,可以在代码其他地方使用。
枚举值
我们可以这样创建 IpAddrKind 两个变体各自的实例:
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
注意,enum 的变体都位于它标识符命名空间下,用双冒号分隔。这很有用,因为现在两个值 IpAddrKind::V4
和 IpAddrKind::V6
都属于同一类型:IpAddrKind
。例如,我们可以定义一个接受任意 IpAddrKind
参数的函数:
fn route(ip_kind: IpAddrKind) {}
然后调用该函数时传入任意变体即可:
route(IpAddrKind::V4);
route(IpAddrKind::V6);
使用枚举还有更多优势。再考虑我们的 IP 类型,目前还没有存储实际地址数据的方法;只知道它是哪种类型。鉴于你刚学过第5章里的结构体,你或许会尝试像清单 6-1 那样用结构体现解决这个问题。
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"),};
清单 6-1:使用结构体存储IP地址及其对应类别
这里,我们定义了一个包含两个字段的 struct Ip Addr
: 一个名为 kind
,其类型为之前定义好的 enum 类型 Ip Addr Kind
;另一个名叫 address
, 类型为字符串 (String
) 。有两个该 struct 实例,第一个变量 home,其 kind 字段赋予了值 I p A d d r K i n d :: V 4
, 并关联地址 “127.0.0.1”;第二个变量 loopback,其 kind 字段赋予另一种 variant 值,即 v6,同时关联 “::1” 。
这里用了 struct 把 type 与 value 捆绑起来,使得 variant 与具体数据信息绑定。但仅靠 enum 表示相同概念更加简洁明了:不用把 enum 放进 struct 中,而是在每个 variant 内直接携带数据。如下面新的 ip addr 枚举行所示,两种 variant 都附带字符串型的数据:
enum IpAddrs{v4(String),v6(String),}let home=ipaddrsv4(stringfrom("127.0.0.1"));let loopback=ipaddrsv6(stringfrom(":::"));
我们直接给每个variant附加数据,无需额外struct。同时也能看出另一点——每个variant名字自动成为构造函数,如 ipaddrv4() 是接收 string 参数返回 ipaddrs 实例的一函数,这是编译器帮忙生成构造器方法。
使用 enum 而非 struct 的另优点在于,每个 variant 可以拥有不同数量与不同类型的数据。例如 ipv4 总共含四部分数字,每部分范围从零至二百五十五。如果想以4个u8 存储 ipv4地址而仍然以字符串形式保存 ipv6,则无法用单纯 struct 达成,但 enums 很容易实现:
enum ipaddrs{v4(u8,u8,u8,u8),v6(string),
}let home=ipaddrsv4(127,0,0,1);let loopback=ipaddrsv6(stringfrom(":::"));
我们展示了多种方式去设计存储ipv4、ipv6地址的数据结构。然而实际上,存储IP以及区分哪类如此常见,以致标准库已有现成实现!来看标准库如何定义 ipaddr :它跟前面一样,是具有相应 variants 的 enum,不过将具体地址封装进两套分别针对各variant 定义不同内容(struct)里:
struct Ipv4Addr {// --snip--
}
struct Ipv6Addr {// --snip--
}enum IpAddr {V4(Ipv4Addr),V6(Ipv6Addr),
}
此处说明,在enum内可放置各种数据,包括字符串、数字甚至其它 structs 或 enums!此外,标准库里的实现通常不会比自己写复杂多少。
注意即便标准库已含有对 ipaddr 的定义,只要未引入作用域,也能自行创建并使用自己的同名定义,不冲突。本书第7章会详细讲解如何引入作用域。
再看清单 6-2 中另一示例,该 Message 枚举行内嵌多样化的数据:
enum message{quit,move{x:i32,y:i32},write(string),changecolor(i32,i32,i32)
}
清单 6-2:Message 枚举行,各variant 携带不同数量与类型的数据
此 Enum 有四个 Variant 各异:
- Quit 无任何关联数据;
- Move 拥有命名字段,如同 Struct;
- Write 包含唯一 String;
- ChangeColor 包括三个 i32 数字。
定义类似清单中这样的 Enum,与分别声明多个 Struct 类似,只不过 Enum 不需关键字 struct 且所有 Variant 聚集归属 Message 类型。如若改写成 Struct,可如下:
struct quitmessage; // 单元Structstruct movemessage{x:i32,y:i32,
}struct writemessage(String); // 元组Structstruct changecolormessage(i32,i32,i32); // 元组Struct
若采用上述多个独立 Struct,由于各自独立新建不同 Type,就没法像统一 Message Enum 一样方便地编写接受任意消息参数之函数。
枚举行与结构体还有一点相似之处:就如可对 Struct 用 impl 定义方法,也能对 Enum 做此操作。例如给 Message 添加 call 方法:
impl message{fn call(&self){// 方法主体逻辑写这里 }
}let m=messagewrite(stringfrom("hello"));
m.call();
方法内部通过 self 获取调用对象。在此案例中变量 m 为 messagewrite(String 从 “hello”) 创建出的实例,当执行 m.call() 时 self 即代表该实例。
最后,再介绍一下极常见且实用标准库中的 Option 枚举办例……
Option 枚举及其相较于 null 值的优势
本节探讨了 Option 的案例研究,Option 是标准库中定义的另一个枚举类型。Option 类型编码了一个非常常见的场景,即某个值可能存在,也可能不存在。
例如,如果你请求非空列表中的第一个元素,你会得到一个值;如果请求空列表中的第一个元素,则不会得到任何值。在类型系统中表达这个概念意味着编译器可以检查你是否处理了所有应处理的情况;这种功能可以防止在其他编程语言中极为常见的错误。
编程语言设计通常被认为是关于包含哪些特性,但排除哪些特性同样重要。Rust 没有许多其他语言都有的 null 特性。null 是一种表示“没有值”的特殊值。在带有 null 的语言中,变量总是处于两种状态之一:null 或非 null。
在他 2009 年题为《Null References: The Billion Dollar Mistake》(空引用:十亿美元级别的错误)的演讲中,null 的发明者 Tony Hoare 如此说道:
我称之为我的十亿美元错误。当时,我正在设计面向对象语言中的首个全面引用类型系统。我的目标是确保所有对引用的使用都绝对安全,由编译器自动进行检查。但我无法抗拒加入 null 引用这一简单易实现特性的诱惑。这导致无数错误、漏洞和系统崩溃,在过去四十年里造成了大约十亿美元级别的问题和损失。
null 值的问题在于,如果你试图将 null 当作非 null 使用,就会产生某种错误。由于这种 null 与非 null 状态普遍存在,这类错误极易发生。
然而,null 想要表达的概念仍然很有用:即某个值因某些原因当前无效或缺失。
问题不在于这个概念,而是在具体实现上。因此,Rust 没有 null,但它确实提供了能编码“值存在或不存在”这一概念的枚举——这就是 Option,由标准库定义如下:
enum Option<T> {None,Some(T),
}
Option 枚举非常实用,以至于它被包含在预导入(prelude)中,无需显式引入作用域。它的变体也包括在预导入内,可以直接使用 Some 和 None 而无需加前缀 Option:: 。Option 依旧只是普通枚举,其中 Some(T) 和 None 都是 Option 类型下的变体。
<T>
语法是 Rust 中尚未介绍的一项特性,它代表泛型类型参数,我们将在第10章详细讨论泛型。目前只需知道 <T>
表示 Option 枚举中的 Some 变体可以持有任意类型的数据,每当替换 T 为具体类型时,会生成不同具体化后的 Option 类型。例如以下代码展示如何使用 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>
,这是另一种不同类型。Rust 能推断这些,因为我们给出了 Some 内部的数据。而对于 absent_number,因为只有 None,没有数据可供推断,所以必须显式标注整体变量为 Option<i32>
。
当我们拥有 Some 值时,就知道该位置确实含有效数据,并且数据保存在 Some 中。当拥有 None 时,从某种意义上说,它与传统意义上的 null 相似:表示没有有效数据。那么为什么说拥有 Option 比单纯拥有 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 runCompiling 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`
...
For more information about this error, try `rustc --explain E0277`.
error: could not compile 'enums' due to previous error.
这条信息表明 Rust 不理解如何将 i8 和 option 包装过后的 i8 相加,因为它们属于不同类别。在 Rust 中,当我们拿到像 i8 一样确定具体的数据时,编译器保证该数据始终有效,因此我们不用担心先检测是否为空再去操作。而只有当遇到类似于 option 包裹起来的不确定是否含有效数据的时候,我们才需要考虑对应情况,并且强制要求程序员处理这些情况才能继续操作该数值。
换句话说,要想对内部实际存储的数据执行操作,需要先从选项(option)转换出真正的数据(T)。这样做帮助捕获最常见的一类 Null 错误——假设某物不是 Null 实际却是 Null 导致的问题。
消除误以为一定不是 Null 带来的风险,让你的代码更加可靠安全。如果希望允许变量可能为空,则必须明确声明其为选项型 (Option<T>
) 。之后每次访问,都必须显式地处理 “无效/空” 情况。而凡是不带选项包装(即普通 T 类型)的地方,都可以放心假定其必定不为空。这正体现了 Rust 有意识限制 Null 普遍性的设计决策,从而提升代码安全性。
那么,当手头已有一个 type 为 option<T> 的变量时,该如何取出其中 some 部分包裹着真实 t 数据以便进一步利用?option<T> 提供大量方法满足各种需求,可参考官方文档深入了解。熟悉并掌握这些方法,将极大助力你的 Rust 学习旅程。
一般来说,为正确使用 option<T>, 应写出能够分别针对两个变体执行逻辑代码块。一段仅针对 some(t) 执行,此处可自由访问内部 t;另一段仅针对 none 执行,此处则不可获得 t 数据。“match” 表达式恰好是一种控制流结构,用来匹配枚举各变体,根据匹配结果执行对应分支,同时可访问匹配内容里的相关数据,实现上述需求。