练习与验收

分级练习与项目验收

在第六、十二、十八章后完成内存书单、文件记录器和批量本机 HTTP 客户端,按修改、补全和独立实现三档检查学习结果。

Hex4C59 11 分钟阅读 更新于 2026-10-17

编程·原创·实战教程

这篇练习配合《Rust 从零开始》的第六、十二、十八章使用。每个阶段都有三档任务:修改现有程序、补全指定行为、根据需求从空文件实现一个小项目。

先试着完成当前阶段的练习,再展开答案。不必等到第十八章才开始;第六章后的书单只使用当时已经学过的数据类型、借用和分支。完成后记录自己在哪一步需要查正文,下一次重点重做那一步。

下载包的运行命令用于核对参考实现。自己练习时,用 cargo new 创建另一个项目,不要覆盖下载包里的旧答案或自己的任务文件。

第六章后:实现内存书单 #

这一阶段检查能否组织结构体、枚举、列表和可变借用。程序只处理内存中的数据,不接终端输入,也不读写文件。

第一档:修改已有程序 #

从第六章的书籍练习开始,增加第三本书。只把第二本标记为已读,打印三本书的标题和状态,再给空列表调用同一个修改函数。

通过标准是:三本书都还在列表中,只有第二本已读,空列表不会 panic。先写下预计输出,再运行;如果直接用下标修改,想一想怎样先处理列表长度不足的情况。

第二档:按标题查找并修改 #

写 finish_by_title(books: &mut [Book], title: &str) -> bool。找到完全相同的标题就标记为已读并返回 true,没找到返回 false。对已经读完的同一本书再次调用,仍返回 true。

分别检查标题存在、标题不存在和列表为空这三种情况。这里只返回是否找到,不返回书籍引用,所以暂时不需要给两个参数标生命周期。

第三档:独立组织命令 #

从空 main.rs 实现一个书单程序。需求如下:

  • Book 保存标题和是否已读。新增时去掉标题首尾空白;空标题和重复标题返回 false,列表不变。重复按去掉首尾空白后的文本判断,区分大小写。
  • Command 有 Add(String)、Finish(String)、List 三种情况。完成命令按传入标题精确查找,不再自动去掉空白。
  • 执行预先写好的命令列表:列出空书单,新增两本书,尝试重复新增,标记一本,再尝试标记不存在的书,最后列出全部。
  • 新增和完成函数只借用列表;执行完命令后,调用方还能检查书籍数量和状态。

先让程序输出正确,再加入 assert! 检查失败操作没有增加书籍、没有把其他书标为已读。这里用布尔值报告结果;第七章学过 Result 后,可以再改为返回具体错误原因。

参考答案:内存书单

下面是可以放入自己项目 src/main.rs 的完整程序。下载包对应 basics/examples/checkpoint06.rs,还附有供第八章之后运行的单元测试。

struct Book {
    title: String,
    finished: bool,
}

enum Command {
    Add(String),
    Finish(String),
    List,
}

fn add_book(books: &mut Vec<Book>, title: &str) -> bool {
    let title = title.trim();
    if title.is_empty() {
        return false;
    }
    for book in books.iter() {
        if book.title == title {
            return false;
        }
    }
    books.push(Book {
        title: title.to_string(),
        finished: false,
    });
    true
}

fn finish_by_title(books: &mut [Book], title: &str) -> bool {
    for book in books {
        if book.title == title {
            book.finished = true;
            return true;
        }
    }
    false
}

fn apply(books: &mut Vec<Book>, command: &Command) {
    match command {
        Command::Add(title) => println!("新增成功:{}", add_book(books, title)),
        Command::Finish(title) => println!("标记成功:{}", finish_by_title(books, title)),
        Command::List => {
            if books.is_empty() {
                println!("还没有书");
            }
            for book in books {
                let state = if book.finished { "已读" } else { "未读" };
                println!("{}:{state}", book.title);
            }
        }
    }
}

fn main() {
    let mut books = Vec::new();
    let commands = [
        Command::List,
        Command::Add(String::from("Rust 入门")),
        Command::Add(String::from("写练习")),
        Command::Add(String::from("Rust 入门")),
        Command::Finish(String::from("Rust 入门")),
        Command::Finish(String::from("不存在")),
        Command::List,
    ];
    for command in &commands {
        apply(&mut books, command);
    }
    assert!(!add_book(&mut books, " "));
    assert_eq!(books.len(), 2);
    assert!(books[0].finished);
    assert!(!books[1].finished);
    assert!(!finish_by_title(&mut [], "Rust 入门"));
}

