编程语言研究书库

minigrep:重构出一个合格的命令行程序 — 第 12 章拆解

minigrep:重构出一个合格的命令行程序

这一章做什么: 写一个 mini 版 grep——minigrep 关键词 文件名,输出文件里含关键词的行。 真正的课程是过程:同样一个程序被重构四遍,从 40 行的 main 一路拆成 "薄 main + 库逻辑 + 可测试函数"的结构。读完你要能说出 Rust 二进制项目的标准分工: main 只负责启动和报错,逻辑住在 lib.rs。

1. 顶层全景:四遍重构的地图

  v1  main 一把梭:读参数 → 读文件 → 打印      → 问题:main 身兼数职,无法测试
  v2  抽出 Config::new,配置聚成结构体          → 问题:参数不够时裸奔 index out of bounds
  v3  Config::build 返回 Result;run 返回 Result → main 用 unwrap_or_else / if let 统一报错
  v4  逻辑搬入 lib.rs(search 函数),TDD 补功能   → 搜索可测、可复用;再加环境变量与 stderr

  图说:每一遍都不加新功能,只改结构。这是本章的潜台词:重构先于功能。

2. 第一遍:让程序跑起来

读参数:std::env::args() 返回命令行参数的迭代器,.collect() 收成 Vec<String>(collect 的目标类型推不出来,要标注)。向量第 0 个是程序自己的名字(沿用 C 的惯例),所以用户参数从 args[1]、args[2] 取1。一个坑书上点了名:args 遇到无效 Unicode 会 panic,要宽容就用 args_os(返回 OsString,跨平台更复杂)2。

读文件:fs::read_to_string(file_path) 返回 io::Result<String>,先 .expect(...) 顶着。跑通:cargo run -- the poem.txt 能打印狄金森的诗3。

v1 的四宗罪(书列得很清楚):main 干两件事;配置变量散在局部;expect 的错误信息不区分失败原因;参数缺失时抛的是"给程序员看的"越界错误4。

3. 第二、三遍:抽出 Config 与 run,错误改走 Result

分离模式(main.rs/lib.rs 分工),书里给了明确的清单——main 只剩四件事:调参数解析、做配置、调 lib.rs 的 run、处理 run 的错误。理由:main 没法直接测试,把逻辑搬出去才能测;留下的 main 小到"读一遍就知道对"5。

Config 的进化链,三步各解决一个问题:

  1. parse_config(&args) -> (&str, &str):参数解析离开 main。返回元组,但调用处立刻拆包——书说,"刚造出来就拆开"是抽象没到位的信号6。
  2. Config { query, file_path } 结构体:两个值是"一份配置"这件事终于写进了类型。为了 owned 字段用了 .clone()——书里的态度很务实:这里 clone 一两次完全划算,"先有一个略低效但能跑的程序,好过一上来就过度优化"7。
  3. Config::new → Config::build(返回 Result<Config, &'static str>):参数不足返回 Err("not enough arguments") 而不是 panic。改名有讲究:程序员默认 new 不会失败,会失败的就别叫 new8。

main 侧的统一报错:

let config = Config::build(&args).unwrap_or_else(|err| {
    eprintln!("Problem parsing arguments: {err}");
    process::exit(1);
});

if let Err(e) = run(config) {
    eprintln!("Application error: {e}");
    process::exit(1);
}

unwrap_or_else 首次登场:Ok 取值,Err 时执行你传的闭包(匿名函数,第 13 章展开)9。run 返回 Result<(), Box<dyn Error>>,内部用 ? 传播;Ok(()) 是"只为副作用而调用"的惯用写法。忽略 run 的返回值,编译器警告 unused Result——第 2 章那个"必须表态"的设计再次出现10。

按第 7 章的双 crate 惯例,search 定义到 lib.rs 并标 pub;binary 里 use minigrep::search;。search 的签名是生命周期在实战里的样子:

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();
    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }
    results
}

返回值里的切片借自 contents,而不是 query——所以只有 contents 带 'a。书特意提醒:编译器帮忙提示会把所有参数都标上 'a,那在这里是错的,因为结果跟 query 无关11。

TDD 流程(书给出了完整四步循环):① 先写失败测试(one_result:在 "safe, fast, productive." 里搜 "duct");② 给 unimplemented!() 的实现先返回空 vec,让测试"因错误的原因失败";③ 实现到通过;④ 重构保绿12。

大小写不敏感搜索同样走 TDD:实现是 to_lowercase() 后比较(返回的仍是原始行,不是小写行——值得注意的细节);配置项 ignore_case: bool 进 Config,值来自 env::var("IGNORE_CASE").is_ok()——只关心"设没设",不关心设成什么,所以用 is_ok 而非 unwrap 系13。

5. 收尾:stderr 分流

终端有两路输出:stdout(正常结果)和 stderr(错误信息)。 分列的意义:用户可以把成功的输出重定向进文件(minigrep to poem.txt > output.txt)而不错过屏幕上的报错。println! 只写 stdout;错误要改 eprintln!。改动只有两处(都在 main 里)——这正是"错误处理集中在一处"的红利:改输出通道时不用翻遍代码14。

验证姿势很妙:cargo run > output.txt 不传参数,改之前错误信息混进文件,改之后错误上屏、文件为空15。

