2026最新Rust入门教程[12]:手写一个 minigrep 命令行程序

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. 编写一个会失败的测试并运行它,确认它失败的原因正是你预期的那个。
  2. 编写或修改刚好够用的代码,让新测试通过。
  3. 重构刚刚添加或修改的代码,并确认测试依旧通过。
  4. 从第 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,程序需要遵循以下步骤:

  1. 遍历内容的每一行。
  2. 检查这一行是否包含查询字符串。
  3. 如果包含,把它加进要返回的值列表。
  4. 如果不包含,什么都不做。
  5. 返回匹配的结果列表。

逐步推进,先从逐行遍历开始。

用 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 章「函数式语言特性:迭代器与闭包」------本章末尾用过的迭代器,这次把原理拆开看。

相关推荐
路就在脚下5 小时前
2026最新Rust入门教程[11]
编程语言
计算机魔术师6 小时前
Meta终于交出开放权重,但最强技术还攥在手里
开发工具·编程语言
全栈弄潮儿1 天前
Python实战第6期:列表与元组
后端·python·编程语言
DLite3 天前
NLang 0.9.0发布:成为可嵌入语言
开发语言·编程语言·编译技术·nlang
codigger5 天前
Java 26 深度解析:一次“性能兑现 + 契约收紧”的版本
java·后端·编程·编程语言
Helix2509 天前
2026编程入门指南:零基础如何科学选择第一门编程语言?
java·c语言·javascript·零基础·编程语言·python入门·编程就业
Helix2509 天前
Python与其他编程语言优劣势对比:2026初学者选型指南
java·python·教程·编程语言·入门·初学者编程选型
狼爷10 天前
Rust/Go/Java/Python/PHP 大比拼:负载下后端框架到底差多少?
java·后端·编程语言
天若有情67310 天前
开源|Comfort Lang v1.0.1,一款主打清爽交互的新型命令式编程语言
python·开源·交互·编程语言·项目分享·repl·comfort lang