Rust Book 编译版 12 · I/O 项目:手写一个 minigrep 命令行程序
编译自 The Rust Programming Language(2024 edition),在原文基础上做了删减与补充。© The Rust Project Developers,依据 MIT OR Apache-2.0 许可编译转载。
开始之前
这是全书的第二个项目章(第三个在第 21 章等你)。第 2 章的猜数字游戏只动了最基本的概念,这一章要把中间十几章的知识拼成一个真实程序:接受命令行参数、读文件、错误处理、测试、环境变量,一路串到底。目标工具是 grep(g lobally search a r egular e xpression and print)------最经典的 Unix 工具之一,在一个文件里找出包含某字符串的所有行。这样的程序自己写一遍,胜过看十篇讲解。
Rust 的速度、安全性、单一二进制输出和跨平台支持,让它天生适合写命令行工具。社区成员 Andrew Gallant 已经写出了功能完备、速度极快的 grep 版本 ripgrep;我们的版本会简单得多,但足以让你看懂这类真实项目的骨架。过程中会综合用到组织代码(第 7 章)、向量与字符串(第 8 章)、错误处理(第 9 章)、trait 与生命周期(第 10 章)、编写测试(第 11 章),还会提前见到闭包(closure)、迭代器(iterator)和 trait 对象(trait object)------它们分别在第 13 章和第 18 章展开。
前置知识:第 11 章编写自动化测试。
你会学到:
- 用
std::env::args读取命令行参数,并把程序拆成 main.rs (胶水层)+ lib.rs(核心逻辑)的结构 - 让
Config::build返回Result,用process::exit以非零错误码退出,替代面向用户的panic! - 按测试驱动开发(TDD)的节奏实现
search,顺带拿下「返回引用的函数」的生命周期标注 - 用
IGNORE_CASE环境变量(environment variable)切换大小写不敏感搜索 - 区分
println!(stdout)与eprintln!(stderr),让程序在管道和重定向下行为规矩
接受命令行参数
老规矩,用 cargo new 起一个新项目,命名为 minigrep,和你系统上可能已有的 grep 区分开:
bash
cargo new minigrep
Created binary (application) `minigrep` project
cd minigrep
第一个任务是让 minigrep 接受它的两个命令行参数(command line argument):要搜索的字符串,以及要搜索的文件路径。也就是说,希望能这样运行程序------两个连字符之后的内容是传给我们的程序而不是传给 cargo 的:
bash
cargo run -- searchstring example-filename.txt
眼下 cargo new 生成的程序还处理不了这些参数。crates.io 上有现成的库能帮忙,但既然在学这个概念,我们就自己实现。
读取参数值
读取命令行参数要用标准库的 std::env::args 函数。它返回一个由传给程序的参数组成的迭代器------第 13 章才会全面介绍迭代器,目前知道两点即可:迭代器产出一系列值;对迭代器调用 collect 方法可以把它变成一个集合(collection),比如一个向量(vector),其中包含迭代器产出的所有元素。示例 12-1 就是这么做的:
rust
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
dbg!(args);
}
示例 12-1:把命令行参数收集到向量中并打印出来
先说 use std::env;:args 函数嵌套在两层模块里,按第 7 章的讨论,我们把父模块而不是函数本身引入作用域------这样 std::env 里的其他函数也顺手可用;这也比 use std::env::args 之后裸写 args 更不容易混淆,后者很容易被误认成当前模块中定义的函数。
args 函数与非法 Unicode
一个边界情况:只要有参数包含非法 Unicode,std::env::args 就会 panic。需要接受这类参数的程序应改用 std::env::args_os,它返回的迭代器产出的是 OsString 而不是 String。出于简单起见本章用 args------OsString 因平台而异,用起来比 String 复杂。
main 的第一行调用 env::args,紧接着用 collect 把迭代器变成包含所有值的向量。collect 可以创建多种集合,所以这里显式标注了 args 的类型,指明要一个字符串向量。Rust 里很少需要写类型标注,collect 却是常常需要的,因为 Rust 无法推断你想要哪种集合。
最后用调试宏 dbg! 打印这个向量。先不带参数、再带两个参数各跑一次:
bash
cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
"target/debug/minigrep",
]
bash
cargo run -- needle haystack
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
Running `target/debug/minigrep needle haystack`
[src/main.rs:5:5] args = [
"target/debug/minigrep",
"needle",
"haystack",
]
编译注:
args()返回的本来就是迭代器,这里却先collect()成Vec再按索引访问,看似走了回头路。取舍在于:我们要按位置取args[1]、args[2],向量支持随机索引,迭代器却只能顺序消费;而参数总量小、启动时一次性读完,先收集成Vec最直白。等第 13 章讲完迭代器适配器,你会看到惰性写法。
注意向量里的第一个值是 "target/debug/minigrep"------二进制程序自己的名字。这与 C 语言参数列表的行为一致,让程序得以在执行中使用自己被调用时的名字(打印进消息、或按命令行别名改变行为,都常有用)。本章会忽略它,只保存需要的那两个参数。
将参数值保存到变量中
接下来把两个参数的值存进变量,供程序其余部分使用(示例 12-2):
rust
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
let query = &args[1];
let file_path = &args[2];
println!("Searching for {query}");
println!("In file {file_path}");
}
示例 12-2:创建变量来保存查询参数与文件路径参数
程序名占了索引 0(args[0]),所以从索引 1 开始取:第一个参数是要搜索的字符串,其引用放进 query;第二个是文件路径,其引用放进 file_path。暂时打印这两个变量验证一下:
bash
cargo run -- test sample.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt
参数值进了正确的变量。缺参数时的错误处理稍后补上,眼下先添加读取文件的功能。
读取文件
现在添加读取 file_path 所指文件的功能。先准备一个测试文件:文本量不大、跨多行、有些单词重复出现------示例 12-3 的 Emily Dickinson 这首诗正合适。在项目根目录创建 poem.txt:
text
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
示例 12-3:Emily Dickinson 的一首诗是很好的测试用例。
然后编辑 src/main.rs,加入读取文件的代码(示例 12-4):
rust
use std::env;
use std::fs;
fn main() {
// --省略--
println!("In file {file_path}");
let contents = fs::read_to_string(file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
示例 12-4:读取第二个参数所指定文件的内容
use std::fs 把文件系统模块引入作用域;fs::read_to_string 接收 file_path,打开该文件,返回一个装着文件内容的 std::io::Result<String>。读完再临时打印一次 contents,确认程序到这一步运转正常。随便用一个字符串当第一个参数(搜索还没实现),poem.txt 当第二个参数,运行:
bash
cargo run -- the poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
文件被读取并打印了出来,但代码已经暴露出几处缺陷:main 同时承担多项职责(每个函数只负责一件事会更清晰、更好维护);错误处理也没到位------expect 的消息对用户毫无信息量。程序眼下还小,这些不算大麻烦,但随着规模增长会越来越难干净地修复------趁代码还少尽早重构,是值得养成的习惯。接下来就动手。
重构以改进模块化与错误处理
具体要修四个问题。
第一,main 现在做两件事:解析参数和读取文件。函数职责越多,越难推理、难测试、难在不破坏其他部分的前提下修改------该把功能分开,让每个函数只负责一项任务。
第二,query 和 file_path 其实是程序的配置变量,而 contents 是执行程序逻辑的变量,两类变量混在 main 的作用域里,越堆越难分辨用途------该把配置变量收拢进一个结构体,让它们的用途一目了然。
第三,读文件失败时 expect 只会打印 Should have been able to read the file。文件可能不存在、可能没权限------多种失败情形共用一条消息,用户得不到任何有用信息。
第四,参数不足时用户从 Rust 那里得到的是 index out of bounds 错误,同样没把问题讲清楚。错误处理代码应集中到一处:将来逻辑变更只需改一个地方,也便于确保消息对最终用户确有意义。
在二进制项目中分离关注点
把多项任务压给 main,是二进制项目的通病。Rust 社区的惯用流程是:
- 把程序拆分成
main.rs和lib.rs两个文件,程序逻辑移进lib.rs; - 命令行解析逻辑还小时,可以留在
main函数中; - 它开始变复杂时,再提取成独立的函数或类型。
走完这个流程,main 里剩下的职责应当只有:用参数值调用命令行解析逻辑、设置其余配置、调用 lib.rs 中的 run 函数、处理 run 返回错误时的情形。这就是关注点分离:main.rs 负责运行程序,lib.rs 负责手头任务的全部逻辑。由于没法直接测试 main 函数,这种结构让你把全部逻辑移出去测,留在 main 里的代码少到通读一遍就能核实正确性。
提取参数解析器
第一步,把解析参数的功能提取到一个将由 main 调用的函数中。示例 12-5 展示了 main 新的开头,它调用新函数 parse_config:
rust
fn main() {
let args: Vec<String> = env::args().collect();
let (query, file_path) = parse_config(&args);
// --省略--
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}
示例 12-5:从 main 中提取出 parse_config 函数
我们仍把参数收集成向量,但不再在 main 里按索引取值,而是把整个向量传给 parse_config,由它持有「哪个参数放进哪个变量」的逻辑并把值传回来。main 里依然创建 query 和 file_path 变量,但不再承担参数与变量如何对应的职责。
对小程序来说这步改造显得用力过猛,但重构本就该以小的、渐进的步骤推进。改完再跑一次程序,确认参数解析依然正常------经常检查进展,问题出现时才好定位。
将配置值分组
parse_config 还能再改进。眼下返回的是元组,返回后又被立刻拆散------这是个信号:抽象还没找对。另一个迹象是函数名里的 config:它暗示这两个值是相关联的、同属一个配置,而这层含义没有体现在数据结构里。改法是把两个值放进结构体并给字段取有意义的名字,让未来的维护者一眼看懂各值的关系与用途。示例 12-6:
rust
fn main() {
let args: Vec<String> = env::args().collect();
let config = parse_config(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
// --省略--
}
struct Config {
query: String,
file_path: String,
}
fn parse_config(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
示例 12-6:重构 parse_config,使其返回一个 Config 结构体的实例
新增的 Config 结构体拥有 query 和 file_path 字段,parse_config 的签名现在直接表明它返回一个 Config 值。注意一个所有权细节:过去返回的是引用 args 中 String 值的字符串切片,现在 Config 持有拥有所有权的 String。main 里的 args 是这些值的所有者,parse_config 只是借用------如果 Config 试图拿走 args 中值的所有权,就违反了 Rust 的借用规则。
使用 clone 的权衡
处理这层所有权关系有多种方式,最简单(尽管略低效)的是对值调用 clone:为 Config 实例做一份完整拷贝,比存引用更费时间和内存,但代码非常直白------不必管理引用的生命周期。许多 Rustacean 出于运行时开销的顾虑,倾向于避免用 clone 解决所有权问题,第 13 章会讲更高效的方法;但现在,拷贝几个字符串完全没问题------这种拷贝只发生一次,而且文件路径和查询字符串都非常小。宁可要一个稍微低效但能正常工作的程序,也不要第一遍就把代码优化到极致;随着经验增长,从最高效的方案入手会越来越自然。
回到代码:main 把 parse_config 返回的 Config 实例存进 config,先前用独立变量的地方改为使用结构体字段。现在代码更清楚地表达了 query 和 file_path 是相关的、用途是配置程序的工作方式------任何使用这些值的代码,都知道去 config 实例中按用途命名的字段里找。
为 Config 创建构造函数
既然 parse_config 的用途就是创建 Config 实例,我们把它从普通函数改成与 Config 关联的 new 函数------就像用 String::new 创建 String 的实例那样,以后就能用 Config::new 创建 Config,更符合惯例。示例 12-7 展示了改动:
rust
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
// --省略--
}
// --省略--
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
示例 12-7:把 parse_config 改成 Config::new
改动有三处:main 里的调用改为 Config::new,函数改名为 new,并移进 impl 块使它与 Config 相关联。重新编译确认正常。
修复错误处理
现在修错误处理。回想一下:args 向量元素少于三个时,访问索引 1 或 2 会让程序 panic。不带参数运行试试:
bash
cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' (6023615) panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
index out of bounds: the len is 1 but the index is 1 是面向程序员的错误消息,帮不了最终用户。
改进错误消息
示例 12-8 在 new 里加一项检查:访问索引 1 和 2 之前先确认切片足够长,不够就 panic 并显示一条更好的错误消息:
rust
// --省略--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
// --省略--
示例 12-8:为参数数量添加检查
这和示例 9-13 的 Guess::new 是同一招:那里检查的是取值范围,这里检查 args 的长度至少为 3,让函数其余部分在这一前提成立下运行。再不带参数运行一次:
bash
cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' (6023776) panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
错误消息合理了,但输出里还夹着 thread 'main' 和 RUST_BACKTRACE 这些不想给用户看的东西。正如第 9 章讨论过的,panic! 更适合应对编程问题而非使用问题------该换第 9 章学过的另一种技术:返回一个表明成功或错误的 Result。
返回 Result 而不是调用 panic!
改成返回 Result:成功时包含 Config 实例,出错时描述问题。函数名同时从 new 改为 build------许多程序员默认 new 函数永远不该失败。这样 Config::build 与 main 沟通时就能用类型示意出了问题,main 再把 Err 变体转换成对用户更实用的错误,而不带 panic! 那段关于 thread 'main' 和 RUST_BACKTRACE 的文字。示例 12-9 展示了改动;注意在 main 一并更新之前,这段代码编译不过:
rust
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
示例 12-9:让 Config::build 返回 Result
build 的错误值将始终是具有 'static 生命周期的字符串字面量。函数体改动两处:参数不足时返回 Err 值而不是调用 panic!;要返回的 Config 值包进 Ok。
调用 Config::build 并处理错误
main 侧的处理见示例 12-10,同时把「以非零错误码退出」这个命令行工具的职责从 panic! 手里接过来------非零退出状态是向调用方进程示意「本程序以错误状态退出」的约定:
rust
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
// --省略--
示例 12-10:构建 Config 失败时以错误码退出
这里用到一个尚未细讲的方法:定义在 Result<T, E> 上的 unwrap_or_else。Result 是 Ok 时它和 unwrap 一样返回内部值;是 Err 时则调用传给它的闭包------闭包就是定义在两根竖线之间的匿名函数,第 13 章详谈。Err 的内部值(此例中即示例 12-9 添加的静态字符串 "not enough arguments")会作为参数 err 传进闭包使用。
新增的 use std::process; 正是为闭包里的两行代码服务:打印 err,然后 process::exit------立即停止程序,并把传入的数字作为退出状态码返回。与示例 12-8 基于 panic! 的方式效果相似,但不再有那一大堆额外输出:
bash
cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
这个输出对用户友好多了。
从 main 中提取逻辑
配置解析的重构完成,轮到程序逻辑。按「在二进制项目中分离关注点」一节的计划,提取一个 run 函数,收拢 main 中所有与配置、错误处理无关的逻辑,让 main 简洁得一目了然,其余逻辑都可以写测试。示例 12-11:
rust
fn main() {
// --省略--
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
run(config);
}
fn run(config: Config) {
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
// --省略--
示例 12-11:提取包含其余程序逻辑的 run 函数
run 以 Config 实例为参数,从读取文件开始持有 main 的全部剩余逻辑。
从 run 中返回错误
接着按 Config::build 的思路改进 run 的错误处理:不再 expect 放任 panic,而是返回 Result<T, E>,把错误处理进一步集中到 main。示例 12-12 是签名和函数体的改动:
text
use std::error::Error;
// --省略--
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
示例 12-12:把 run 函数改为返回 Result
三处重要改动。第一,返回类型改成 Result<(), Box<dyn Error>>(并用 use 把 std::error::Error 引入顶部作用域),成功时保留原先的单元类型 ()。错误类型用的是 trait 对象 Box<dyn Error>,第 18 章才正式介绍;现在只需知道,它意味着「返回一个实现了 Error trait 的类型,而不必指明具体是哪个」------不同错误情形可以返回不同类型的错误值,灵活性来自这里。dyn 是 dynamic(动态)的缩写。
编译注:
Box<dyn Error>不是新面孔------第 9 章讲?运算符的使用限制时,main就短暂地返回过Result<(), Box<dyn Error>>。它是「先不把错误类型定死」的权宜之计:run里的?现在抛的是io::Error,将来加了别的操作还会抛别的错误类型,统一装进盒子就不必逐个枚举;main拿到后用{e}打印(Display格式化),对命令行工具足够了。
第二,去掉 expect 改用 ? 运算符:出错时不 panic!,而是把错误值从当前函数返回,交给调用者处理。
如果
?忘记了可以回去错误的传播看看
第三,成功时返回 Ok(())。签名把成功类型声明为 (),就得把单元类型的值包进 Ok 值------这个语法乍看有点怪,但用它表明「调用 run 只是为了副作用,不需要它返回值」是地道的写法。
这段代码能编译,但会显示一个警告:
bash
cargo run -- the poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
--> src/main.rs:19:5
|
19 | run(config);
| ^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
|
19 | let _ = run(config);
| +++++++
warning: `minigrep` (bin "minigrep") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
Rust 在提醒:run 返回的 Result 被忽略了,而它可能装着错误------这里或许本该有错误处理代码。现在纠正。
在 main 中处理 run 返回的错误
用和 Config::build 类似的技术检查并处理,但略有不同:
文件名:src/main.rs
text
fn main() {
// --省略--
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
这里用 if let 而不是 unwrap_or_else:run 成功时返回的只是 (),没有要以同样方式 unwrap 的值,我们只关心有没有检测到错误,用不上「解包成功值」的能力。两种写法的主体都一样:打印错误,然后退出。
将代码拆分为库 crate
是时候把 src/main.rs 拆开了:一部分代码移进 src/lib.rs。这一步之后代码既能测试,main.rs 承担的职责也更少。我们特别要把负责搜索文本的代码放进 src/lib.rs------这样我们(以及其他任何想用 minigrep 库的人)就能在比这个二进制程序更多的上下文中调用搜索函数。拆分后的结构:
text
minigrep/
├── Cargo.toml
├── poem.txt # 测试数据(示例 12-3 的诗)
└── src/
├── main.rs # 二进制 crate:main + Config + run
│ # 解析参数、处理错误、调用库------只做胶水
└── lib.rs # 库 crate:search 等核心逻辑
# 不碰命令行,可以独立测试与复用
首先,如示例 12-13 所示,在 src/lib.rs 中定义 search 函数的签名,函数体先调用 unimplemented! 宏占位;实现填进去之后,再回头细看这个签名:
rust
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
示例 12-13:在 src/lib.rs 中定义 search 函数
pub 关键字把 search 指定为库 crate(library crate)公共 API 的一部分。至此我们有了既能在二进制 crate(binary crate)中使用、又能拿来测试的库 crate。接着把 src/lib.rs 中定义的代码引入 src/main.rs 的作用域并调用,如示例 12-14 所示:
rust
// --省略--
use minigrep::search;
fn main() {
// --省略--
}
// --省略--
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
for line in search(&config.query, &contents) {
println!("{line}");
}
Ok(())
}
示例 12-14:在 src/main.rs 中使用 minigrep 库 crate 的 search 函数
use minigrep::search 把库 crate 的 search 函数引入二进制 crate 的作用域;run 不再打印文件全部内容,而是把 config.query 和 contents 传给 search,再用 for 循环打印返回的每个匹配行。正好把 main 里那几处显示查询内容和文件路径的 println! 删掉,让程序只打印搜索结果(前提是没有发生错误)。
注意,search 会在任何打印发生之前把全部结果收集进返回的向量------搜索大文件时,结果的显示可能因此变慢,第 13 章会用迭代器讨论一种可能的改进方案。
费了些功夫,但值得:现在错误处理容易了,模块化程度也高了。从这里开始,几乎所有工作都在 src/lib.rs 中完成。利用这份新得到的模块性,做一件旧代码很难做到、新代码轻而易举的事------编写测试。
用测试驱动开发为程序添加功能
搜索逻辑已经从 main 分离进 src/lib.rs,为核心功能编写测试就容易了:直接用各种参数调用函数并检查返回值,不必非得从命令行驱动整个二进制程序。
本节按测试驱动开发(test-driven development, TDD)的流程为 minigrep 添加搜索逻辑,步骤如下:
- 编写一个会失败的测试并运行它,确认它失败的原因正是你预期的那个。
- 编写或修改刚好够用的代码,让新测试通过。
- 重构刚刚添加或修改的代码,并确认测试依旧通过。
- 从第 1 步开始重复!
TDD 只是编写软件的众多方式之一,但它有助于驱动代码设计:在编写让测试通过的代码之前先写测试,能在整个开发过程中保持较高的测试覆盖率。我们要测试驱动地实现的功能是:在文件内容中搜索查询字符串,产出一个由匹配行组成的列表,放进名为 search 的函数。
编译注: 这套循环就是业界说的「红-绿-重构」:先写测试看它失败(红),再写最少实现看它通过(绿),然后在测试保护下重构。新手常把顺序反过来------写完实现再补测试,测试只是给已有代码背书;TDD 把测试提前,让它反过来约束接口设计。本章你会完整走两轮:大小写敏感搜索一轮,大小写不敏感搜索一轮。
编写一个失败的测试
在 src/lib.rs 中按第 11 章的方式添加一个包含测试函数的 tests 模块。这个测试给出了我们希望 search 具备的行为:接收一个查询字符串和要搜索的文本,只返回文本中包含该查询字符串的那些行。示例 12-15:
rust
// --省略--
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
示例 12-15:为我们希望拥有的功能创建一个会失败的 search 函数测试
测试搜索字符串 "duct",三行文本中只有一行包含它(注意开头双引号后面的反斜杠,它告诉 Rust 不要在字符串字面量内容的开头放置换行符)。我们断言 search 的返回值只包含期望的那一行。
眼下运行这个测试会失败------unimplemented! 宏会以 "not implemented" 消息 panic。遵循 TDD 原则先迈一小步:只添加刚好让测试在调用函数时不再 panic 的代码,把 search 定义为总是返回空 vector(示例 12-16)。测试能编译,但仍会失败------空 vector 与包含期望行的 vector 不匹配:
rust
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
示例 12-16:只定义刚好让调用它时不会 panic 的 search 函数
这里值得停下来说生命周期。为什么 search 的签名要显式定义生命周期 'a,并把它用在 contents 参数和返回值上?回忆第 10 章:生命周期参数指明哪个参数的生命周期与返回值的生命周期相关联。本例指明:返回的 vector 中包含的字符串切片,引用的是参数 contents(而非 query)的切片。
换句话说,我们告诉 Rust:search 返回的数据与以 contents 传进来的数据活得一样久。这一点非常重要------切片所引用 的数据必须保持有效,引用本身才能有效;如果编译器以为我们是在对 query 而不是 contents 做字符串切片,它的安全检查就会出偏差。
漏掉生命周期标注就去编译这个函数,会得到这个错误:
bash
cargo build
Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
--> src/lib.rs:1:51
|
1 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
|
1 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error
Rust 无法知道输出需要借用这两个参数中的哪一个,必须显式告诉它。注意,帮助文本建议为所有参数和输出类型指定同一个生命周期参数------这并不正确!contents 才是承载全部文本、我们要从中返回匹配的参数,只有它应当用生命周期语法与返回值相关联。其他编程语言不要求在签名里把参数和返回值关联起来,但这种写法会越来越顺手;不妨与第 10 章「用生命周期验证引用」一节的示例对照着看看。
编写让测试通过的代码
测试失败是因为总返回空 vector。要实现 search,程序需要遵循以下步骤:
- 遍历内容的每一行。
- 检查这一行是否包含查询字符串。
- 如果包含,把它加进要返回的值列表。
- 如果不包含,什么都不做。
- 返回匹配的结果列表。
逐步推进,先从逐行遍历开始。
用 lines 方法逐行遍历
Rust 为字符串逐行迭代提供了名字贴切的 lines 方法,用法如示例 12-17 所示。注意,这段代码眼下还编译不过:
rust
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
for line in contents.lines() {
// 对这一行做些处理
}
}
示例 12-17:遍历 contents 中的每一行
lines 方法返回一个迭代器(iterator),第 13 章深入讨论。你在示例 3-5已经见过这种用法:for 循环配一个迭代器,对集合中的每个条目执行一些代码。
在每一行中搜索查询字符串
接下来检查当前行是否包含查询字符串。幸运的是,字符串自带一个干这活的好方法,名叫 contains。在 search 里加上对它的调用(示例 12-18)。这段代码仍然编译不过:
rust
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
for line in contents.lines() {
if line.contains(query) {
// 对这一行做些处理
}
}
}
示例 12-18:增加检查该行是否包含 query 中字符串的功能
还在逐步搭建。要让代码编译通过,需要按函数签名里声明的那样,从函数体返回一个值。
存储匹配的行
最后一步:在 for 循环之前创建一个可变 vector,调用 push 把匹配的 line 存进去,循环结束后返回它(示例 12-19):
rust
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
}
示例 12-19:存储匹配的行,以便返回它们
search 现在应当只返回包含 query 的行了。运行测试:
bash
cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 1 test
test tests::one_result ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
测试通过,说明它是有效的!此时可以考虑在保持测试通过、功能不变的前提下重构:search 的代码不算差,但还没用上迭代器的一些实用特性------第 13 章会回到这个例子深入探究并改进它。
整个程序现在应该都能正常工作了。先用一个应当恰好从诗里返回一行的词 frog 试试:
bash
cargo run -- frog poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
Running `target/debug/minigrep frog poem.txt`
How public, like a frog
再试一个能匹配多行的词 body:
bash
cargo run -- body poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!
最后确认:搜索一个诗里根本没出现过的词(比如 monomorphization)时,不会得到任何行:
bash
cargo run -- monomorphization poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep monomorphization poem.txt`
我们造出了自己的一版经典工具迷你版,顺带练了组织应用程序结构、文件 I/O、生命周期、测试和命令行解析。作为项目收尾,再简要演示两样写命令行程序很有用的东西:环境变量(environment variable)和打印到标准错误(standard error)。
使用环境变量
给 minigrep 增添一项功能:用户可通过环境变量开启的大小写不敏感搜索。做成命令行选项也行,但用户每次想用都得敲一遍;改成环境变量后,设置一次,该终端会话里的所有搜索就都生效。
为大小写不敏感搜索编写失败的测试
先在库里添加一个新的 search_case_insensitive 函数,它会在环境变量有值时被调用。继续遵循 TDD 流程,第一步照旧是写一个失败的测试:为新函数添加测试,并把旧测试从 one_result 改名为 case_sensitive,以凸显两个测试的差异(示例 12-20):
rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn case_sensitive() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";
assert_eq!(
vec!["Rust:", "Trust me."],
search_case_insensitive(query, contents)
);
}
}
示例 12-20:为即将添加的大小写不敏感函数新增一个失败的测试
注意旧测试的 contents 也改了:新增一行 "Duct tape.",其中的大写 D 在大小写敏感搜索下不应匹配 "duct"。以这种方式修改旧测试,能确保开发新功能的过程中不会无意破坏已实现的功能------它现在应当通过,且在开发大小写不敏感搜索期间应一直保持通过。
大小写不敏感 搜索的新测试用 "rUsT" 作查询:应当匹配带大写 R 的 "Rust:" 所在行,也应当匹配 "Trust me.",尽管两者的大小写都与查询不同。这个失败测试眼下压根编译不过------search_case_insensitive 还没定义。可以像示例 12-16 处理 search 那样,先加一个总返回空 vector 的骨架实现,看测试能编译但运行失败。
实现 search_case_insensitive 函数
示例 12-21 与 search 函数几乎一模一样,唯一的区别:把 query 和每一 line 都转为小写,这样无论输入是什么大小写,比较时两边的大小写都是一致的:
rust
pub fn search_case_insensitive<'a>(
query: &str,
contents: &'a str,
) -> Vec<&'a str> {
let query = query.to_lowercase();
let mut results = Vec::new();
for line in contents.lines() {
if line.to_lowercase().contains(&query) {
results.push(line);
}
}
results
}
示例 12-21:定义 search_case_insensitive 函数,在比较之前把查询字符串和该行都转为小写
先把 query 转为小写存进同名的新变量,遮蔽(shadow)原先的值:无论用户输入 "rust"、"RUST"、"Rust" 还是 "rUsT",都按 "rust" 处理,对大小写不再敏感。to_lowercase 能处理基本的 Unicode 但做不到百分之百准确;真正的应用程序这里还得再下功夫,不过本节主题是环境变量,到此为止。
注意 query 现在是 String 而不是字符串切片------to_lowercase 创建新数据而非引用已有数据("rUsT" 里没有小写的 u 和 t 可供使用,必须分配一个新 String)。因此把 query 传给 contains 时要加一个与号(&),因为 contains 的签名定义为接收字符串切片。接着对每个 line 调用 to_lowercase 把所有字符转为小写------两边都是小写,无论查询是什么大小写都能找到匹配。
看看测试:
bash
cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
都通过了。现在在 run 里调用新函数:先给 Config 结构体加一个配置项,用于在大小写敏感与不敏感搜索之间切换。加上这个字段会引发编译错误,因为还没有任何地方初始化它:
文件名:src/main.rs
rust
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
接着让 run 检查 ignore_case 字段的值,据此决定调用哪个搜索函数(示例 12-22)。这段代码眼下还编译不过:
rust
use minigrep::{search, search_case_insensitive};
// --省略--
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
示例 12-22:根据 config.ignore_case 的值调用 search 或 search_case_insensitive
最后检查环境变量。处理环境变量的函数位于标准库的 env 模块中,src/main.rs 顶部已经把它引入了作用域。用 env::var 检查名为 IGNORE_CASE 的环境变量是否被设置了任何值(示例 12-23):
rust
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
示例 12-23:检查名为 IGNORE_CASE 的环境变量是否设置了任何值
env::var 返回一个 Result:环境变量被设置为任何值时,是装着该值的成功变体 Ok;没被设置时,返回 Err 变体。我们用 is_ok 方法检查它是否被设置------为真就意味着执行大小写不敏感搜索。我们并不关心环境变量的值 ,只关心设没设置,所以检查 is_ok,而不是用 unwrap、expect 或其他任何在 Result 上见过的方法。ignore_case 的值最终传进 Config 实例,run 读它并决定调用 search_case_insensitive 还是 search。
试试。先不设置环境变量,查询字符串用 to,应当匹配所有包含全小写单词 to 的行:
bash
cargo run -- to poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!
一切照常。现在把 IGNORE_CASE 设置为 1,用同样的查询字符串运行:
bash
IGNORE_CASE=1 cargo run -- to poem.txt
(Windows PowerShell 用户需把设置和运行拆成两条命令:先 $Env:IGNORE_CASE=1; 再 cargo run -- to poem.txt,该变量会在当前 shell 会话剩余时间里持续生效,Remove-Item Env:IGNORE_CASE 可取消设置。)
应当得到包含 to(可能带大写字母)的那些行:
bash
Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!
包含 To 的行也拿到了。minigrep 现在可以做由环境变量控制的大小写不敏感搜索;至此,你已经知道如何管理用命令行参数或环境变量设置的选项了。有些程序允许对同一项配置同时使用参数和 环境变量,并自行决定谁优先------留个练习:试着让 minigrep 同时支持两者,并决定当一方设为大小写敏感、另一方设为忽略大小写时,谁应当优先。std::env 模块还包含许多处理环境变量的实用功能,去翻翻它的文档。
将错误重定向到标准错误
目前所有输出都经 println! 宏打印到终端。多数终端把输出分为两类:用于一般信息的标准输出 (stdout),与用于错误消息的标准错误 (stderr)。这种区分让用户可以把成功运行的输出导向文件,同时错误消息仍打印在屏幕上。println! 只能打印到标准输出,要打印到标准错误就得换工具。
检查错误被写到了哪里
先观察现状:minigrep 的所有内容------包括那些本想写到标准错误的错误消息------目前都进了标准输出。做法是:把标准输出流重定向到一个文件,同时故意触发一个错误;标准错误流没有重定向,发往它的内容仍会显示在屏幕上。
命令行程序应当把错误消息发送到标准错误流,这样标准输出被重定向到文件后,我们仍能在屏幕上看到错误。我们的程序目前并不规矩------马上就会看到,它把错误消息存进了文件。用 > 加上重定向目标 output.txt 运行程序,不传任何参数以触发错误:
bash
cargo run > output.txt
> 语法告诉 shell 把标准输出的内容写入 output.txt 而不是屏幕。屏幕上没有期待中的错误消息,说明它进了文件------output.txt 的内容如下:
text
Problem parsing arguments: not enough arguments
错误消息正被打印到标准输出。让它走标准错误要有用得多------文件里就只会留下成功运行产生的数据。改一改。
把错误打印到标准错误
多亏本章前面的重构,所有打印错误消息的代码都集中在一个函数------main 里。标准库提供了向标准错误流打印的 eprintln! 宏,把原先用 println! 打印错误的两处改成使用它(示例 12-24):
rust
fn main() {
let args: Vec<String> = env::args().collect();
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);
}
}
示例 12-24:用 eprintln! 把错误消息写入标准错误而不是标准输出
以同样的方式再运行一次:不传参数,> 重定向标准输出:
bash
cargo run > output.txt
Problem parsing arguments: not enough arguments
这次错误出现在屏幕上,output.txt 里空空如也------正是我们期望命令行程序具备的行为。再运行一次,这次传入不会引发错误的参数,仍把标准输出重定向到文件:
bash
cargo run -- to poem.txt > output.txt
终端上没有任何输出,结果都在 output.txt 里:
文件名:output.txt
text
Are you nobody, too?
How dreary to be somebody!
成功输出走标准输出,错误输出走标准错误------现在用得恰到好处。
编译注: stdout 与 stderr 的区分是 Unix 哲学的地基之一:程序的正常输出要能安全地接进管道与重定向。设想把 minigrep 接进管道统计行数(
minigrep to poem.txt | wc -l):错误若混在 stdout 里,就会被wc当数据一起数进去。所以规则很朴素:数据走 stdout,给人看的诊断走 stderr 。对应到 shell:>只重定向 stdout,2>重定向 stderr,2>&1把两者合并;你(的AI)天天用的grep、ls都遵守这条约定,ripgrep也不例外。
小结与下一步
- 一个完整走通的命令行工具骨架:解析参数(
std::env::args)→ 读文件(fs::read_to_string)→ 搜索 → 输出,外加用环境变量(env::var)控制行为 - 结构上把二进制拆成 main.rs (
main/Config::build/run:胶水与错误处理)+ lib.rs (search等核心逻辑)------main无法直接测试,逻辑必须搬出去才能测 - 错误处理三件套:
build返回Result、?向上传播、process::exit(1)非零退出码;面向用户的错误用eprintln!走 stderr search返回Vec<&'a str>的生命周期标注:告诉编译器返回值借用自contents而非query- TDD 红绿节奏实战两轮:先写会失败的测试,再写刚好够用的实现,最后在测试保护下重构
clone的权衡:启动期拷贝一次小字符串,换取不用管理引用的生命周期------这个阶段的正当选择
下一篇:第 13 章「函数式语言特性:迭代器与闭包」------本章末尾用过的迭代器,这次把原理拆开看。