6. 作者的判断与证据

  • "main 薄到可以目测验证、逻辑必须可测"——结构性主张,贯穿全章5。
  • "clone 一点字符串换简单,划算"——务实立场,明说反对过度优化7。
  • "会失败的构造函数别叫 new"——命名即契约的社区惯例8。
  • "错误处理集中一处"——本章最重要的架构判断,stderr 改造是它的验证14。

判断(我们的,不是书里的): 本章其实教了一种可迁移的审查清单, 拿来 review 任何 Rust 二进制项目:main 能否一眼读完?所有错误是否汇于一处? 每个会失败的构造函数是否返回 Result?核心逻辑是否可脱离 main 测试? 输出是否分流 stdout/stderr?——这五问覆盖的正是"脚本"与"程序"的分界线。 如果错,会错在: 这套结构对小工具是负担——十个参数的一次性脚本按此拆分, 文件数翻三倍;判据是"这个程序会被维护多久、被多少人读", 本章默认答案是"足够久、足够多",这对教学项目成立,对一次性脚本不成立。

7. 边界与局限

  • to_lowercase 的大小写转换明说"对基本 Unicode 够用,不是 100% 准确",真产品要更细致。
  • search 先把全部结果收进 vector 再打印——大文件上会延迟输出,书里预告第 13 章用迭代器改善。
  • 参数解析手写;真实的 CLI 该用 clap 这类 crate(本书不讲)。

8. 可带走的

  1. env::args().collect::<Vec<String>>() 收参数;args[0] 是程序名。
  2. 结构体化的信号:一对值"总是一起出现、一起传"就该聚成类型。
  3. 会失败的构造函数命名 build,返回 Result。
  4. main 的四件事:解析参数、建配置、调 run、处理错误。
  5. unwrap_or_else 处理 Err 并保留 Ok 值;if let Err(e) 只关心错误时更贴切。
  6. Result<(), Box<dyn Error>> + ? + Ok(()) 是 run 函数的标准形态。
  7. TDD 循环:失败测试 → 最简实现 → 通过 → 重构。
  8. 返回切片借自哪个参数,'a 就标在哪个参数上(help 提示不一定是最佳答案)。
  9. env::var(name).is_ok():检测环境变量"是否设置"。
  10. 错误信息用 eprintln!(stderr),正常结果用 println!(stdout)。

9. 原文地图

主题原书章原文位置
读命令行参数Chapter 12: An I/O Projecttext/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:73(搜「env::args」)
args 与无效 Unicode同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:88(搜「UNICODE」)
读文件同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:194(搜「fs」)
四宗罪同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:247(搜「four problems」)
main/lib 分工清单同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:273(搜「Separating Concerns」)
clone 的权衡同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:403(搜「OF USING CLONE」)
build 与 Result同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:512(搜「Returning a Result」)
unwrap_or_else / exit(1)同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:564(搜「unwrap_or_else」)
run 返回 Result同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:648(搜「Box<dyn Error>」)
search 的生命周期同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:734(搜「search<'a>」)
TDD 循环同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:794(搜「test-driven development」)
大小写不敏感与环境变量同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:1096(搜「to_lowercase」) · text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:1193(搜「env::var」)
stdout/stderr 分流同上text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:1263(搜「Standard Error」) · text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:1308(搜「eprintln!」)

Footnotes

  1. 出处:「Chapter 12: An I/O Project」第 120 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:120,搜「first value in the vector」)。 ↩

  2. 出处:「Chapter 12: An I/O Project」第 90 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:90,搜「invalid Unicode」)。 ↩

  3. 出处:「Chapter 12: An I/O Project」第 200 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:200,搜「read_to_string」)。 ↩

  4. 出处:「Chapter 12: An I/O Project」第 247 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:247,搜「four problems」)。 ↩

  5. 出处:「Chapter 12: An I/O Project」第 273 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:273,搜「Separating Concerns」)。 ↩ ↩2

  6. 出处:「Chapter 12: An I/O Project」第 338 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:338,搜「break that tuple」)。 ↩

  7. 出处:「Chapter 12: An I/O Project」第 403 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:403,搜「OF USING CLONE」)。 ↩ ↩2

  8. 出处:「Chapter 12: An I/O Project」第 516 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:516,搜「expect new functions to never fail」)。 ↩ ↩2

  9. 出处:「Chapter 12: An I/O Project」第 564 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:564,搜「unwrap_or_else」)。 ↩

  10. 出处:「Chapter 12: An I/O Project」第 648 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:648,搜「Box<dyn Error>」)与第 676 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:676,搜「side effects」)。 ↩

  11. 出处:「Chapter 12: An I/O Project」第 734 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:734,搜「search<'a>」)与第 892 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:892,搜「incorrect」)。 ↩

  12. 出处:「Chapter 12: An I/O Project」第 794 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:794,搜「test-driven development」)。 ↩

  13. 出处:「Chapter 12: An I/O Project」第 1096 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:1096,搜「to_lowercase」)与第 1193 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:1193,搜「env::var」)。 ↩

  14. 出处:「Chapter 12: An I/O Project」第 1263 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:1263,搜「Standard Error」)。 ↩ ↩2

  15. 出处:「Chapter 12: An I/O Project」第 1308 段(text/19-ch12-chapter-12-an-i-o-project-building-a-command-lin.txt:1308,搜「eprintln!」)。 ↩

On this page