Rust 枚举(enum)深度解析:从定义到 Option 的安全之道
文章目录
- [Rust 枚举(enum)深度解析:从定义到 Option 的安全之道](#Rust 枚举(enum)深度解析:从定义到 Option 的安全之道)
1.前言
在 Rust 的类型系统中,枚举( enum )远不止是一个"多选一"的语法糖。它承载着 Rust 最核心的设计哲学:用类型系统解决运行时问题.
很多初学者第一次接触 Rust 枚举时,会下意识拿它和其他语言中的枚举类比------C 语言中用来定义常量的 enum ,或是 Java 里那种简单的数值枚举。这种类比会让你错过 Rust 枚举最精彩的部分.
Rust 的枚举是一种代数数据类型(Algebraic Data Type). 它不仅能列出所有可能的变体,还能让每个变体携带不同的数据. 配合 match 表达式和泛型,枚举成为了 Rust 中最强大的工具之一------而 Option 正是这一理念的最佳实践.
本文将从枚举的基本定义出发,逐步深入到标准库中的 Option ,带你理解为什么 Rust 敢于抛弃"空值(null)",以及这种设计如何从根本上提升代码的安全性。
2.正文
2.1 enum
枚举里面存放的是值(实例 / 数据), 不是类型. 类型在编译的时候确定的, 运行的时候不存在.
简单的来说, 写代码的时候也就是定义的时候, 指定变体可以携带的数据类型. 等到程序运行的时候, 枚举变量力存放的是具体的值.
虽然第一眼看上去, enum 看上去像是结构体, 但是这两者之间有本质的区别.
结构体中的字段是并且关系, 同时包含了所有字段, 所有字段会同时存在, 用来组合多个属性.
enum中的就只是或者的关系, 只能是其中的一个变体, 只存储最大变体的大小和标签, 用来表示多种可能.
Struct vs Enum
| 特性 | 结构体 (Struct) | 枚举 (Enum) |
|---|---|---|
| 逻辑关系 | 乘法类型 (Product Type) - 字段共存 | 加法类型 (Sum Type) - 变体互斥 |
| 内存占用 | 所有字段大小之和 (+ padding) | 最大变体大小 + 标签 (Tag) |
| 使用场景 | 描述一个物体的多个属性 | 描述一个物体可能存在的多种状态 |
根据官方学习文档中的例子:
IPV4 和 IPV6 是我们现在会遇到的所有可能性的 IP 地址类型. 所以可以枚举出所有的值.
以下代码就是定义一个 IpAddeKind 枚举来表现这个概念并且列出所有可能的 IP 类型, V4 和 V6.
这被称为枚举的成员.
rust
#![allow(unused)]
fn main() {
enum IpAddrKind {
V4,
V6,
}
}
rust
#![allow(unused)]
fn main() {
enum IpAddrKind {
V4,
V6,
}
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
}
可以通过一个函数来获取任何的 IpAddrKind.
rust
#![allow(unused)]
fn main() {
enum IpAddrKind {
V4,
V6,
}
fn route(ip_type: IpAddrKind) { }
}
可以使用函数来获取任意 IP 类型. 但是没有一个具体的实际 IP 值存储方法, 只知道是什么类型的. 这个问题可以使用刚刚学习过的结构体来处理.
rust
#![allow(unused)]
fn main() {
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"),
};
}
这里通过在结构体中创建一个字段类型为 IpAddrKind, 通过创建实例来和数据绑定. 用结构体把 kind 和数据打包在一起, 现在枚举成员和值相关了.
但是现在可以用一种更简洁的方式来表达相同的概念, 仅仅使用枚举并且将数据直接放进每一个枚举成员而不是将枚举作为结构体的一部分.
rust
#![allow(unused)]
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"));
}
现在的两个成员都关联了 String 值, 创建实例的方式如下:
rust
#![allow(unused)]
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"));
}
直接将数据附加到枚举的每个成员上, 就不需要一个额外的结构体了.
使用枚举还可以做到结构体不能办到的事情. IPV4 的IP地址总是含有四个值在0-255之间的数字. 如果同时存储 IPV4 和 IPV6 就不再适用, 因为 adress 字段不能很好同时适应两种类型, 但是枚举可以轻松应对.
rust
#![allow(unused)]
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"));
}
这样将数据直接将数据附加到枚举的每个成员上就不需要一个额外的结构体了.
虽然我们在这里写了这个枚举类型, 但是因为使用频繁, 标准库中提供了一个定义.
rust
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
虽然标准库中也有, 但是没有进行引入作用域不会和自己写的代码发生冲突.
rust
#![allow(unused)]
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
}
rust
#![allow(unused)]
fn main() {
struct QuitMessage; // 类单元结构体
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // 元组结构体
struct ChangeColorMessage(i32, i32, i32); // 元组结构体
}
上面的代码中关联枚举的方法和定义多个不同类型的结构体很像. 处理不使用 struct 关键字以及所有成员都被组合在一起位于 Message 类型下. 如果使用结构体, 因为他们都有不同的类型. 不能像使用 Message 枚举那样. 轻松定义一个能处理这些不同类型的结构体函数, 因为结构体是单独的一个类型.
但是, 枚举和结构体还有一个相似点. 可以像使用 impl 为结构体定义方法那样来为枚举定义方法.
rust
#![allow(unused)]
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
// 在这里定义方法体
}
}
let m = Message::Write(String::from("hello"));
m.call();
}
接下来将介绍的是另一个常见实用的枚举: Option.
enum 是关键字, 而 Option 是标准库中的具体类型. enum 位于语言语法层面, 而 Option 是用 enum 关键字定义出来的一个类型. 区别就像是造房子的工具 和 用工具造出来的房子.
2.2 Option
使用Option之所以广泛是因为它涵盖/实现了一个非常普遍的场景, 即一个值要么有值要么没有值. 从类型系统的角度来表达这个概念就意味着编译器需要检查是否处理了所有应该处理的情况. 这样就可以避免在其他编程语言中非常常见的bug.
Rust没有很多其他语言中有空值的功能. 在有空值的语言中, 变量总是处于这两种状态之中.
空值的问题在于尝试像一个非空值那样使用一个空值, 会出现某种形式的错误, 因为空和非空 的属性无处不在, 非常容易出错.
并且空值尝试表达的概念仍是有意义的: 空值是因为某种原因目前无效或者缺失的值.
问题不在于概念, 而在于具体的实现. 所以 Rust 没有空值, 不过它可以拥有一个编码存在或不存在概念的枚举. 这个枚举就是Option<T> , 而且它定义在标准库中.
因为太有用了, 所以甚至被包含在预导入包 (Prelude) 中, 不用显式引入作用域. 另外它的成员也不用 Option:: 前缀来使用 Some 和 None.
rust
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
<T>是一个泛型类型参数, 是我将在稍后的文章中讲到的Rust功能. 在这里你只需要知道: 和其他编程语言一样, 这里的Some成员可以包含的是任意类型的数据.
rust
#![allow(unused)]
fn main() {
let some_number = Some(5);
let some_string = Some("a string");
let absent_number: Option<i32> = None;
}
如果使用的是 None 而不是 Some, 需要告诉Rust Option<T> 是什么类型的, 编译器只能通过 None 值无法推断出 Some 成员保存的值的类型.
但是当有一个 None 值的时候, 在某种意义上和空值的意义相同: 没有一个有效的值. 所以, 为什么 Option<T> 为什么就比空值好呢?
简单来说, 就是因为 Option<T> 和 T 是不同的类型, 编译器不允许像一个肯定有效的值那样使用 Option<T>.
rust
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
如果运行这段代码就会出现编译错误. 这里不允许这两个类型的相加. 当在 Rust 中拥有一个像 i8 这样类型的值时,编译器确保它总是有一个有效的值。我们可以自信使用而无需做空值检查。只有当使用 Option<i8>(或者任何用到的类型)的时候需要担心可能没有值,而编译器会确保我们在使用值之前处理了为空的情况。
换句话说,在对
Option<T>进行T的运算之前必须将其转换为T。通常这能帮助我们捕获到空值最常见的问题之一:假设某值不为空但实际上为空的情况。不再担心会错误地假设一个非空值,会让你对代码更加有信心。为了拥有一个可能为空的值,你必须要显式地将其放入对应类型的
Option<T>中。接着,当使用这个值时,必须明确地处理值为空的情况。只要一个值不是Option<T>类型,你就 可以 安全地认定它的值不为空。这是 Rust 的一个经过深思熟虑的设计决策,来限制空值的泛滥以增加 Rust 代码的安全性。总的来说,为了使用
Option<T>值,需要编写处理每个成员的代码。你想要一些代码只当拥有Some(T)值时运行,允许这些代码使用其中的T。也希望一些代码在值为None时运行,这些代码并没有一个可用的T值。match表达式就是这么一个处理枚举的控制流结构:它会根据枚举的成员运行不同的代码,这些代码可以使用匹配到的值中的数据。
3.小结
如果这篇文章帮到了你,不妨:
👍 点赞 ------ 让更多人看到这篇教程
⭐ 收藏 ------ 下次直接翻出来看
💬 评论 ------ 遇到任何问题,评论区交流,我会尽力解答
🔖 关注 ------ 介绍 match 控制流运算符.
咱们下篇见!