预期输出:

还没有书
新增成功:true
新增成功:true
新增成功:false
标记成功:true
标记成功:false
Rust 入门:已读
写练习:未读

在下载包的 rust-course 目录运行:

cargo run -p rust-basics --example checkpoint06

books.iter() 临时共享借用列表,检查已有标题。循环结束后,函数才用 push 修改列表。finish_by_title 接收可变切片,可以改已有书籍的状态,但不能向切片增加书籍。apply 借用一条命令,再把其中的标题借给新增或完成函数,不会把标题从命令列表中移走。新增函数需要让书籍在调用结束后仍保有标题,所以通过 to_string 保存自己的文本;查找函数只用标题做比较。

读完第八章后,再运行 cargo test -p rust-basics --example checkpoint06。三个测试检查新增拒绝条件、只修改目标书籍,以及空列表。

第十二章后:完成学习时长记录器 #

这一阶段要把终端参数、Result、小函数和文件保存用到一起。换成学习时长这个数据模型,看看自己能否应用前十二章的知识。

第一档:确认当前程序在哪里读文件 #

先用第十二章的 taskbook 做一次练习:在两个独立目录分别运行同一个可执行程序,新增不同任务,再观察默认任务文件出现在哪里。

开始前写下预计路径。通过标准是能解释:项目清单在哪里、可执行文件在哪里、运行时的当前工作目录在哪里,以及相对数据路径以哪一个为起点。不要用修改别人的文件来验证路径。

第二档:先写文本转换函数 #

学习时长文件每行保存一个整数分钟数,例如 25\n40\n。写 decode 把文本转成列表,再写 encode 转回文本。

规则是每条记录在 1~1440 分钟之间;空文件表示零条记录,文件末尾的一次换行允许存在,中间空行无效。解析失败要指出行号。依次检查空文件、两条记录、0、1441、非数字和中间空行,先不要操作磁盘。

第三档:从参数到保存 #

从空项目完成下面的命令接口。示意中的路径要换成自己的练习文件:

checkpoint12 minutes.txt add 25
checkpoint12 minutes.txt total

add 新增一条记录,total 打印累计分钟数。参数个数或分钟数无效时,在读取文件前报错。文件缺失视为空列表,但 total 不创建文件;已有文件损坏时,add 报错并保留原文件。

有效源文件必须是 UTF-8 文本。新增采用读取、解析、增加记录、重新编码、覆盖保存的顺序。本练习没有事务、文件锁和写入中断恢复;只能确认写入前拒绝的错误不会覆盖文件。

参考答案:学习时长记录器

下面是完整程序,可以放入自己项目的 src/main.rs。下载包对应 basics/examples/checkpoint12.rs,另附三个单元测试。

use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

const USAGE: &str = "用法:checkpoint12 <文件> add <分钟数> | <文件> total";

enum Command {
    Add(u32),
    Total,
}

fn parse_minutes(input: &str) -> Result<u32, String> {
    let value = input
        .trim()
        .parse::<u32>()
        .map_err(|_| String::from("分钟数必须是整数"))?;
    validate_minutes(value)
}

fn validate_minutes(value: u32) -> Result<u32, String> {
    if !(1..=1440).contains(&value) {
        return Err(String::from("每条记录必须在 1~1440 分钟之间"));
    }
    Ok(value)
}

fn parse_args(args: &[String]) -> Result<(PathBuf, Command), String> {
    match args {
        [file, command, minutes] if command == "add" => {
            Ok((PathBuf::from(file), Command::Add(parse_minutes(minutes)?)))
        }
        [file, command] if command == "total" => Ok((PathBuf::from(file), Command::Total)),
        _ => Err(USAGE.to_string()),
    }
}

fn decode(input: &str) -> Result<Vec<u32>, String> {
    let mut records = Vec::new();
    for (index, line) in input.lines().enumerate() {
        let minutes =
            parse_minutes(line).map_err(|message| format!("第 {} 行:{message}", index + 1))?;
        records.push(minutes);
    }
    Ok(records)
}

