第 11 篇
实战:任务与命令
组合结构体、枚举、集合和错误处理,实现任务新增、完成、展示和命令参数解析。
Hex4C59 10 分钟阅读 更新于 2026-10-17
编程·原创·实战教程
前面十章已经覆盖了这个项目要用的主要语法。这一章开始实现 taskbook:保存一组待办任务,新增任务,按编号标记完成,并把结果展示成文本。
我们先实现不接触终端和文件的核心函数。这样新增失败时有没有改变列表、完成任务时有没有改错对象,都能用测试直接检查。下一章再把同一组函数接到命令行和文件上。本章跟写结束后,你应该能运行内存版程序,并解释为什么不同函数分别接收 &str、&[Task]、&mut [Task] 或 &mut Vec<Task>。
先写下程序应该怎样工作 #
任务有固定编号、标题和完成状态。新任务默认未完成;完成任务时按编号查找,找不到要返回错误。空白标题不接受。列出任务时使用 1 [ ] 标题 或 1 [x] 标题 的形式。
命令有三种:add "标题"、list、done 编号。编号使用正整数。标题中不允许制表符或换行,因为下一章会用制表符分隔文件字段。
本项目只增加和完成任务,不删除。新编号取已有最大编号加 1,避免依赖列表当前位置;编号达到上限时返回错误。
把规则写成能检查的结果 #
| 操作或输入 | 预期结果 |
|---|---|
向空列表新增 学习 Rust | 得到编号 1,状态为未完成 |
再新增 写练习 | 得到编号 2,第一条仍保留 |
| 完成编号 1 | 只把第一条标记为完成 |
| 再次完成编号 1 | 仍然成功,状态保持完成 |
| 完成不存在的编号 9 | 返回错误,已有任务不变 |
| 新增空文本或全是空白的标题 | 返回错误,不追加任务 |
| 列出空列表 | 返回 还没有任务,末尾有换行 |
已有最大编号是 u64::MAX | 拒绝新增,避免编号溢出 |
编号和列表下标不是一回事。即使列表中只有两条任务,它们的编号也可能是 7 和 12。查找任务要比较 task.id,不能用 tasks[id - 1] 猜测位置。
从头建立项目 #
你可以直接阅读配套源码,也可以在工作区之外新建自己的项目:
cargo new taskbook --edition 2024
cd taskbook自行新建项目时,下面的文件路径都相对于这个 taskbook 目录,运行命令用 cargo run 和 cargo test。配套工作区中的对应命令则分别是 cargo run -p taskbook ... 和 cargo test -p taskbook ...。
项目现在只需要下面两个文件:
taskbook/
├── Cargo.toml
└── src/
├── lib.rs
└── main.rslib.rs 定义可测试的业务函数,main.rs 使用它们。读完整代码时,可以先只找 Task、Command 和五个函数名,确认每个函数的输入输出;随后按下面的说明逐段阅读,不需要一次背下全部代码。
在 lib.rs 中实现核心逻辑 #
新建 src/lib.rs,完整内容如下。这是暂时不含存储模块的第一版;下一章只需要增加模块声明,不必重写这些函数。
pub const USAGE: &str = "用法:taskbook add <标题> | list | done <正整数编号>";
#[derive(Debug, PartialEq, Eq)]
pub struct Task {
pub id: u64,
pub title: String,
pub done: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Add(String),
List,
Done(u64),
}
pub fn parse_args(args: &[String]) -> Result<Command, String> {
match args {
[command, title] if command == "add" => Ok(Command::Add(title.clone())),
[command] if command == "list" => Ok(Command::List),
[command, id] if command == "done" => {
let id = id
.parse::<u64>()
.map_err(|_| String::from("编号必须是正整数"))?;
if id == 0 {
return Err(String::from("编号必须大于 0"));
}
Ok(Command::Done(id))
}
_ => Err(USAGE.to_string()),
}
}
pub fn validate_title(title: &str) -> Result<(), String> {
if title.trim().is_empty() {
return Err(String::from("标题不能为空"));
}
if title.contains(['\t', '\n', '\r']) {
return Err(String::from("标题不能包含制表符或换行"));
}
Ok(())
}
pub fn add_task(tasks: &mut Vec<Task>, title: &str) -> Result<u64, String> {
validate_title(title)?;
let id = tasks
.iter()
.map(|task| task.id)
.max()
.unwrap_or(0)
.checked_add(1)
.ok_or_else(|| String::from("任务编号已用完"))?;
tasks.push(Task {
id,
title: title.trim().to_string(),
done: false,
});
Ok(id)
}
pub fn mark_done(tasks: &mut [Task], id: u64) -> Result<(), String> {
let task = tasks
.iter_mut()
.find(|task| task.id == id)
.ok_or_else(|| format!("找不到任务 {id}"))?;
task.done = true;
Ok(())
}
pub fn render_tasks(tasks: &[Task]) -> String {
if tasks.is_empty() {
return String::from("还没有任务\n");
}
let mut output = String::new();
for task in tasks {
let mark = if task.done { "x" } else { " " };
output.push_str(&format!("{} [{mark}] {}\n", task.id, task.title));
}
output
}这些函数没有读取终端或写入文件,所以相同输入可以得到可预测的结果,也很容易在测试里调用。
沿着一次新增看函数怎样合作 #
调用 add_task(&mut tasks, " 学习 Rust ") 时,程序先检查标题是否有效,再计算编号,最后才 push 新任务。保存的标题会去掉两端空白,成为 学习 Rust。
validate_title 返回 Result<(), String>。成功分支里的 () 表示检查通过但没有额外结果,失败分支里的字符串说明拒绝原因。? 在成功时让后面的步骤继续;失败时就结束本次新增,不再计算编号或追加任务,所以非法标题不会改变列表。
render_tasks 则把结果组织为一个 String,而不是直接打印。这样测试能准确比较输出,入口也可以选择打印、写日志或再加工。字符串中的 \n 是输出格式的一部分,print! 打印时不用再加一个换行。
本章使用 derive(Debug, PartialEq, Eq)。Debug 帮助观察值,PartialEq 与 Eq 让任务和命令能按内容比较;它们不会替我们检查标题或编号的业务规则。
命令解析里有哪些新写法 #
match args 对切片进行模式匹配。[command, title] 要求恰好两个参数;[command] 要求恰好一个。后面的 if command == "add" 是匹配守卫,进一步检查第一个参数的内容。
title 来自借用的参数切片,因此是借来的 String。Command::Add 要保存拥有内容的标题,于是 title.clone() 创建副本。这是为命令对象保留数据所做的明确选择。
数字解析后的 map_err 把底层解析错误换成面向用户的消息,闭包参数 _ 表示这里不保留原错误的细节。比如文本 abc 不能解析成编号;文本 0 可以解析成数字,但仍会被后面的正整数规则拒绝。
parse_args 负责检查命令形式和编号;add_task 负责检查标题。因此解析出 Add(" ") 后,实际新增时仍会被拒绝。保持这两个层次的职责明确,测试才容易定位问题。
shell 会先把命令拆成参数 #
在下一章运行 taskbook add "学习 Rust" 时,程序接收的命令参数是 "add" 和 "学习 Rust" 两项。引号帮助 shell 把含空格的标题当成一项;引号本身不成为标题内容。
如果写成 taskbook add 学习 Rust,参数就会有三项,无法匹配 [command, title],程序会给出用法。这是我们有意选择的命令规则:标题必须是一个参数,而不是自动拼接所有剩余词。
先不等下一章,直接调用解析器看看结果。创建自己的 examples/parse.rs,完整内容如下,随后在单包目录执行 cargo run --example parse。这份辅助示例需要你按本文新建,不是下载包中的既有目标。
use taskbook::parse_args;
fn main() {
let inputs = [
vec!["add", "学习 Rust"],
vec!["list"],
vec!["done", "2"],
vec!["add", "学习", "Rust"],
];
for input in inputs {
let args: Vec<String> = input.iter().map(|value| value.to_string()).collect();
println!("{:?}", parse_args(&args));
}
}前面三行分别是 Ok(Add("学习 Rust"))、Ok(List)、Ok(Done(2)),第四行是包含用法的 Err。parse_args 不知道这些字符串来自 shell、测试还是别的地方,它只按同一套规则检查。
Done 的编号还要经过数字解析和大于 0 的检查。参数数量正确,并不意味着内容有效;同理,成功得到一个 Add 命令,也不代表标题已经通过新增函数的校验。
Option 可以转换成 Result #
max() 对空列表返回 None,unwrap_or(0) 提供初始编号 0。它不会在 None 时 panic。
checked_add(1) 在加法溢出时返回 None。.ok_or_else(|| String::from("任务编号已用完")) 把缺失情况转换成 Err,接下来的 ? 就能把错误传出去。
find 同样返回 Option。mark_done 用 iter_mut() 找到可修改的任务,再通过 ok_or_else 把“找不到”转换成带编号的错误消息。找到后才把完成字段设为真;若原来就已完成,再设一次仍是完成。没找到时,错误会在赋值之前返回,因此列表里的任务都不变。
不要为了短一点省去溢出检查 #
编号代码可读成四步:取得每条任务的编号,找最大值,空列表时从 0 开始,尝试加 1。下面是独立的完整程序,只观察最后两步:
fn main() {
let empty: Vec<u64> = Vec::new();
let ids = vec![7_u64, 2];
let exhausted = vec![u64::MAX];
println!("{:?}", empty.iter().copied().max().unwrap_or(0).checked_add(1));
println!("{:?}", ids.iter().copied().max().unwrap_or(0).checked_add(1));
println!("{:?}", exhausted.iter().copied().max().unwrap_or(0).checked_add(1));
}输出 Some(1)、Some(8)、None。业务函数接着把最后一种情况转换成 Err("任务编号已用完")。这条失败路径应该在测试中保留,不能只换成普通的 + 1 后假定编号永远不会到上限。
ok_or_else 后面的 || ... 是无参数闭包,只有遇到 None 才需要生成错误值。Option 自身只有“有或没有”,这一层转换给“没有”加上了调用方能理解的原因。
Vec 和切片各用在哪里 #
add_task 需要向列表追加元素,因此接收 &mut Vec<Task>。mark_done 只修改已有元素,接收 &mut [Task] 已经足够。render_tasks 只读取任务,接收 &[Task]。
函数签名直接说明了它需要的权限。调用方在查看这些签名时,也能判断哪些函数可能修改数据、哪些会改变列表长度。
Task 的字段暂时公开,方便在教材里查看和构造测试数据。实际库可以把字段设为私有,通过构造函数和方法维护约束。下一章的编码函数还会检查非法字段,防止手工构造的数据直接写入文件。
从权限反推参数类型 #
| 函数 | 参数里的集合类型 | 需要做什么 |
|---|---|---|
add_task | &mut Vec<Task> | 修改列表并增加长度 |
mark_done | &mut [Task] | 修改已有元素,保持长度 |
render_tasks | &[Task] | 读取已有元素 |
当你把 &mut tasks 传给 mark_done 时,Rust 可以把对 Vec<Task> 的借用转换成元素切片的借用。函数不需要知道列表的容量或怎样追加元素。
add_task 接收 &str 标题,再创建任务拥有的 String。调用方的输入即使之后被清空,已经加入的任务标题也仍然存在。与之相对,render_tasks 只是读任务,不会把它们移走。
写一个内存中的 main.rs #
在自己创建的项目中,将 src/main.rs 完整替换为下面的内容,再运行 cargo run:
use taskbook::{add_task, mark_done, render_tasks};
fn main() -> Result<(), String> {
let mut tasks = Vec::new();
add_task(&mut tasks, "学习 Rust")?;
add_task(&mut tasks, "写练习")?;
mark_done(&mut tasks, 1)?;
print!("{}", render_tasks(&tasks));
Ok(())
}预期输出:
1 [x] 学习 Rust
2 [ ] 写练习main 返回 Result<(), String>,允许函数体中使用 ?。() 表示成功时没有额外数据。若返回 Err,Rust 的默认入口处理会报告失败。下一章会改成显式处理错误,给用户更清楚的消息和退出状态。
在下载的配套源码中,这个内存版本保存为示例,而 src/main.rs 已是下一章的完整版本,所以运行方式是:
cargo run -p taskbook --example memory内存版每次运行都创建新列表;退出后没有保存。这一章只是先检验核心函数。
为行为写测试 #
将以下完整测试模块追加到自己项目的 src/lib.rs 末尾,然后运行 cargo test:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_and_complete_task() {
let mut tasks = Vec::new();
assert_eq!(add_task(&mut tasks, "学习 Rust"), Ok(1));
assert_eq!(add_task(&mut tasks, "写练习"), Ok(2));
assert_eq!(mark_done(&mut tasks, 1), Ok(()));
assert_eq!(render_tasks(&tasks), "1 [x] 学习 Rust\n2 [ ] 写练习\n");
}
#[test]
fn rejects_blank_titles_and_unknown_ids() {
let mut tasks = Vec::new();
assert!(add_task(&mut tasks, " ").is_err());
assert!(tasks.is_empty());
assert!(mark_done(&mut tasks, 9).is_err());
}
}配套源码还有命令参数、标题非法字符、编号溢出和空列表的测试。运行 cargo test -p taskbook --lib 可以查看它们。测试直接调用核心逻辑,不需要每次手动在终端重演所有输入。
再检查失败时列表是否保持原状 #
将下面的完整测试函数加入自己项目中已有的 mod tests:
#[test]
fn invalid_changes_leave_existing_task_unchanged() {
let mut tasks = Vec::new();
add_task(&mut tasks, "保留这条").unwrap();
let before = render_tasks(&tasks);
assert!(add_task(&mut tasks, "\t").is_err());
assert!(mark_done(&mut tasks, 99).is_err());
assert_eq!(render_tasks(&tasks), before);
assert_eq!(tasks.len(), 1);
}这里不仅检查“返回了错误”,还检查已有任务的内容和数量。若后来有人把 push 提前到验证之前,这个测试就有机会发现错误路径改变了数据。
再加入下面的解析测试,确认多余参数和非法编号都被拒绝:
#[test]
fn rejects_bad_command_arguments() {
for parts in [
vec!["list", "extra"],
vec!["add"],
vec!["done", "0"],
vec!["done", "abc"],
] {
let args: Vec<String> = parts.iter().map(|part| part.to_string()).collect();
assert!(parse_args(&args).is_err());
}
}这两个测试是跟写时新增的检查;下载包中已经有覆盖相近规则的测试。不要根据两份项目的测试数量是否相同来判断行为是否正确,应该看具体检查了什么。
练习:统计未完成任务 #
写 remaining(tasks: &[Task]) -> usize,统计 done 为 false 的项。新增两条任务、完成其中一条后,结果应为 1;空列表应为 0。为它写一个测试。
参考答案:先自己完成再展开
将下面的完整函数放在 lib.rs 的测试模块外:
pub fn remaining(tasks: &[Task]) -> usize {
tasks.iter().filter(|task| !task.done).count()
}将下面的完整测试函数放在已有的 mod tests 内:
#[test]
fn counts_only_unfinished_tasks() {
let mut tasks = Vec::new();
assert_eq!(remaining(&tasks), 0);
add_task(&mut tasks, "第一条").unwrap();
add_task(&mut tasks, "第二条").unwrap();
mark_done(&mut tasks, 1).unwrap();
assert_eq!(remaining(&tasks), 1);
}这里在测试中使用 unwrap:这些调用若意外失败,测试就应该立即失败。处理真实用户输入时仍返回错误。统计函数只借用任务,筛掉已完成的项后计数,不修改任务;把最后一条未完成任务也标记完成,统计结果就应从 1 变成 0。
进入文件操作之前先确认三件事 #
运行内存示例,结果应只有编号 1 被标记为完成。运行自己的测试,合法操作和失败条件都应符合规则。最后找出每个函数修改数据的位置,确认它前面需要通过哪些检查。
下一章继续使用这些函数。如果文件读取失败,入口应提前返回错误;如果读取成功,才允许新增或完成任务。这样你不需要在每个业务函数里重复处理磁盘路径。
官方延伸阅读 #
外部文档查阅日期:2026-10-13。
一起讨论
选中正文中的文字,可以针对这段内容发起讨论。
正在读取讨论…
在 GitHub 查看与管理讨论 ↗