外观
第 5 章 动物叫声模拟器
小螃蟹说
这一章我们要开一家"动物叫声机"!你喊出动物的名字,它就替你叫出那种动物的声音。猫?喵~ 狗?汪汪!奶牛?哞~
还记得第 2 章结尾我预告过的吗?当时我们说 Ordering::Less 里的 Less 是一种叫枚举的东西的变体,还说第 5 章要让它大显身手——就是今天!
这一章之后,你将正式掌握 Rust 里最常用的两个工具:match 和枚举。它们俩是黄金搭档,几乎每个 Rust 程序里都有它们的身影。
这一章还藏着一个非常重要的新朋友——Option。它是 Rust 处理"可能有,可能没有"的专用工具。学会了它,你就再也不用担心程序里突然冒出一个"空指针"了。
5.1 预览:我们要做什么
- 输入动物的名字(猫/狗/牛/鸭/羊)
- 程序找出这只动物,播放它的叫声
- 输入不认识的动物(比如"恐龙"),程序礼貌拒绝
- 输入"退出"结束,报告一共叫了多少声
运行结果
text
欢迎来到动物叫声模拟器!
输入动物名字(猫/狗/牛/鸭/羊),听它说话。输入"退出"结束。
动物?
猫
猫说:喵~
动物?
恐龙
恐龙?不认识这种动物哦。
动物?
牛
牛说:哞~
动物?
退出
一共叫了 2 声。再见!这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
| 枚举 | 从菜单里选一个的"选项卡" | Animal::Cat、Animal::Dog…… |
| 变体 | 枚举里的每一个选项 | 五只动物,五个变体 |
Option | 可能有,可能没有 | 按名字找动物,找不到就是 None |
match 穷尽 | 所有选项必须全列出来 | 编译器替你把每一种动物都管到 |
_ 通配符 | "其它情况"的兜底选项 | 不认识的动物名 |
5.2 动手做
步骤一:新建项目
老规矩:
bash
cargo new animal_sounds
cd animal_sounds步骤二:设计动物菜单
第 4 章的 struct Pet 是一张"全都要填"的表格:名字、种类、年龄、可爱度,一栏不能少。
这一章我们不需要表格,我们需要一张菜单:猫、狗、牛、鸭、羊,五选一。这种"从几个选项里选一个"的类型,就叫枚举(enum,读作"衣娜姆",enumeration 的缩写)。
把 src/main.rs 替换成:
rust
#[derive(Debug)]
enum Animal {
Cat,
Dog,
Cow,
Duck,
Sheep,
}
fn main() {
println!("欢迎来到动物叫声模拟器!");
}看这张菜单:
enum Animal { ... }声明了一种叫Animal的枚举- 大括号里的每一项是一个变体(variant)——菜单上的一道菜:猫、狗、牛、鸭、羊
- 变体名也用大驼峰(和结构体、枚举名一样)
#[derive(Debug)] 是第 4 章学过的调试魔法,先写上,等下我们打印动物试试。
结构体 vs 枚举,一句话记住
结构体是"表格",每一栏都要填;枚举是"菜单",只能选一道菜。 宠物档案要四样信息全填 → 结构体;动物世界五选一 → 枚举。
步骤三:让动物说话
现在让菜单上的动物开口。在 enum Animal 的下面,加 impl 块:
rust
impl Animal {
fn speak(&self) -> &'static str {
match self {
Animal::Cat => "喵~",
Animal::Dog => "汪汪!",
Animal::Cow => "哞~",
Animal::Duck => "嘎嘎嘎!",
Animal::Sheep => "咩~",
}
}
}然后改一下 main,用调试魔法看看一只动物:
rust
fn main() {
let cat = Animal::Cat;
println!("{:?}", cat);
println!("{}", cat.speak());
}运行:
运行结果
text
Cat
喵~解剖一下:
Animal::Cat 是"菜单上的猫"。:: 老朋友了:从枚举里取变体。Animal::Cat 读作"Animal 家的 Cat"。
match self 是黄金搭档的主场。speak 方法收到"自己"(&self),用 match 对照变体:是猫就喊"喵~",是狗就喊"汪汪!"……每个变体一个分支,一个不多,一个不少。
&'static str 是返回类型,第 1 章见过(字符串字面值都是 'static 生命周期的文字),这里照抄就行——这些叫声都是写死在代码里的文字,不会变。
编译器强迫你"管到底"
如果 speak 里少写一个变体(比如忘了 Animal::Cow),编译会直接失败,并告诉你:"match 没有覆盖所有变体!"这就是第 2 章说的安全网:菜单上几道菜,代码里就必须有几个分支,永远不会有"点了菜没人做"的情况。
步骤四:按名字找动物
现在要从玩家输入的文字(比如 "猫")找到对应的动物。这是"可能有,可能没有"的场景:输入"猫"能找到,输入"恐龙"就找不到。
Rust 处理这种场景的专用工具叫 Option(读作"奥普审",意思是"选项")。它是这样工作的:找到 → 信封上写 Some,里面装着动物;找不到 → 信封上写 None,里面空空如也。
在 impl 块下面,加一个函数:
rust
fn from_name(name: &str) -> Option<Animal> {
match name {
"猫" => Some(Animal::Cat),
"狗" => Some(Animal::Dog),
"牛" => Some(Animal::Cow),
"鸭" => Some(Animal::Duck),
"羊" => Some(Animal::Sheep),
_ => None,
}
}看 match name 的牌子:这次对照的不是枚举变体,而是文字 "猫"、"狗"……match 什么都能对照。
最下面那行 _ => None 是新朋友:_ 通配符。它念"管它是什么"——前面五个名字都没对上?那就是 _,返回 None(没有这只动物)。有了 _,match 就永远有兜底,不会因为"恐龙"这种意外输入而崩溃。
改一下 main 试试:
rust
fn main() {
let animal = from_name("猫");
println!("{:?}", animal);
let animal = from_name("恐龙");
println!("{:?}", animal);
}运行:
运行结果
text
Some(Cat)
None看到了吗?"猫"找到了一只动物(Some(Cat)),"恐龙"什么都没有(None)。程序没有崩溃——恐龙不存在这件事,被 None 好好地接住了。
小贴士
Option 是 Result 的兄弟。还记得第 2 章的 Result 吗?它也是信封:Ok(值) / Err(错误)。区别在于:读文件失败时你想知道为什么失败(Err 里装着原因);而找动物找不到时,原因不重要,只要知道"没有"就行(None 里什么都不装)。要原因用 Result,只问有没有用 Option。
步骤五:组装成模拟器
把拆信封和播放叫声接起来,再套上循环,模拟器就完工了。把整个 main.rs 替换成:
rust
use std::io;
#[derive(Debug)]
enum Animal {
Cat,
Dog,
Cow,
Duck,
Sheep,
}
impl Animal {
fn speak(&self) -> &'static str {
match self {
Animal::Cat => "喵~",
Animal::Dog => "汪汪!",
Animal::Cow => "哞~",
Animal::Duck => "嘎嘎嘎!",
Animal::Sheep => "咩~",
}
}
}
fn from_name(name: &str) -> Option<Animal> {
match name {
"猫" => Some(Animal::Cat),
"狗" => Some(Animal::Dog),
"牛" => Some(Animal::Cow),
"鸭" => Some(Animal::Duck),
"羊" => Some(Animal::Sheep),
_ => None,
}
}
fn main() {
println!("欢迎来到动物叫声模拟器!");
println!("输入动物名字(猫/狗/牛/鸭/羊),听它说话。输入\"退出\"结束。");
let mut total = 0;
loop {
println!();
println!("动物?");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("读取输入失败");
let name = input.trim();
if name == "退出" {
break;
}
match from_name(name) {
Some(animal) => {
total += 1;
println!("{}说:{}", name, animal.speak());
}
None => println!("{}?不认识这种动物哦。", name),
}
}
println!("一共叫了 {} 声。再见!", total);
}组装时用了三个老朋友:loop 转圈(第 1 章)、trim 擦空格(第 1 章)、match 拆 Option 信封(第 2 章拆 Result 的老本行)。
match from_name(name) 是拆信:Some(animal) 就把动物取出来(animal.speak() 让它叫);None 就礼貌拒绝。注意 total += 1 只在 Some 分支里——不认识的动物不算数。
运行,听听动物们的叫声:
运行结果
text
欢迎来到动物叫声模拟器!
输入动物名字(猫/狗/牛/鸭/羊),听它说话。输入"退出"结束。
动物?
猫
猫说:喵~
动物?
恐龙
恐龙?不认识这种动物哦。
动物?
牛
牛说:哞~
动物?
退出
一共叫了 2 声。再见!动物叫声模拟器完工!试着把所有动物都叫一遍,再试试输入"老虎"、"大象"看看。
5.3 知识深挖
5.3.1 枚举:从菜单里选一个
枚举是"从几个选项里选一个"的类型。定义它:
rust
enum Animal {
Cat,
Dog,
Cow,
Duck,
Sheep,
}用起来:
rust
let cat = Animal::Cat;
let duck = Animal::Duck;变体名就是选项本身。一个变量要么是 Cat,要么是 Dog,绝不可能"既是猫又是狗"——Animal 类型的变量,一定是菜单上的一道菜。
回忆第 4 章的结构体:它像一个"全都要"的表格,四栏信息同时存在。枚举正好相反,它是"五选一"的菜单。什么时候用哪个?信息同时都要 → 结构体;情况只能选一种 → 枚举。
实际程序里,两者经常配合:结构体 Pet 里的 species 字段,就可以是 Species 枚举(猫、狗、仓鼠三选一),而不是随便什么文字——这样就不会出现"火星狗"这种莫名其妙的种类了。这个练习,第 5.4 节你会亲手做。
5.3.2 match 与穷尽性
match 的黄金搭档就是枚举——因为枚举的选项是有限的,match 可以一条一条全列出来:
rust
match self {
Animal::Cat => "喵~",
Animal::Dog => "汪汪!",
// 漏了 Cow、Duck、Sheep?编译失败!
}少列一个分支,编译器就报错。这个特性叫穷尽性(exhaustiveness):match 必须把每种可能都穷尽。
这是 Rust 最贴心的设计之一。别的语言里,如果忘记处理一种情况,程序会在运行时悄悄出错;在 Rust 里,编译器直接拦住你:"你漏了!"错误在编译时被发现,而不是半夜三点在用户手机上爆炸。
那 from_name 里 match 的对象是 &str——文字有无限多种,怎么穷尽?这就轮到 _ 通配符出场:
rust
_ => None,_ 匹配"其它所有情况",是无穷情况下的兜底。有了它,match 就完整了。给动物列表加新成员时,speak 里的 match 会强迫你加分支(没有 _ 兜底),from_name 里的 match 则只用改一行(有 _ 兜底)——两种风格各有各的道理。
5.3.3 Option:可能有,可能没有
Option<T> 是标准库里最常用的枚举,它只有两个变体:
rust
enum Option<T> {
Some(T), // 有!里面装着一个 T
None, // 没有
}(这是它的真实样子,<T> 是泛型——第 9 章我们学,现在只要知道"T 是占位符,装什么都行"。)
Some(Animal::Cat):"有一份,里面是猫"None:"没有"
为什么要有它?因为很多操作是可能失败的:按名字找动物、从空数组里拿第一个、把文字转数字……别的语言遇到"没有"的情况,常常返回一个"空指针",谁用谁炸。Rust 的答案很朴素:"没有"也是一种结果,把它装进 Option,明明白白地告诉调用者。调用者用 match 拆信封,两种情况都处理,就不会有惊喜。
对比 Result(第 2 章):
| 信封 | 装着什么 | 什么时候用 |
|---|---|---|
Result<T, E> | Ok(值) 或 Err(原因) | 想知道失败原因(读文件、转数字) |
Option<T> | Some(值) 或 None | 只关心有没有(找东西) |
提前预告
Option 身上还挂着一大串工具方法:unwrap()(没忍住直接拆,None 就崩溃)、unwrap_or(备胎)(没有就用备胎)、is_some()(有没有?)、map(有就加工一下)……第 9 章、第 11 章我们会陆续认识它们。
5.3.4 if let:只看一种情况的懒人简写
有时候你只关心一种情况。比如"如果有这只动物,就叫;没有就算了,不用多说话":
rust
if let Some(animal) = from_name(name) {
println!("{}说:{}", name, animal.speak());
}if let 是 match 的懒人简写:只写一个 Some(animal) 分支,其它情况自动忽略。它最适合"我只关心有,没有就不管"的场景。
想要"没有就干点别的"?if let 也有 else:
rust
if let Some(animal) = from_name(name) {
println!("{}说:{}", name, animal.speak());
} else {
println!("{}?不认识这种动物哦。", name);
}练习二会让你亲手把 match 改成 if let,对比两种写法的味道。
5.3.5 带数据的枚举
枚举的变体不一定是光杆司令——它可以带着数据:
rust
enum BirthdayGift {
Coin(u32), // 红包,带着金额
Pet(String), // 宠物,带着名字
Hug, // 拥抱,啥也不带
}用的时候,变体像个小盒子,里面装着数据:
rust
let gift = BirthdayGift::Coin(10);match 拆的时候,把数据从模式里取出来:
rust
match gift {
BirthdayGift::Coin(amount) => println!("红包!{} 块钱", amount),
BirthdayGift::Pet(what) => println!("宠物!{}", what),
BirthdayGift::Hug => println!("一个大大的拥抱!"),
}Coin(amount) 模式里的 amount 就像拆信封时把值取出来一样——模式里写的名字,就是给数据起的临时变量。
这是枚举最强大的地方:同一个类型,不同变体可以带不同类型的行李。有的带数字,有的带文字,有的什么都不带。这种能力是结构体给不了的。第 18 章我们还会看到它更厉害的样子(带数据的枚举配 match,可以优雅地表达"游戏状态""菜单指令"这些真实世界的东西)。
5.4 动脑筋练习
练习一:狮子来了
动物园新来了一头狮子(Lion,叫声"吼——!")。把它加进模拟器,一共要改三处。想一想:哪三处?
点开看答案
三处改动:
第一处,菜单上加菜:
rust
enum Animal {
Cat,
Dog,
Cow,
Duck,
Sheep,
Lion,
}第二处,speak 里加分支(注意:这里没有 _ 兜底,不加它编译不过——这就是穷尽性在逼你):
rust
Animal::Sheep => "咩~",
Animal::Lion => "吼——!",第三处,from_name 里加一行(这里有 _ 兜底,加了就能用):
rust
"羊" => Some(Animal::Sheep),
"狮子" => Some(Animal::Lion),改完运行,输入"狮子",听它吼一嗓子。再把 speak 里的 Lion 分支删掉试试——看,编译器立刻拦住你,这就是穷尽性的安全网。
练习二:if let 改写
把 main 里的:
rust
match from_name(name) {
Some(animal) => {
total += 1;
println!("{}说:{}", name, animal.speak());
}
None => println!("{}?不认识这种动物哦。", name),
}改写成 if let ... else 的版本。运行,效果应该一模一样。
点开看答案
rust
if let Some(animal) = from_name(name) {
total += 1;
println!("{}说:{}", name, animal.speak());
} else {
println!("{}?不认识这种动物哦。", name);
}两种写法效果完全一样。那什么时候用哪个?
- 两种情况都要认真处理 → 用
match(比如speak里每种动物都有话要说) - 只关心一种情况 → 用
if let(比如这里,"有动物"才需要忙活,"没有"只说一句客气话)
一句话:分支多,用 match;只关心一个,用 if let。
练习三:生日礼物箱(带数据的枚举)
小螃蟹过生日,收到一个礼物箱。里面有三种礼物:红包(带着金额)、宠物(带着名字)、拥抱(啥也不带)。
写一个小程序:循环拆礼物,输入 红包、宠物、拥抱 拆对应的礼物,输入 结束 停止。提示:
- 枚举定义成
enum BirthdayGift { Coin(u32), Pet(String), Hug } - 拆礼物用
match,把数据从模式里取出来 Pet变体里的文字,用String::from("一只小乌龟")造一个
点开看答案
rust
use std::io;
#[derive(Debug)]
enum BirthdayGift {
Coin(u32),
Pet(String),
Hug,
}
fn main() {
println!("小螃蟹的生日礼物箱!");
loop {
println!();
println!("拆开一份礼物?(红包 / 宠物 / 拥抱,输入\"结束\"退出)");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("读取输入失败");
let choice = input.trim();
if choice == "结束" {
break;
}
let gift = match choice {
"红包" => BirthdayGift::Coin(10),
"宠物" => BirthdayGift::Pet(String::from("一只小乌龟")),
"拥抱" => BirthdayGift::Hug,
_ => {
println!("没有这种礼物哦。");
continue;
}
};
match gift {
BirthdayGift::Coin(amount) => println!("红包!里面有 {} 块钱。", amount),
BirthdayGift::Pet(what) => println!("宠物!收到了 {}", what),
BirthdayGift::Hug => println!("一个大大的拥抱!好温暖。"),
}
}
println!("礼物拆完啦,生日快乐!");
}运行示例:
text
拆开一份礼物?(红包 / 宠物 / 拥抱,输入"结束"退出)
红包
红包!里面有 10 块钱。
拆开一份礼物?(红包 / 宠物 / 拥抱,输入"结束"退出)
宠物
宠物!收到了 一只小乌龟看 Coin(amount) 模式:amount 把 10 从红包里"拆"了出来。这就是带数据枚举的魔法。试着把红包金额改成让玩家自己输入(提示:又该用到 .parse() 了)。
5.5 完整代码清单
项目结构:
text
animal_sounds/
├── Cargo.toml
└── src/
└── main.rs文件:Cargo.toml
toml
[package]
name = "animal_sounds"
version = "0.1.0"
edition = "2024"文件:src/main.rs
rust
use std::io;
#[derive(Debug)]
enum Animal {
Cat,
Dog,
Cow,
Duck,
Sheep,
}
impl Animal {
fn speak(&self) -> &'static str {
match self {
Animal::Cat => "喵~",
Animal::Dog => "汪汪!",
Animal::Cow => "哞~",
Animal::Duck => "嘎嘎嘎!",
Animal::Sheep => "咩~",
}
}
}
fn from_name(name: &str) -> Option<Animal> {
match name {
"猫" => Some(Animal::Cat),
"狗" => Some(Animal::Dog),
"牛" => Some(Animal::Cow),
"鸭" => Some(Animal::Duck),
"羊" => Some(Animal::Sheep),
_ => None,
}
}
fn main() {
println!("欢迎来到动物叫声模拟器!");
println!("输入动物名字(猫/狗/牛/鸭/羊),听它说话。输入\"退出\"结束。");
let mut total = 0;
loop {
println!();
println!("动物?");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("读取输入失败");
let name = input.trim();
if name == "退出" {
break;
}
match from_name(name) {
Some(animal) => {
total += 1;
println!("{}说:{}", name, animal.speak());
}
None => println!("{}?不认识这种动物哦。", name),
}
}
println!("一共叫了 {} 声。再见!", total);
}怎么运行:
bash
cd animal_sounds
cargo run运行检查单:
输入 猫、狗、牛、鸭、羊,都能听到对应的叫声
输入 恐龙 之类的怪名字,程序不崩溃,礼貌拒绝
不认识的动物不计入叫声总数
输入 退出,报告总数并结束
给 Animal 加一个新变体但不改 speak,看看编译器怎么拦住你