fn encode(records: &[u32]) -> Result<String, String> {
    let mut output = String::new();
    for minutes in records {
        validate_minutes(*minutes)?;
        output.push_str(&format!("{minutes}\n"));
    }
    Ok(output)
}

fn load(path: &Path) -> Result<Vec<u32>, String> {
    match fs::read_to_string(path) {
        Ok(input) => decode(&input),
        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
        Err(error) => Err(format!("读取失败:{error}")),
    }
}

fn total(records: &[u32]) -> Result<u64, String> {
    let mut sum = 0_u64;
    for minutes in records {
        sum = sum
            .checked_add(u64::from(*minutes))
            .ok_or_else(|| String::from("总时长超出范围"))?;
    }
    Ok(sum)
}

fn run() -> Result<(), String> {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let (path, command) = parse_args(&args)?;
    let mut records = load(&path)?;
    match command {
        Command::Add(minutes) => {
            records.push(minutes);
            let content = encode(&records)?;
            fs::write(&path, content).map_err(|error| format!("保存失败:{error}"))?;
            println!("已记录 {minutes} 分钟");
        }
        Command::Total => println!("累计 {} 分钟", total(&records)?),
    }
    Ok(())
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(message) => {
            eprintln!("错误:{message}");
            ExitCode::FAILURE
        }
    }
}

parse_args 只接受两种完整参数形状,parse_minutes 和 validate_minutes 分别检查文本能否转成整数,以及整数是否在业务范围内。编码时也检查范围,防止调用者直接传入无效数字。

decode 按行解析,失败时用 map_err 给原消息加上行号,再由 ? 结束整个解码。假设输入是第一行 25、第二行非数字,不会向调用者交出只含第一条的成功列表。

total 先把每条分钟数转成 u64,再逐项做受检查的加法,避免把总量也限制在单条记录的整数类型里。

下面的演示在下载包 rust-course 中运行,适用于 macOS / Linux shell。使用一个此前不存在的练习目录:

mkdir checkpoint12-demo
cargo run -p rust-basics --example checkpoint12 -- checkpoint12-demo/minutes.txt total
cargo run -p rust-basics --example checkpoint12 -- checkpoint12-demo/minutes.txt add 25
cargo run -p rust-basics --example checkpoint12 -- checkpoint12-demo/minutes.txt add 40
cargo run -p rust-basics --example checkpoint12 -- checkpoint12-demo/minutes.txt total

忽略 Cargo 日志,输出:

累计 0 分钟
已记录 25 分钟
已记录 40 分钟
累计 65 分钟

再制造一份专用的损坏数据,并比较运行前后的字节。以下命令只写刚才建立的练习目录:

printf '25\nbad\n' > checkpoint12-demo/broken.txt
cp checkpoint12-demo/broken.txt checkpoint12-demo/before.txt
cargo run -p rust-basics --example checkpoint12 -- checkpoint12-demo/broken.txt add 20
cmp checkpoint12-demo/before.txt checkpoint12-demo/broken.txt

新增命令应该失败,提示第二行无效;最后的 cmp 成功时没有输出。如果在自动脚本中启用了遇错退出,要为这条预期失败的命令单独处理退出码。

PowerShell 读者可以用文本编辑器在专用目录里建立同样的 UTF-8 文件,再运行 Cargo 命令;这里没有把 shell 的 printf、cp、cmp 写法当作 PowerShell 命令。

运行 cargo test -p rust-basics --example checkpoint12,检查文本往返、边界值、行号和参数形状。文件跨进程保存与损坏文件保留还需要运行上面的命令演示,不能仅凭内存转换测试下结论。

第十八章后:顺序读取多个本机服务 #

这一阶段把参数解析和错误处理组合起来,不引入异步或线程。你需要会启动第十八章的一次性本机服务。

第一档:修改服务响应 #

把练习 JSON 的分钟数从 30 改成 45,重新运行服务,再用它新显示的端口请求一次。随后改成 0,检查 HTTP 200 为什么仍会触发数据错误。练习结束后恢复原来的响应文件。

通过标准是能指出:服务返回的状态码、JSON 字段类型和业务范围分别由哪一段代码检查。不要把响应格式变化归因于客户端是否连上服务器。

第二档:接到失败后继续处理 #

写一个循环,依次处理两份 Result:第一份失败,第二份成功。失败时记录错误,成功时打印数据,最后分别统计数量。

