外观
第 8 章 宠物收藏册的存档
小螃蟹说
玩过游戏的小朋友都知道"存档"有多重要:辛辛苦苦打了半天的进度,一关机全没了,那叫一个难受。
第 7 章的收藏册就是这个问题:添加的宠物,程序一关就全散了。这一章,我们给它装上存档机和读档机——宠物们终于有"家"了。
不过你别以为这章只是"存文件"。存文件是件可能出错的事:硬盘满了怎么办?文件被删了怎么办?存档写坏了怎么办?这一章真正的主角,是 Rust 处理这些"意外"的整套功夫——错误处理。
你会看到 Rust 最重要的两个错误工具:panic!(程序崩溃)和 Result(可恢复的错误),还会学到新朋友 ? 运算符——它让错误处理代码变得又短又安全。
8.1 预览:我们要做什么
给第 7 章的收藏册加两个功能:
- 存档:把收藏册写成文件
收藏册.txt - 读档:把文件读回来,宠物们整整齐齐地回来
最精彩的是这个完整闭环:
运行结果
text
(第一次运行)
欢迎来到宠物收藏册!
选一个:1
宠物的名字:小咪
宠物的种类:猫
宠物的年龄:2
可爱度(1~10):9
已加入收藏册!现在一共有 1 只宠物。
选一个:1
宠物的名字:旺财
宠物的种类:狗
宠物的年龄:0
可爱度(1~10):7
已加入收藏册!现在一共有 2 只宠物。
选一个:5
存档成功!收藏册里有 2 只宠物。
选一个:0
再见!欢迎下次再来玩!
(重新运行!程序已经关掉又打开)
选一个:6
读档成功!共 2 只宠物。
选一个:2
1. 小咪 是 2 岁的小猫,可爱度 9/10
2. 旺财 是 0 岁的小狗,可爱度 7/10看到了吗?程序关掉再打开,宠物还在! 这就是存档的魔法——数据从"内存里"搬到了"硬盘上",再也不怕关机了。
这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
panic! | 程序崩溃("原地去世") | 程序员写错时的最后防线 |
Result | 可能出错的事,拆信封 | 读文件、写文件 |
? 运算符 | 错误自动往上跑 | 让 save/load 代码又短又稳 |
map_err | 把错误翻译成人话 | 报错信息更友好 |
| 错误哲学 | 什么时候该崩溃,什么时候该商量 | 设计程序的"脾气" |
8.2 动手做
步骤一:回到收藏册项目
这一章是在第 7 章的基础上升级,所以直接回到 pet_collection 项目:
bash
cd pet_collection打开 src/main.rs,我们就在这个文件里改。先看一眼现在的样子——菜单、add_pet、list_all……都是老朋友了。我们要加的是:save_pets(存档)和 load_pets(读档)两个函数。
步骤二:存档(第一版)
存盘的原理很简单:把每只宠物写成一行文字,存进文件。一行一只,每只的四个信息用逗号隔开:
text
小咪,猫,2,9
旺财,狗,0,7这就像把档案卡一张张抄进本子。打开 main.rs,在 use 区加 fs(第 3 章的老朋友),然后在 show_stats 下面写第一个版本:
rust
use std::fs;
const SAVE_FILE: &str = "收藏册.txt";rust
fn save_pets(pets: &Vec<Pet>) {
let mut text = String::new();
for pet in pets.iter() {
let line = format!("{},{},{},{}\n", pet.name, pet.species, pet.age, pet.cuteness);
text.push_str(&line);
}
match fs::write(SAVE_FILE, text) {
Ok(()) => println!("存档成功!收藏册里有 {} 只宠物。", pets.len()),
Err(error) => println!("存档失败:{}", error),
}
}const SAVE_FILE 是常量(第 1 章练习一学过):存档文件名只写一次,全程序共用。
fs::write(文件名, 内容) 是"把内容写进文件"。它的结果是一个 Result——可能成功(文件写好了),可能失败(比如硬盘满了)。我们用第 2 章的 match 拆信封:Ok 庆祝,Err 报错,程序不崩溃。
pets.len() 放在 Ok 分支里,打印的是"写入成功后"的宠物数——如果写入失败了,那句"存档成功"就不会出现。错误处理和正常流程泾渭分明,这就是 Result 的优雅之处。
步骤三:让错误自动往上跑(认识 ?)
match 拆信封很好,但有个小问题:如果 save_pets 里要干三件可能出错的事(写文件、记日志、备份……),就要写三个 match,每个都缩进一层。代码会变成"金字塔"。
Rust 有更帅的招:? 运算符。它的意思是:"这步出错?那就带着错误立刻回家——把错误交给叫我的人。" 把 save_pets 改成:
rust
fn save_pets(pets: &Vec<Pet>) -> Result<(), String> {
let mut text = String::new();
for pet in pets.iter() {
let line = format!("{},{},{},{}\n", pet.name, pet.species, pet.age, pet.cuteness);
text.push_str(&line);
}
fs::write(SAVE_FILE, text).map_err(|error| format!("存档失败:{}", error))?;
println!("存档成功!收藏册里有 {} 只宠物。", pets.len());
Ok(())
}三处变化,缺一不可:
- 返回类型变成
Result<(), String>:函数现在声明"我可能成功(成功时没有值,所以是()),也可能失败(失败时带一句String人话)" fs::write(...).map_err(...)?:.map_err(|error| format!("存档失败:{}", error))是把错误"翻译成人话"(那串|error| ...叫闭包,第 11 章我们正式学,现在先照抄);后面的?是"出错就回家"- 最后一行
Ok(()):成功时,打包一个"空包裹"交回去
? 翻译成人话就是:"这一步要是失败了,别在这儿纠结,把错误打包,直接退出函数交给叫我的人。"
注意!? 的规矩
? 只能在返回 Result 的函数里用。因为 ? 失败时要"带着错误回家",家里得有个 Result 类型的门(返回值)让它进门。我们的 main 函数返回 ()(没有门),所以在 main 里不能直接 ?——要用 match 接住。
所以在 main 的菜单里,我们这样调用:
rust
"5" => match save_pets(&pets) {
Ok(()) => {}
Err(message) => println!("{}", message),
},match 接住 Result,Err 里的"人话"直接打印。程序不崩溃,菜单继续。
步骤四:读档
存档有了,读档是它的镜像。在 save_pets 下面加:
rust
fn load_pets() -> Result<Vec<Pet>, String> {
let text = fs::read_to_string(SAVE_FILE).map_err(|error| format!("读档失败:{}", error))?;
let mut pets: Vec<Pet> = Vec::new();
for (index, line) in text.lines().enumerate() {
let fields: Vec<&str> = line.split(',').collect();
if fields.len() != 4 {
return Err(format!("第 {} 行格式坏了,只有 {} 个字段。", index + 1, fields.len()));
}
let name = fields[0].to_string();
let species = fields[1].to_string();
let age = fields[2].parse().map_err(|_| format!("第 {} 行的年龄不是数字。", index + 1))?;
let cuteness = fields[3]
.parse()
.map_err(|_| format!("第 {} 行的可爱度不是数字。", index + 1))?;
pets.push(Pet {
name,
species,
age,
cuteness,
});
}
println!("读档成功!共 {} 只宠物。", pets.len());
Ok(pets)
}一行一行看:
fs::read_to_string(第 3 章的老朋友)读回整个文件,出错就?回家text.lines().enumerate():一行一行读,顺便记行号(第 7 章的组合拳)line.split(',').collect():按逗号把一行切成 4 段(第 3 章split的用法,collect把切出来的段收进Vec——它是第 11 章的正式内容,现在先认脸)if fields.len() != 4:检查"是不是 4 段"。不是?那就是存档文件被手改坏了,return Err(...)直接回家,还告诉你是第几行坏了——这叫格式校验fields[2].parse():文字变数字(第 2 章),失败就map_err翻译成人话,再?回家
return Err 和 ? 是兄弟:? 是"这步出错了,带着错误回家";return Err 是"我检查发现不对,主动带着错误回家"。一个被动一个主动,但都是"错误往上跑"。
在 main 菜单里接住:
rust
"6" => match load_pets() {
Ok(loaded) => {
pets = loaded;
}
Err(message) => println!("{}", message),
},pets = loaded; 是"用读回来的收藏册,整个换掉现在的"。这是把 Vec<Pet> 直接搬进 pets 变量——第 3 章的移动,一个值一个主人,新主人上位。
步骤五:菜单整合,完整闭环
菜单加上两项,main 的最终形态:
rust
fn main() {
let mut pets: Vec<Pet> = Vec::new();
println!("欢迎来到宠物收藏册!");
loop {
println!();
println!("== 菜单 ==");
println!("1. 添加宠物");
println!("2. 查看所有宠物");
println!("3. 按种类找宠物");
println!("4. 统计报告");
println!("5. 存档");
println!("6. 读档");
println!("输入 0 退出");
let choice = read_input("选一个:");
match choice.as_str() {
"1" => add_pet(&mut pets),
"2" => list_all(&pets),
"3" => find_by_species(&pets),
"4" => show_stats(&pets),
"5" => match save_pets(&pets) {
Ok(()) => {}
Err(message) => println!("{}", message),
},
"6" => match load_pets() {
Ok(loaded) => {
pets = loaded;
}
Err(message) => println!("{}", message),
},
"0" => break,
_ => println!("没有这个选项哦。"),
}
}
println!("再见!欢迎下次再来玩!");
}现在,见证奇迹的时刻!运行程序:
运行结果
text
欢迎来到宠物收藏册!
== 菜单 ==
1. 添加宠物
2. 查看所有宠物
3. 按种类找宠物
4. 统计报告
5. 存档
6. 读档
输入 0 退出
选一个:1
宠物的名字:小咪
宠物的种类:猫
宠物的年龄:2
可爱度(1~10):9
已加入收藏册!现在一共有 1 只宠物。
选一个:1
宠物的名字:旺财
宠物的种类:狗
宠物的年龄:0
可爱度(1~10):7
已加入收藏册!现在一共有 2 只宠物。
选一个:5
存档成功!收藏册里有 2 只宠物。
选一个:0
再见!欢迎下次再来玩!看看项目文件夹里——多了一个 收藏册.txt!用记事本打开它:
text
小咪,猫,2,9
旺财,狗,0,7宠物们的档案,变成了一行行的文字。现在重新运行程序(关掉再打开!),然后:
运行结果
text
欢迎来到宠物收藏册!
== 菜单 ==
1. 添加宠物
2. 查看所有宠物
3. 按种类找宠物
4. 统计报告
5. 存档
6. 读档
输入 0 退出
选一个:6
读档成功!共 2 只宠物。
选一个:2
1. 小咪 是 2 岁的小猫,可爱度 9/10
2. 旺财 是 0 岁的小狗,可爱度 7/10
选一个:0
再见!欢迎下次再来玩!小咪和旺财,回来了! 这就是"持久化"(persistence)——数据不再只活在内存里,而是住进了硬盘。恭喜你,你的程序第一次拥有了"记忆"。
小贴士
如果存档文件不见了(比如被删了),读档会怎样?试试!程序不会崩溃,而是打印一句"读档失败:系统找不到指定的文件",然后菜单继续活着。这就是可恢复错误的魅力:出了岔子,还能商量。
8.3 知识深挖
8.3.1 错误的两大家族
程序出错,分两种,处理方式完全不同:
| 可恢复错误 | 不可恢复错误 | |
|---|---|---|
| 例子 | 文件不存在、输入的不是数字 | 数组索引越界、逻辑写错 |
| 谁造成的 | 外部世界(用户、硬盘、网络) | 程序员自己 |
| 工具 | Result | panic! |
| 处理方式 | 拆信封,商量着办 | 直接崩溃,大声喊 |
可恢复错误是"意外但正常"的:用户手滑删了存档文件?提示一句,继续用菜单。不可恢复错误是"不可能发生但发生了"的:代码 bug。对于 bug,崩溃反而是最好的处理——别让一个坏程序带着错误继续跑,越跑越坏。
8.3.2 panic!:程序"原地去世"
panic! 是"我不管了,崩溃!"的意思。你其实早就见过它——第 2 章的 .expect("这不是一个数字!") 失败时,内部就是在 panic:
rust
panic!("出大事了!");运行结果
text
thread 'main' panicked at src/main.rs:2:5:
出大事了!
note: run with `RUST_BACKTRACE=1` environment variable to display a backtracepanic! 会打印一句"遗言",然后程序立刻停止。注意遗言里的位置信息(src/main.rs:2:5,第 2 行第 5 列)——这是程序员找 bug 的线索:哪个文件哪一行出了事。
什么情况会 panic?我们遇到过的:索引越界(scores[99] 而柜子里只有 3 个,第 7 章警告过),.expect 失败。这些都属于"程序员写错了",不是用户的错。所以:panic 不是洪水猛兽,它是编译器派来的"警报器"——程序出 bug 时,大声报警,比默默出错好一万倍。
8.3.3 Result:拆信封的艺术
Result 我们已经用了好几章,今天做个总复习:
rust
enum Result<T, E> {
Ok(T), // 成功,带着结果
Err(E), // 失败,带着原因
}拆信封有四种姿势,从粗鲁到优雅:
rust
// 1. 粗鲁版:失败就崩溃(适合"这里绝不该错")
let number = input.parse::<u32>().unwrap();
let number = input.parse::<u32>().expect("一定是数字!");
// 2. 商量版:失败就自己处理
match input.parse::<u32>() {
Ok(number) => number,
Err(_) => { println!("要输入数字哦。"); continue; }
}
// 3. 懒人商量版:只关心一种情况
if let Ok(number) = input.parse::<u32>() { /* ... */ }
// 4. 甩手版:失败交给上头(? 运算符)
let number = input.parse::<u32>().map_err(|_| "数字坏了")?;unwrap 和 expect:一个不写遗言,一个写遗言,失败都 panic。它们适合"我确定这里不会失败"的场景——真的失败了,说明程序员想错了,panic 正好报警。
match / if let:用户可能犯错(输入 "abc")、世界可能捣乱(文件被删),这些要商量着办。
?:把"商量"的责任上交——我这层不处理,让上层决定。代码最干净,但要记得函数得返回 Result。
复习:我们的进化史
第 2 章猜数字:parse().expect()(粗鲁版,输错就崩溃)→ 第 5 章动物叫声:match(商量版)→ 第 7 章收藏册:loop + match(终极输入机)→ 今天:?(甩手版)。四章走来,你其实一直在学错误处理,只是今天它终于有了完整的名字。
8.3.4 ? 运算符:错误自动往上跑
? 是今天的主角。它的完整故事:
rust
fn load_pets() -> Result<Vec<Pet>, String> {
// 读文件,失败就回家,带着翻译好的错误
let text = fs::read_to_string(SAVE_FILE).map_err(|e| format!("读档失败:{}", e))?;
// 每行解析,数字坏了也回家
let age = fields[2].parse().map_err(|_| "年龄不是数字")?;
// 全都顺利?带着宠物们回家
Ok(pets)
}? 的魔力:一个函数里可以有很多步可能出错的操作,每一步失败都会自动带着错误退出函数,不用写一个 match 金字塔。代码读起来像"顺利的流水线"——因为出错的岔路,都被 ? 悄悄处理了。
注意 ? 和 return Err 的分工:
fs::read_to_string(...)?:被动,操作自己失败了,?送它回家return Err("第 1 行格式坏了..."): 主动,我自己检查发现不对,自己回家
主动检查 + 被动传播,组合成一道坚固的防线。 存档文件被手改坏了?被删了?数字变乱码了?每一道防线都有人守,而且守门员的遗言(错误消息)都翻译成了人话。
8.3.5 错误处理哲学:什么时候崩溃,什么时候商量
最后是本章的"心法"——Rust 官方推荐的错误处理哲学:
用户可能错的,用 Result 商量;程序员可能错的,用 panic! 报警。
| 场景 | 是谁的错 | 处理方式 |
|---|---|---|
| 玩家输入"香蕉"当年龄 | 用户 | Result,提示重输 |
| 存档文件被删了 | 世界(用户手滑) | Result,提示一句继续 |
索引越界(第 7 章的 scores[i]) | 程序员 | panic!,报警 |
| 存档文件格式坏了 | 程序员(或手改文件) | 我们用了 Result(友好提示) |
| 程序内部逻辑不可能错的地方 | 程序员 | panic! 兜底 |
记住一个简单判断:"用户能撞上的墙,要软软的;程序员能踩的坑,要响响的。" 软软的墙(Result)让用户从容重来;响响的坑(panic!)让程序员立刻发现 bug。两种都要,缺一不可。
8.4 动脑筋练习
练习一:清空收藏册
加一个"清空收藏册"功能(菜单第 7 项)。提示:Vec 有个方法叫 clear(),一口气倒空整个柜子。清理前先记一下数量,打印"已清空 N 只宠物"。
点开看答案
菜单加一项:
rust
println!("7. 清空收藏册");match 加一个分支:
rust
"7" => clear_pets(&mut pets),新函数:
rust
fn clear_pets(pets: &mut Vec<Pet>) {
let count = pets.len();
pets.clear();
println!("已清空 {} 只宠物。", count);
}pets.clear() 把收藏册里的宠物全部请出去,柜子变空。注意先 let count = pets.len(); 记下数量——如果先 clear 再 len,数出来的就是 0 了(顺序很重要,这是老规矩了)。
小脑筋:清空之后,存档文件里的宠物还在吗?答案是——还在!clear 只清内存,不清硬盘。想连存档一起清?那得把 收藏册.txt 也删掉(可以试试用 fs::remove_file)。
练习二:容忍空行
你手改过 收藏册.txt 之后,可能会留下空行:
text
小咪,猫,2,9
旺财,狗,0,7现在读档会怎样?空行被切成 1 个字段,fields.len() != 4 成立,直接报错!给 load_pets 加一行:遇到空行(去掉空格后是空的)就跳过,别报错。
点开看答案
在 load_pets 的 for 循环里,split 之前加:
rust
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let fields: Vec<&str> = line.split(',').collect();line.trim() 擦掉首尾空格(第 1 章老朋友),.is_empty() 问"擦完是不是空的",是就 continue 跳过这一行(第 2 章老朋友)。
改完试试:存档文件中间夹一个空行,读档照样成功。程序对外面的世界更宽容了——这就是错误处理的日常:不是所有异常都要大呼小叫,有些只要悄悄绕过去。
练习三:退出前自动存档
每次都要手动按 5 存档,太麻烦。改成:输入 0 退出时,自动存档,然后才说再见。
提示:把 "0" => break, 改成一个块,在 break 之前调用 save_pets。想想用什么接住它的 Result?(提示:if let Err 是第 5 章的懒人商量版)
点开看答案
把 "0" => break, 改成:
rust
"0" => {
if let Err(message) = save_pets(&pets) {
println!("自动存档失败:{}", message);
}
break;
}if let Err(message) 只关心失败的情况:存好了就悄悄走,存坏了才喊一句。这比 match 简洁——因为我们只在"出错"时有事要做。
现在退出程序时,存档自动完成。试试:添加宠物 → 直接输入 0 退出 → 重新运行 → 6 读档——宠物已经乖乖在存档里等着了。
小脑筋:还有更懒的写法 let _ = save_pets(&pets);——let _ 是"我不管结果"的意思。但出错时连句提醒都没有,有点不负责任。你觉得哪种好?
8.5 完整代码清单
项目结构:
text
pet_collection/
├── Cargo.toml
├── 收藏册.txt (运行程序存档后自动出现)
└── src/
└── main.rs文件:Cargo.toml
toml
[package]
name = "pet_collection"
version = "0.1.0"
edition = "2024"文件:src/main.rs
rust
use std::collections::HashMap;
use std::fs;
use std::io;
const SAVE_FILE: &str = "收藏册.txt";
struct Pet {
name: String,
species: String,
age: u32,
cuteness: u32,
}
fn main() {
let mut pets: Vec<Pet> = Vec::new();
println!("欢迎来到宠物收藏册!");
loop {
println!();
println!("== 菜单 ==");
println!("1. 添加宠物");
println!("2. 查看所有宠物");
println!("3. 按种类找宠物");
println!("4. 统计报告");
println!("5. 存档");
println!("6. 读档");
println!("输入 0 退出");
let choice = read_input("选一个:");
match choice.as_str() {
"1" => add_pet(&mut pets),
"2" => list_all(&pets),
"3" => find_by_species(&pets),
"4" => show_stats(&pets),
"5" => match save_pets(&pets) {
Ok(()) => {}
Err(message) => println!("{}", message),
},
"6" => match load_pets() {
Ok(loaded) => {
pets = loaded;
}
Err(message) => println!("{}", message),
},
"0" => break,
_ => println!("没有这个选项哦。"),
}
}
println!("再见!欢迎下次再来玩!");
}
fn save_pets(pets: &Vec<Pet>) -> Result<(), String> {
let mut text = String::new();
for pet in pets.iter() {
let line = format!("{},{},{},{}\n", pet.name, pet.species, pet.age, pet.cuteness);
text.push_str(&line);
}
fs::write(SAVE_FILE, text).map_err(|error| format!("存档失败:{}", error))?;
println!("存档成功!收藏册里有 {} 只宠物。", pets.len());
Ok(())
}
fn load_pets() -> Result<Vec<Pet>, String> {
let text = fs::read_to_string(SAVE_FILE).map_err(|error| format!("读档失败:{}", error))?;
let mut pets: Vec<Pet> = Vec::new();
for (index, line) in text.lines().enumerate() {
let fields: Vec<&str> = line.split(',').collect();
if fields.len() != 4 {
return Err(format!("第 {} 行格式坏了,只有 {} 个字段。", index + 1, fields.len()));
}
let name = fields[0].to_string();
let species = fields[1].to_string();
let age = fields[2].parse().map_err(|_| format!("第 {} 行的年龄不是数字。", index + 1))?;
let cuteness = fields[3]
.parse()
.map_err(|_| format!("第 {} 行的可爱度不是数字。", index + 1))?;
pets.push(Pet {
name,
species,
age,
cuteness,
});
}
println!("读档成功!共 {} 只宠物。", pets.len());
Ok(pets)
}
fn add_pet(pets: &mut Vec<Pet>) {
println!();
let name = read_input("宠物的名字:");
let species = read_input("宠物的种类:");
let age = read_number("宠物的年龄:");
let cuteness = read_number("可爱度(1~10):");
pets.push(Pet {
name,
species,
age,
cuteness,
});
println!("已加入收藏册!现在一共有 {} 只宠物。", pets.len());
}
fn list_all(pets: &Vec<Pet>) {
println!();
if pets.is_empty() {
println!("收藏册还是空的,先添加一只宠物吧。");
return;
}
for (index, pet) in pets.iter().enumerate() {
println!(
"{}. {} 是 {} 岁的小{},可爱度 {}/10",
index + 1,
pet.name,
pet.age,
pet.species,
pet.cuteness
);
}
}
fn find_by_species(pets: &Vec<Pet>) {
println!();
let species = read_input("想找什么种类的宠物?");
let mut found = 0;
for pet in pets.iter() {
if pet.species == species {
found += 1;
println!("{}:{} 岁,可爱度 {}/10", pet.name, pet.age, pet.cuteness);
}
}
if found == 0 {
println!("没有找到 {} 呢。", species);
}
}
fn show_stats(pets: &Vec<Pet>) {
println!();
println!("一共有 {} 只宠物。", pets.len());
let mut counts: HashMap<String, u32> = HashMap::new();
for pet in pets.iter() {
let count = counts.entry(pet.species.clone()).or_insert(0);
*count += 1;
}
for (species, count) in &counts {
println!("{}:{} 只", species, count);
}
}
fn read_number(prompt: &str) -> u32 {
loop {
let input = read_input(prompt);
match input.parse() {
Ok(number) => return number,
Err(_) => println!("要输入数字哦。"),
}
}
}
fn read_input(prompt: &str) -> String {
println!("{}", prompt);
let mut input = String::new();
io::stdin().read_line(&mut input).expect("读取输入失败");
input.trim().to_string()
}怎么运行:
bash
cd pet_collection
cargo run运行检查单:
添加几只宠物,按 5 存档,项目文件夹里出现 收藏册.txt
退出程序再重新运行,按 6 读档,宠物都回来了
没有存档文件时按 6,程序不崩溃,提示"读档失败",菜单继续
手改存档文件:删掉一行的某个字段,读档提示"第 N 行格式坏了"
手改存档文件:把年龄改成 abc,读档提示"年龄不是数字"
存档和读档,和 0 退出,都能正常回到菜单