不少刚接触 Rust 的同学,第一眼看到 enum 时都会下意识地用 C/Java 的思路去理解:“这不就是一组具名常量吗?”等真正开始写业务代码后才意识到,Rust 里的枚举完全不是这么回事。尤其当你看到某个枚举成员居然能“背着”一段数据出现时,往往会有一种“原来类型还能这么设计”的感觉。
本文就从 Rust 自定义类型中最容易低估的 enum 入手,先讲清楚传统枚举和 Rust 枚举的区别,再重点拆解“带负载枚举”(也叫数据携带枚举)的定义、匹配、方法实现和实战用法。我会用支付场景作为完整案例,并补充高频踩坑点和工程建议。看完之后,你不仅能看懂 Option<T>、Result<T, E> 这类标准库枚举,还能顺手写出适合业务建模的自定义枚举。
搜索“枚举”这个关键词,很容易看到“暴力枚举算法”“状压 DP 枚举子集”等算法题内容。这里先做一个区分:本文谈论的是 Rust 语言层面的 enum 自定义类型,不是算法中的枚举遍历。搞清楚这个前提,后续内容就不会串味。
1. 为什么 Rust 的枚举值得单独学
1.1 传统枚举的局限
先来看一段很常见的 C 语言枚举:
c复制enum Color {
RED,
GREEN,
BLUE
};
这种枚举的本质是一组具名常量。它确实能表达“某个值属于几类之一”,但当业务需要额外信息时就开始别扭了。比如你要表示一个订单状态“已支付”,通常还需要“支付时间”;表示“已发货”,还要带“物流单号”。传统枚举无法把这些信息挂在成员上,只能接着写结构体、类,再额外维护一套转换关系。
Java 的枚举虽然可以用构造方法带字段,但使用时依然偏“单值 + 方法”模式。Rust 则把“枚举成员 + 独立数据”做成了语言级特性:每个枚举变体都可以拥有完全不同的数据类型,而且由编译器强制约束。这不是语法糖,而是建模能力的差异。
1.2 Rust 枚举的定位:标签联合体
Rust 中的 enum 是真正的自定义类型。每个枚举成员称为“变体”(variant),变体之间通过一个隐式的标签区分。抽象地说,这种类型又叫“带标签的联合”(tagged union),或者更贴近函数式编程习惯的说法:代数数据类型(ADT)里的“求和类型”。
求和类型的含义是:一个枚举值在某一时刻只能取其中一种变体,不可能同时是“信用卡”又是“银行转账”。但因为每个变体可以挂载数据,这种“多选一”建模能力比传统枚举强得多。
举一个直观的例子:
rust复制enum Payment {
CreditCard {
number: String,
holder: String,
},
PayPal {
email: String,
},
BankTransfer {
iban: String,
},
}
CreditCard 携带卡号和持卡人,PayPal 携带邮箱,BankTransfer 携带银行账号。每个变体的数据类型都不一样,但都被安全地收在同一个 Payment 类型之下。这就是“带负载枚举”最典型的形态。
1.3 适合用枚举的业务场景
带负载枚举最适合解决“同一类事物,不同分支的附加数据差异很大”的场景。常见的有:
- 订单状态机:待支付、已支付、已退款。已支付可能带支付时间,已退款可能带退款原因。
- 网络协议栈:不同类型的请求/响应帧,各自携带不同字段。
- 自定义错误类型:
Error::Network { host, code }和Error::Parse { line, message }放在同一个Error枚举里。 - 抽象语法树节点:表达式、语句、函数声明等不同类型的语法节点,信息构成完全不同。
- 配置系统:多个互斥配置项,每个配置项有独立的参数结构。
当你发现代码里到处都是“类型判断 + 强制转换”,或者不得不写大量带 Option 的结构体时,就应该考虑用带负载枚举重新建模。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与项目初始化
2.1 安装 Rust 工具链
在开始写代码之前,先确认本机已经安装 Rust。官方推荐通过 rustup 管理工具链。Linux/macOS 常见安装命令是:
bash复制curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Windows 环境建议去 Rust 官网下载 rustup-init.exe,或者先用 winget install Rustlang.Rustup 这类包管理器安装。装完后重启终端,检查版本:
bash复制rustc --version
cargo --version
只要命令能正常输出版本号,就说明环境可用。需要提醒的是,Rust 的版本迭代速度较快,实际项目中的最小版本要求以你使用的依赖库为准。本文示例不依赖第三方 crate,使用普通 stable 工具链即可运行。
2.2 创建示例项目
我们用 Cargo 新建一个项目,命名为 rust-enum-demo:
bash复制cargo new rust-enum-demo
cd rust-enum-demo
Cargo 是 Rust 官方的构建和包管理工具。cargo new 会生成一个最小可运行的二进制项目,包括 Cargo.toml 和 src/main.rs。后面的示例代码都写在 src/main.rs 中,不需要额外配置依赖。
2.3 项目结构说明
创建完成后,项目结构大致如下:
text复制rust-enum-demo
├── Cargo.toml
└── src
└── main.rs
Cargo.toml 管理项目元信息和依赖,src/main.rs 是程序入口。你可以在同一个目录下创建多个示例文件,但为了保持教程连贯,我们集中在一个文件中演示。这样也方便你复制整段代码直接运行。
3. Rust 枚举的基础语法
3.1 最简单的 C 风格枚举
先看一个没有数据的枚举定义:
rust复制#[derive(Debug)]
enum Weekday {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
}
这里 #[derive(Debug)] 让 Weekday 可以使用 {:?} 格式化输出。Rust 枚举默认不能直接打印,如果不加 Debug 派生,调试时会报错提示“Weekday doesn't implement Debug”。
使用枚举成员时需要带上类型前缀:
rust复制fn main() {
let today = Weekday::Monday;
println!("today = {:?}", today);
}
这里 Weekday::Monday 就是“创建一个值为 Monday 的 Weekday”。这种写法和其他语言的静态常量访问类似,但背后的类型模型完全不同。
需要注意:Weekday 类型在没有显式 #[repr(...)] 时,并不保证底层是整数。虽然某些场景下它确实像 C 枚举,但 Rust 编译器允许对它做更自由的布局优化。
3.2 用 match 穷尽匹配
match 是与枚举配套使用的核心控制流。它要求覆盖所有可能分支,否则编译失败:
rust复制fn describe(day: Weekday) -> &'static str {
match day {
Weekday::Monday => "周一",
Weekday::Tuesday => "周二",
Weekday::Wednesday => "周三",
Weekday::Thursday => "周四",
Weekday::Friday => "周五",
}
}
假设 Weekday 只定义了这五个成员,上面的 match 正好穷尽。如果你漏掉 Weekday::Friday,编译器会报 non-exhaustive match 错误。这个特性对代码维护很有价值:以后给枚举新增成员时,编译器会列出所有需要更新的 match 分支,避免漏改业务逻辑。
如果确实不想处理全部成员,可以用 _ 作为兜底分支:
rust复制fn is_workday(day: Weekday) -> bool {
match day {
Weekday::Saturday | Weekday::Sunday => false,
_ => true,
}
}
但这里出现了问题:Weekday 没有 Saturday/Sunday。由于我们用 _ 兜底,编译器不会检查漏分支,所以实际写业务代码时要谨慎使用 _,否则会掩盖枚举扩展后需要处理的逻辑。
3.3 为枚举添加方法
枚举和 struct 一样可以使用 impl 块添加方法:
rust复制impl Weekday {
fn is_weekend(&self) -> bool {
match self {
Weekday::Saturday | Weekday::Sunday => true,
Weekday::Monday
| Weekday::Tuesday
| Weekday::Wednesday
| Weekday::Thursday
| Weekday::Friday => false,
}
}
}
match self 中 self 是 &Weekday,Rust 会自动解引用匹配,不会转移所有权。方法内部可以读取枚举值,也可以根据枚举值返回其他类型。通过 impl,枚举不再只是“数据定义”,而是具备行为能力的自定义类型。
4. 带负载枚举:让数据跟着变体走
4.1 元组形式负载
当某个变体需要携带少量且语义不太复杂的数据时,可以使用元组形式:
rust复制enum Message {
Quit,
Move(i32, i32),
Write(String),
ChangeColor(u8, u8, u8),
}
Move(i32, i32) 表示消息携带两个整数坐标,Write(String) 表示携带一段文本。这种形式和元组结构体风格一致,适合字段顺序能表达语义、不太需要命名的情况。
匹配元组负载时,可以直接用变量接收位置数据:
rust复制fn handle(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),
}
}
这里要强调一个细节:如果 Message 没有实现 Copy,那么 match msg 在匹配 Message::Write(text) 时会尝试把 text 从 msg 中移出来。一旦第一个分支匹配并移动了字段,后续分支就不能再使用 msg 整体。因此在实际代码中,如果后续还需要使用原来的枚举值,建议写 match &msg,得到的是字段的引用;如果明确不再需要原值,按值匹配反而更简洁。
4.2 结构体形式负载
当变体字段较多、或者字段名本身很重要时,使用结构体形式可读性更好:
rust复制enum UserCommand {
Login {
username: String,
password: String,
},
SendMessage {
to: String,
content: String,
},
Logout,
}
匹配时通过字段名解构:
rust复制fn execute(cmd: &UserCommand) {
match cmd {
UserCommand::Login { username, password } => {
println!("登录用户: {},密码长度: {}", username, password.len());
}
UserCommand::SendMessage { to, content } => {
println!("发送给 {}: {}", to, content);
}
UserCommand::Logout => {
println!("退出登录");
}
}
}
由于匹配的是 &UserCommand,username、password、to、content 都会被推断为 &String,不会发生所有权转移。如果不需要某个字段,用 .. 忽略剩余字段:
rust复制UserCommand::Login { username, .. } => println!("登录: {}", username),
4.3 if let 与单分支处理
有时候你只关心某一种变体,用 match 写全部分支会显得啰嗦。if let 是更轻量的选择:
rust复制let msg = Message::Move(10, 20);
if let Message::Move(x, y) = msg {
println!("触发移动: ({}, {})", x, y);
}
if let 本质上是把“匹配某个模式”和“条件执行”结合在一起。它省去了 match 必须穷尽所有分支的负担,但代价是其他变体会被静默忽略。如果只有一种分支需要处理,if let 是最好的选择;如果多个分支有不同逻辑,还是应该回到 match。
5. 完整实战:用枚举建模支付类型并计算手续费
5.1 需求分析
这个案例的目的是把带负载枚举放进一个相对完整的业务场景里。我们模拟一个第三方支付系统,需要支持三种支付方式:
- 信用卡:包含卡号、持卡人姓名、CCV 验证码。
- PayPal:包含邮箱地址。
- 银行转账:包含 IBAN 国际银行账号。
每种支付方式的手续费率不同:
- 信用卡:2%
- PayPal:3.5%
- 银行转账:0.5%
同时,每种支付方式有各自的校验规则。我们的目标是:通过一个 Payment 结构保存金额和支付方式,再通过函数计算手续费,并在主函数中演示不同类型支付方式的完整流程。
5.2 定义支付相关类型
在 src/main.rs 中写入如下内容:
rust复制use std::fmt;
#[derive(Debug, Clone)]
enum PaymentMethod {
CreditCard {
number: String,
holder: String,
ccv: String,
},
PayPal {
email: String,
},
BankTransfer {
iban: String,
},
}
#[derive(Debug)]
struct Payment {
amount: f64,
method: PaymentMethod,
}
PaymentMethod 的每个变体携带不同数据,这正是带负载枚举的核心形态。#[derive(Clone)] 主要用于后续可能存在的复制需求,不是必须项,但保留它可以让枚举在测试中更灵活。Payment 是一个普通结构体,把“金额”和“支付方式”组合在一起。
5.3 实现方法:费率、名称展示
接下来给 PaymentMethod 添加两个方法。第一个返回手续费率,第二个返回适合打印给用户的摘要信息。
rust复制impl PaymentMethod {
fn fee_rate(&self) -> f64 {
match self {
PaymentMethod::CreditCard { .. } => 0.02,
PaymentMethod::PayPal { .. } => 0.035,
PaymentMethod::BankTransfer { .. } => 0.005,
}
}
fn summary(&self) -> String {
match self {
PaymentMethod::CreditCard { holder, .. } => {
format!("信用卡(持卡人: {})", holder)
}
PaymentMethod::PayPal { email } => {
format!("PayPal(账号: {})", email)
}
PaymentMethod::BankTransfer { iban } => {
format!("银行转账(IBAN: {})", iban)
}
}
}
}
match self 中的 self 类型是 &PaymentMethod,因此 CreditCard { holder, .. } 拿到的 holder 是 &String,不会发生移动。.. 用来表示“忽略剩余字段”,因为我们只关心 holder。
如果你希望直接在 println! 中输出 PaymentMethod,可以手动实现 fmt::Display:
rust复制impl fmt::Display for PaymentMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.summary())
}
}
Display 是面向用户的格式化输出 trait。实现之后,println!("{}", payment.method) 就能直接工作,不需要每次调用 .summary()。
5.4 编写支付校验与手续费计算逻辑
核心处理函数接收 &Payment,先做金额校验,再按不同支付方式做字段校验,最后计算手续费并返回 Result:
rust复制fn process_payment(payment: &Payment) -> Result<f64, String> {
if payment.amount <= 0.0 {
return Err("支付金额必须大于 0".to_string());
}
match &payment.method {
PaymentMethod::CreditCard { number, ccv, .. } => {
if number.len() < 12 {
return Err("信用卡号格式不正确".to_string());
}
if ccv.len() != 3 {
return Err("CCV 必须为 3 位数字".to_string());
}
}
PaymentMethod::PayPal { email } => {
if !email.contains('@') {
return Err("PayPal 邮箱格式不正确".to_string());
}
}
PaymentMethod::BankTransfer { iban } => {
if iban.len() < 15 {
return Err("IBAN 长度不正确".to_string());
}
}
}
let fee = payment.amount * payment.method.fee_rate();
Ok(fee)
}
这里使用 match &payment.method 非常关键。如果写成 match payment.method,Rust 会尝试把 payment.method 的所有权移出,后续再调用 payment.method.fee_rate() 就会报“value moved”错误。使用引用匹配,我们既能读取字段做校验,又不会破坏原结构体。
5.5 完整主函数与运行结果
接下来组合一个主函数,创建两笔支付,分别走信用卡和 PayPal:
rust复制fn main() {
let payment1 = Payment {
amount: 100.0,
method: PaymentMethod::CreditCard {
number: "123456789012".to_string(),
holder: "Alice".to_string(),
ccv: "123".to_string(),
},
};
let payment2 = Payment {
amount: 200.0,
method: PaymentMethod::PayPal {
email: "alice@example.com".to_string(),
},
};
let payments = [&payment1, &payment2];
for payment in payments {
match process_payment(payment) {
Ok(fee) => println!(
"支付方式:{},金额:{:.2},手续费:{:.2}",
payment.method, payment.amount, fee
),
Err(e) => println!("支付失败:{}", e),
}
}
}
运行项目:
bash复制cargo run
预期输出:
text复制支付方式:信用卡(持卡人: Alice),金额:100.00,手续费:2.00
支付方式:PayPal(账号: alice@example.com),金额:200.00,手续费:7.00
信用卡费率 2% 对应 2.00,PayPal 费率 3.5% 对应 7.00。这个输出说明:
PaymentMethod::fee_rate()能根据枚举变体正确返回不同费率。- 通过
Display实现,{}可以直接打印枚举摘要。 - 带负载枚举的数据被安全地解构和读取。
- 整个流程没有借助字符串判断或类型转换,逻辑完全由枚举驱动。
6. 常用转换:枚举与字符串、整数的互相转换
6.1 从字符串解析为枚举
业务开发里经常需要把 "credit_card" 这样的字符串解析成枚举。如果是有负载枚举,通常的做法是先解析外层类型,再用单独的方法补全数据。这里用一个简单的无负载枚举演示通用思路:
rust复制#[derive(Debug, Clone, Copy)]
enum Language {
Rust,
Java,
Python,
}
impl Language {
fn parse(input: &str) -> Option<Language> {
match input.trim().to_lowercase().as_str() {
"rust" => Some(Language::Rust),
"java" => Some(Language::Java),
"python" => Some(Language::Python),
_ => None,
}
}
}
trim() 去掉首尾空白,to_lowercase() 避免大小写问题。返回 Option<Language> 而不是直接返回 Language,是因为输入字符串可能不属于任何已知类型。调用方式:
rust复制fn main() {
let lang = Language::parse(" Rust ");
println!("{:?}", lang);
}
输出 Some(Rust)。如果解析失败,则得到 None,调用方必须处理这种可能性。
6.2 枚举转整数
对于没有携带数据的枚举,可以使用 as 转换成整数。为了让底层值可控,可以加上 #[repr(u8)]:
rust复制#[repr(u8)]
enum Priority {
Low = 1,
Medium = 5,
High = 10,
}
fn main() {
let p = Priority::High;
println!("High = {}", p as u8);
}
这里 #[repr(u8)] 指定枚举的内存表示为一个 u8,Low 的值是 1,Medium 是 5,High 是 10。如果没有 #[repr],虽然某些情况下 as 也可以转为整数,但底层具体取值是不可预测的,尽量不要依赖。
需要特别注意:如果枚举变体携带了数据,比如 Message::Move(i32, i32),就不能用 as 转成整数,因为此时的枚举值不只是标签,还包含额外数据。强行 as 会得到编译错误。
6.3 枚举转字符串与 Display
枚举转字符串最自然的方式是实现 Display。对于无负载枚举,可以这样写:
rust复制impl fmt::Display for Language {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Language::Rust => "rust",
Language::Java => "java",
Language::Python => "python",
};
write!(f, "{}", s)
}
}
如果不想手动写,可以引入第三方 crate strum,在枚举上标注 #[derive(strum::Display)],自动生成 Display 实现。但要注意,引入额外依赖之前要考虑可维护性和项目体量,手动实现通常也只需要几分钟,而且不依赖外部代码生成。
7. 常见问题与排查思路
7.1 高频错误对照表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
报错 non-exhaustive match |
match 没有覆盖所有枚举变体 |
补齐缺失分支,或使用 _ => ... |
报错 cannot move out of ... |
通过值匹配枚举,导致字段被移动 | 改用 match &value,匹配字段的引用 |
报错 X doesn't implement Debug |
枚举没有派生 Debug |
添加 #[derive(Debug)] 或实现 Display |
带负载枚举无法用 as 转整数 |
非 C 风格枚举包含额外数据 | 不依赖 as,通过方法返回需要的数据 |
字符串解析总是返回 None |
大小写、空格等原因导致匹配不上 | 先 trim().to_lowercase() 再匹配 |
| 打印枚举时出现移动错误 | 使用 println!("{}", value) 且 Display 未实现 |
为类型实现 Display,或使用 {:?} |
7.2 两个值得展开的案例
第一个是“无法打印”的问题。Rust 的 println! 要求类型实现 Debug 或 Display。初学者最容易遇到的组合是:
rust复制enum Simple { A, B }
fn main() {
println!("{:?}", Simple::A);
}
上面这段代码无法编译,因为 Simple 没有实现 Debug。解决办法是在枚举定义前加 #[derive(Debug)]。如果希望用 {} 打印,则需要实现 Display。两者的用途不一样:Debug 偏调试,Display 偏用户输出。
第二个是“match 匹配后无法继续使用原值”。下面的代码会报编译错误:
rust复制enum Data {
Text(String),
}
fn main() {
let data = Data::Text("hello".to_string());
match data {
Data::Text(s) => println!("{}", s),
}
println!("{:?}", data); // 错误:data 已被移动
}
因为 match data 按值匹配,s 从 data 中把 String 移动走了,后续无法再使用 data。如果想要“借用枚举内部数据”,应该写成 match &data。这是 Rust 所有权系统和枚举交互时最需要反复练习的点。
8. 枚举最佳实践与工程建议
8.1 用枚举代替魔法字符串
业务代码中经常能看到 status == "PAID" 这样的字符串判断。一旦字符串拼写错误,编译器不会发现。使用枚举可以把这个风险前置到编译期:
rust复制#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OrderStatus {
Pending,
Paid,
Shipped,
Cancelled,
}
这样不仅避免拼写错误,还更容易配合 match 穷尽处理所有状态。比如订单状态流转时,编译器能提醒你新加的状态没有在某个模块中处理。
8.2 与 Option/Result 配合使用
自定义枚举不应该替代 Option 和 Result 的位置。Option 表示“可能有值,可能没有”,Result 表示“成功或失败”,它们是标准库最常用的枚举。自定义枚举通常用来建模领域中的多态数据,而不是错误处理。
如果业务中确实需要更丰富的错误信息,推荐基于通用错误枚举:
rust复制use std::fmt;
#[derive(Debug)]
enum AppError {
InvalidInput(String),
Timeout { service: String, seconds: u64 },
Unknown,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::InvalidInput(msg) => write!(f, "输入无效: {}", msg),
AppError::Timeout { service, seconds } => {
write!(f, "服务 {} 超时,耗时 {}s", service, seconds)
}
AppError::Unknown => write!(f, "未知错误"),
}
}
}
8.3 公共 API 的新增变体策略
如果你正在写一个会被其他模块或其他项目使用的库,枚举的演进需要谨慎。直接给公共枚举新增变体,可能导致下游所有 match 编译失败。Rust 提供了 #[non_exhaustive] 标注,告诉编译器这是一个允许未来扩展的枚举:
rust复制#[non_exhaustive]
enum PublicEvent {
Created,
Updated,
}
下游代码即使把 match 写全覆盖了,也必须额外加一个 _ 分支,因为编译器意识到上游未来可能新增变体。这个特性适合对外发布的 crate,在内部模块中也可以按需使用。
8.4 字段过多时重新设计
如果一个枚举变体携带五六个字段,写起来会很长。可以考虑把多个字段抽成结构体,再让变体持有这个结构体:
rust复制struct CreditCardInfo {
number: String,
holder: String,
ccv: String,
expiry: String,
}
enum PaymentMethod {
CreditCard(CreditCardInfo),
PayPal { email: String },
}
这样既保留“枚举变体可以携带数据”的优点,又不让枚举定义变得臃肿。match 中可以用 PaymentMethod::CreditCard(info) 拿到整个结构体,再访问内部字段。
8.5 性能与内存占用
带负载枚举的大小由最大的变体决定。比如一个枚举同时包含 Empty 和 BigPayload([u8; 1024]),那么每个枚举值都会占至少 1024 字节,即使当前值是 Empty。如果这类大对象比较常见,可以用 Box 包住大字段:
rust复制enum Request {
Empty,
Large(Box<[u8; 1024]>),
}
Box 把大数据放到堆上,栈上只保存一个指针,从而降低枚举的整体大小。这属于优化手段,业务建模时不必过度设计,遇到真实性能瓶颈再调整即可。
9. 总结与下一步学习建议
这篇文章从“传统枚举不够用”这个痛点出发,重点讲解了 Rust 带负载枚举是什么、怎么定义、怎么匹配,以及如何放进真实业务场景。通过支付手续费的案例,你应该已经理解:枚举不只是常量集合,它可以把不同类型的数据安全地放进同一个类型中,并用 match 做穷尽处理。
接下来可以从两个方向继续深入。第一是研究标准库的 Option 和 Result,它们本质上都是枚举,源码里的 map、unwrap_or_else 等常用方法能帮你更透彻地理解枚举的用法。第二是学习 FromStr、serde 这类 trait 和序列化方案,让枚举可以方便地在字符串、JSON、数据库字段之间转换。
学枚举最好的方式不是背语法,而是把日常写的 if-else 分支改写成 match,把一个又一个“状态字符串”替换成真正的枚举类型。刚开始会不习惯,但当你发现编译器开始主动提醒你哪里漏了分支时,就会明白 Rust 这种设计到底有多可靠。如果本文对你有帮助,建议收藏备用,后续开发中遇到“多选一但每个选项数据不同”的需求时,可以随时回来对照这份笔记。
