这次我们来看 Rust 入门阶段最容易把新手卡住、但又是整个语言核心的一部分:自定义类型里的 struct 和 enum,重点会放在“带负载的枚举”上。先给结论:Rust 的枚举和 C/Java 里的“一组常量”完全不是一回事,它可以把不同形状的数据直接“挂在”枚举变体上,再用 match 做穷尽匹配。这个能力直接塑造了标准库里的 Option<T>、Result<T, E>,你写 Rust 越久,越会发现 enum 真的是逃不掉的东西。
这篇文章不是泛泛讲概念,而是会从零带你走完“安装 Rust 工具链 → 用 cargo 建项目 → 写 struct → 写普通枚举 → 写带负载枚举 → 用 match/if let 取数据 → Debug 与 Display 输出 → 蹲一遍常见编译错误 → 总结工程建议”这条完整路线。适合的人群是:已经有 C、Java、Python、TypeScript 等语言基础,想快速搞懂 Rust 自定义类型到底“自定义”在哪里的读者。
1. Rust 自定义类型核心能力速览
| 能力项 | 说明 |
|---|---|
| 主题类型 | Rust 语言基础语法教程,不涉及 GPU、API 服务与批量任务 |
| 核心语法 | struct、enum、带负载枚举、match、if let、impl 方法 |
| 适用读者 | 有编程基础、刚开始学 Rust,或者想对比 C/Java 枚举差异的读者 |
| 工具链 | rustc、cargo,Rust 官方工具链即可 |
| 运行方式 | cargo new 创建项目,cargo run 编译并运行 |
| 调试支持 | #[derive(Debug)]、format!("{:?}")、rustfmt、Clippy |
| 典型应用 | 状态表示、消息处理、简单命令解析、形状计算、错误处理 |
| 第三方依赖 | 本教程不需要第三方库,自定义 Display 即可完成枚举转字符串 |
这节先把信息摆出来:你不需要配置 CUDA、不需要下载模型文件、不需要考虑显存。只需要一台能跑 Rust 的电脑,官方工具链装好就可以开始。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Rust 工具链安装与环境准备
2.1 安装 rustc 和 cargo
如果你想先快速体验语法,可以打开 Rust Playground 在线运行,地址是 https://play.rust-lang.org/。但要在本地写完整项目,建议装官方工具链。
Linux/macOS 下推荐使用 rustup 安装脚本:
bash复制curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
安装完成后,重新加载环境:
bash复制source "$HOME/.cargo/env"
rustc --version
cargo --version
如果能看到类似 rustc 1.x.x 和 cargo 1.x.x 的版本输出,说明工具链已经就绪。Windows 用户从官网下载 rustup-init.exe 后双击运行即可,选默认安装选项就可以。
先说明一个容易混淆的点:这里的“枚举”是类型系统层面的 enum,和算法里的“暴力枚举”、C 语言里把 0、1、2 映射给名字的枚举不是一回事。Rust 的 enum 是“类型构造器”,后面会看到它能带数据。
2.2 创建演示项目
建议单独建一个项目,不要在已有的业务项目里顺手试,避免把 main.rs 改乱。
bash复制cargo new learn_enum
cd learn_enum
此时目录结构是:
text复制learn_enum/
├── Cargo.toml
└── src/
└── main.rs
之后的代码都写在 src/main.rs 里,每次运行都执行:
bash复制cargo run
cargo run 会自动帮你做“编译 + 执行”两步,初次编译会比较慢,后续增量编译会明显变快。
3. struct:自定义类型的第一种写法
3.1 定义与创建实例
struct 是 Rust 中最直观的自定义类型。它把一组相关字段打包成一个整体。先看一个用户信息的例子:
rust复制struct User {
name: String,
email: String,
age: u8,
active: bool,
}
fn main() {
let user = User {
name: String::from("zhangsan"),
email: String::from("zhangsan@example.com"),
age: 38,
active: true,
};
println!("用户: {}, 年龄: {}", user.name, user.age);
}
User 这个类型声明了四个字段,创建实例时必须把每个字段都写清楚。字段的类型可以完全不同,这一点和 C 的结构体类似,但比 C 更安全:编译期就检查字段名和类型是否匹配。
3.2 给 struct 添加方法
Rust 用 impl 块给类型添加方法,入门阶段记住 &self 是“只借用自身”即可:
rust复制impl User {
fn is_adult(&self) -> bool {
self.age >= 18
}
}
fn main() {
let user = User {
name: String::from("zhangsan"),
email: String::from("zhangsan@example.com"),
age: 38,
active: true,
};
if user.is_adult() {
println!("{} 是成年人", user.name);
}
}
除了一般的具名结构体,Rust 还有两种语法糖:
rust复制struct Point(i32, i32); // 元组结构体
struct AlwaysRed; // 单元结构体
let origin = Point(0, 0);
let _red = AlwaysRed;
入门阶段,先掌握普通的具名 struct 就够了,另外两种在阅读项目代码时会遇到。
4. enum:Rust 中的“强类型枚举”
先看一个最简单的枚举定义:
rust复制enum Direction {
Up,
Down,
Left,
Right,
}
fn main() {
let dir = Direction::Up;
println!("方向: {:?}", dir);
}
等等,这段代码直接运行会报错。因为 Direction 没有实现 Debug trait,无法用 {:?} 打印。需要先加上派生宏:
rust复制#[derive(Debug)]
enum Direction {
Up,
Down,
Left,
Right,
}
fn main() {
let dir = Direction::Up;
println!("方向: {:?}", dir);
}
Rust 的普通枚举有几个关键点:
第一,枚举值不能直接写成 0、1、2,必须写 Direction::Up 这种路径形式。C 语言那种“枚举常量就是整数”的做法在 Rust 里是类型错误。
第二,无数据字段的枚举可以通过 as 转成整数:
rust复制let idx = Direction::Up as u8;
println!("{}", idx); // 0
如果不确定顺序,建议先查询文档再做转换,因为序号默认按定义顺序从 0 开始。
第三,普通枚举本质上是在“分类”,它表达“这个东西属于哪一类”。当你还需要“属于这个类,并且带上一份数据”时,就需要带负载枚举了。
5. 带负载枚举:把数据挂在变体上
5.1 为什么需要带负载
想象一个聊天消息系统。消息的类型可能是退出、移动、发送文本、改变颜色。用普通枚举只能写出这个消息是“哪个类型”,却拿不到“文本内容是什么”“移动到哪个坐标”这些信息。带负载枚举的解决办法是:直接在枚举变体上声明字段。
rust复制#[derive(Debug)]
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(u8, u8, u8),
}
拆开看:
Quit:没有携带任何数据,相当于普通枚举的变体。Move { x: i32, y: i32 }:携带两个具名字段,类似一个匿名的 struct。Write(String):携带一个 String,采用元组式写法。ChangeColor(u8, u8, u8):携带三个 u8。
这就是“带负载”三个字的含义。同一个 Message 类型,不同变体可以携带完全不同的数据结构。
5.2 构造带负载枚举
构造方式要和变体定义对应:
rust复制fn main() {
let msg1 = Message::Quit;
let msg2 = Message::Move { x: 10, y: 20 };
let msg3 = Message::Write(String::from("hello rust"));
let msg4 = Message::ChangeColor(255, 0, 0);
println!("{:?}", msg1);
println!("{:?}", msg2);
println!("{:?}", msg3);
println!("{:?}", msg4);
}
注意这里有一个初学者容易踩的坑:msg3 和 msg4 是“元组式变体”,不能用字段名访问,比如 msg3.text 是错的。Move 里的 x、y 是“命名字段”,但也不能用 msg2.x 直接拿,必须通过模式匹配把数据取出来。这正是带负载枚举和 struct 最大的区别:数据访问不是“点字段”,而是“匹配解构”。
6. match 与 if let:枚举的标准处理器
6.1 用 match 取出枚举负载
Rust 中处理枚举的标准工具是 match。下面的函数接收一个 Message,并打印出对应的信息:
rust复制fn process(msg: Message) {
match msg {
Message::Quit => println!("收到退出信号"),
Message::Move { x, y } => println!("移动目标: ({x}, {y})"),
Message::Write(text) => println!("收到文本: {text}"),
Message::ChangeColor(r, g, b) => println!("设置颜色为 RGB({r}, {g}, {b})"),
}
}
fn main() {
let messages = vec![
Message::Move { x: 10, y: 20 },
Message::Write(String::from("hello")),
Message::ChangeColor(255, 0, 0),
Message::Quit,
];
for msg in messages {
process(msg);
}
}
这里最关键的是“穷尽性检查”。match 必须处理完所有变体,漏掉任何一个都会编译失败,错误信息类似:
text复制error[E0004]: non-exhaustive patterns
当你给现有枚举新增一个变体时,编译器会强制你去所有 match 代码里补处理。这是好事,它把“漏处理分支”的问题提前到编译期暴露出来。
如果确实不需要关心某些变体,可以加一个 _ 通配分支:
rust复制match msg {
Message::Quit => println!("退出"),
_ => println!("其他消息"),
}
但要注意,使用 _ 会掩盖穷尽性检查的好处,能用显式分支还是尽量显式。
6.2 借用与移动:匹配时注意所有权
按值 match msg 会把 Message 整体移动进去。如果后面还想用 msg,通常改成传引用:
rust复制fn process(msg: &Message) {
match msg {
Message::Quit => println!("退出"),
Message::Move { x, y } => println!("移动: ({x}, {y})"),
Message::Write(text) => println!("文本: {text}"),
Message::ChangeColor(r, g, b) => println!("颜色: ({r}, {g}, {b})"),
}
}
fn main() {
let msg = Message::Write(String::from("hello"));
process(&msg);
println!("仍然可以使用: {:?}", msg);
}
因为匹配的是 &Message,所以不会把 msg 移走。
6.3 if let:只关心一个变体时更简洁
如果只想判断“是不是某个变体”,用 match 会显得有些笨重:
rust复制let msg = Message::ChangeColor(0, 128, 255);
if let Message::ChangeColor(r, g, b) = msg {
println!("蓝色分量: {b}");
}
if let 会尝试把左侧模式匹配到右侧值,匹配成功才进入分支。如果还要处理匹配失败的情况,可以加 else:
rust复制if let Message::Write(text) = &msg {
println!("文本长度: {}", text.len());
} else {
println!("这条消息不是 Write 类型");
}
这是很常用的语法,尤其适合判断 Option 和 Result 的场景。
7. 枚举类型转字符串与调试输出
7.1 用 Debug 先看个大概
#[derive(Debug)] 是最快的调试方案:
rust复制#[derive(Debug)]
enum Status {
Active,
Paused { reason: String },
Dead(String),
}
fn main() {
let status = Status::Paused {
reason: String::from("user request"),
};
println!("{:#?}", status);
}
{:#?} 会带缩进输出,比 {:?} 更好读。但 Debug 的格式并不适合给用户展示,因为它带着变体名和结构信息。
7.2 手动实现 Display 做正式输出
如果想让枚举输出成更自然的内容,可以实现 std::fmt::Display:
rust复制use std::fmt;
#[derive(Debug)]
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(u8, u8, u8),
}
impl fmt::Display for Message {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Message::Quit => write!(f, "quit"),
Message::Move { x, y } => write!(f, "move({x}, {y})"),
Message::Write(text) => write!(f, "write({text})"),
Message::ChangeColor(r, g, b) => write!(f, "color({r}, {g}, {b})"),
}
}
}
fn main() {
let msg = Message::Move { x: 10, y: 20 };
println!("{msg}");
}
实现 Display 之后,println!("{}", msg) 就会输出 move(10, 20) 这种自定义文本。如果项目里有大量枚举需要做字符串转换,可以调研 strum 库的 Display、EnumString 派生宏,但入门阶段先手写一遍,理解底层机制更值得。
8. 实战示例:带负载枚举做面积计算
把前面内容串起来,做一个很常见的例子:用枚举表示不同形状,每个形状携带自己的参数,然后用 match 计算面积。
rust复制#[derive(Debug, Clone, Copy)]
enum Shape {
Circle(f64),
Rectangle { width: f64, height: f64 },
}
impl Shape {
fn area(self) -> f64 {
match self {
Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
}
}
}
fn main() {
let shapes = vec![
Shape::Circle(5.0),
Shape::Rectangle { width: 3.0, height: 4.0 },
];
for shape in shapes {
println!("面积: {:.2}", shape.area());
}
}
输出应该是:
text复制面积: 78.54
面积: 12.00
这个小示例展示了一个重要工程结论:如果一个类型可能表示“不同形状”,而且不同形状需要携带不同字段时,带负载枚举比“多个 struct + 分类判断”更简洁。你不需要维护 is_circle、circle_radius、rectangle_width 这类散落的字段,所有数据都在枚举变体上,逻辑也统一收敛在 match 分支里。
如果还想模拟“命令解析”,可以定义一个 Command 枚举:
rust复制enum Command {
ShowHelp,
SetTimeout(u64),
Rename { from: String, to: String },
}
然后对不同变体做分支处理。这种写法在 CLI 工具、配置解析、状态机里大量出现。
9. 常见编译错误与排查清单
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
non-exhaustive patterns |
match 没有覆盖所有枚举变体 |
查看错误提示缺了哪个变体 | 补全分支,或加 _ => 通配分支 |
赋值 let dir = Direction::Up as u8 与预期不符 |
把无字段枚举当整数常量使用 | 确认枚举顺序 | 无字段枚举才可用 as 转整数,带负载枚举禁止 |
the trait \Debug` is not implemented for ...` |
枚举没有实现 Debug 就打印 | 检查 println!("{:?}", ...) |
加 #[derive(Debug)] |
cannot move out of \msg`` |
按值 match 后又想继续使用 msg |
检查是否有后续访问 | 改用 match &msg,或调整所有权逻辑 |
no field \x` on type `Message`` |
尝试用点号直接访问变体字段 | 检查变体定义 | 先 match 或 if let 取出字段 |
| 带负载枚举无法转数字 | 想用 as u8 转换 |
确认枚举是否带有字段 | 手动写 match 做映射 |
编译时出现大量 use 警告 |
没有正确导入 trait | 检查 impl 和 use |
按编译器提示补充或删除 |
调试枚举相关代码时,建议先运行 cargo check,它只做类型检查,比 cargo run 更快:
bash复制cargo check
另外,cargo run 启动后如果出现异常中断,可以设置:
bash复制RUST_BACKTRACE=1 cargo run
这会打印完整的栈回溯,方便定位 panic 位置。
10. 最佳实践:什么时候该用 enum
并不是所有自定义类型都必须做成 enum。下面几条是入门阶段比较实用的判断依据。
第一,如果你确实需要“同一类事物,不同形态带不同数据”,优先用带负载枚举。比如消息、命令、状态机状态、AST 节点、网络事件,都是典型场景。
第二,如果每个形态都有大量自己的逻辑,且不同形态之间几乎没有共性,可以在 enum 变体里放一个 struct,而不是无限增加变体字段。
rust复制struct LoginPayload {
username: String,
password: String,
}
enum UserEvent {
Login(LoginPayload),
Logout,
}
这样变体内部仍然保持清晰,不会出现十几个字段堆在一个变体里的情况。
第三,普通枚举适合“只做分类”的场景。枚举的变体数量建议保持克制,如果超过十几个,往往意味着设计上可以进一步分组。
第四,方法写在 impl 块里,而不是用一堆外部函数去判断枚举内部数据。把“如何处理枚举”和枚举本身放在一起,代码阅读起来会更流畅。
第五,和标准库的 Option<T>、Result<T, E> 结合使用。Option 本质就是一个带负载枚举,Result 也是:
rust复制enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
所以你现在学到的枚举知识,直接会用在几乎所有 Rust 项目里。后续学习泛型时,再回头看这两类标准枚举会非常顺。
11. 总结:一条可以直接照做的学习路径
最值得掌握的核心点就是:Rust 的枚举不只是“常量集合”,它可以通过变体携带数据,配合 match 实现穷尽匹配,安全性由编译器保证。最容易踩的三个坑是:match 漏分支导致编译失败、带负载枚举不能直接用点号取字段、按值 match 会把枚举移动掉导致后续无法使用。
建议按照下面的顺序快速验证理解:
- 创建
cargo new learn_enum项目。 - 定义一个
Direction普通枚举,打印出 Debug 输出。 - 定义一个
Message带负载枚举,分别用Match和if let取出Write(String)里的文本。 - 实现
Display,让枚举输出自定义字符串。 - 模仿
Shape::area做一次带负载枚举的面积计算。
下一步可以从两个方向深入:一是学习泛型,然后重点理解 Option<T> 和 Result<T, E> 的使用;二是学习模式匹配的高级语法,比如嵌套模式、@ 绑定、match 守卫。先把枚举这条路走通,Rust 的自定义类型就算入门了。