这一档先用手工构造的 Result,不需要网络。比较 match 和在循环里直接用 ? 的控制流程;这次需求要求处理下一项,因此一次失败不能让整个函数提前返回。对比问号时,先让所在的辅助函数返回可以传播该错误的 Result。问号会退出这个函数,不会只跳过失败项;匹配错误后记录并结束当前分支,循环才会继续处理第二项。

第三档:批量客户端 #

从空示例文件实现 checkpoint18:接收 1~3 个本机端口,顺序调用已有的 lesson_http::fetch。不接受任意网址,也不使用公网服务。

每个成功响应打印端口、课程标题和分钟数;每个失败响应打印端口及原因。继续处理后面的端口,最后打印成功和失败数量。全部成功返回退出码 0,任一请求失败返回 1;参数无效由 clap 返回 2。

复用 fetch 时仍保留它的状态码、大小和数据检查。顺序请求没有引入整个批次的绝对截止时间;第十八章说明的阻塞读取超时边界也仍然适用。

参考答案:批量本机客户端

下面是 projects/lesson-http/examples/checkpoint18.rs 的完整内容。它依赖第十八章的库,不能只复制这个文件到没有依赖的新项目后直接编译。循环中每次请求都得到一个单独的成功或失败结果,两个分支分别累加计数;最后由失败总数决定整个批次的退出状态。因此后面的请求即使成功,也不会抹掉前面已经记录的一次失败。

use std::error::Error;
use std::process::ExitCode;

use clap::Parser;

#[derive(Parser)]
#[command(about = "顺序读取最多三个本机课程服务")]
struct Args {
    #[arg(long = "port", required = true, num_args = 1..=3,
        value_parser = clap::value_parser!(u16).range(1..))]
    ports: Vec<u16>,
}

fn main() -> ExitCode {
    let args = Args::parse();
    let mut succeeded = 0;
    let mut failed = 0;
    for port in args.ports {
        match lesson_http::fetch(port) {
            Ok(lesson) => {
                println!("端口 {port}:{},{} 分钟", lesson.title, lesson.minutes);
                succeeded += 1;
            }
            Err(error) => {
                eprintln!("端口 {port} 请求失败:{error}");
                let mut cause = error.source();
                while let Some(source) = cause {
                    eprintln!("原因:{source}");
                    cause = source.source();
                }
                failed += 1;
            }
        }
    }
    println!("成功 {succeeded} 项,失败 {failed} 项");
    if failed == 0 {
        ExitCode::SUCCESS
    } else {
        ExitCode::FAILURE
    }
}

在同一台机器上打开三个终端,都进入 rust-course/projects。终端 A 启动 cargo run -p lesson-http --example serve -- missing,终端 B 启动 cargo run -p lesson-http --example serve。分别记下它们显示的实际端口。

终端 C 运行下面的命令,将两个示意数字换成 A 和 B 的端口,先传失败服务,再传成功服务:

cargo run -p lesson-http --example checkpoint18 -- --port 54321 54322

失败信息写到标准错误,成功信息和统计写到标准输出。成功的课程仍应该被打印,最后一行是:

成功 1 项,失败 1 项

程序退出码为 1。两种输出流在终端上的显示顺序不应作为断言,测试应分别检查内容。

每个服务只接受一次连接,重试前要重新启动。再检查全部成功、全部失败、端口 0、缺少端口和超过三个端口的情况;参数错误不应发出请求。

怎样判断自己已经完成 #

先对照需求逐项检查,再回看答案。每次练习都保留自己写的源码、实际运行的输入,以及成功和失败的结果。结果不一致时,用排错指南缩小问题范围。

如果某一档做不出来,回到对应章节重做最小例子,再回来继续。可以先完成第六章的内存书单,过几天换一个名称和数据重新实现;能解释每个参数的所有权,比记住答案的行数更有用。

这三份参考实现于 2026-10-13 在 macOS arm64、rustc 1.99.0、cargo 1.99.0 上验证。具体执行范围见学习指南,最低工具链和其他系统没有在本轮实测。


教材目录与源码下载 · 学习指南 · 常见错误排查

一起讨论

选中正文中的文字,可以针对这段内容发起讨论。

正在读取讨论…

在 GitHub 查看与管理讨论 ↗

文章讨论