上一篇: 11-编写自动化测试
本章是对迄今为止所学到的许多技能的回顾,也是对一些标准库特性的探索。我们将创建一个与文件和命令行输入/输出交互的命令行工具,以练习你现在掌握的一些 Rust 概念。
Rust 的速度、安全性、单一二进制输出和跨平台支持使其成为创建命令行工具的理想语言,因此在我们的项目中,我们将制作自己版本的经典命令行搜索工具 grep (全局搜索正则表达式并打印)。在最简单的使用情况下, grep 搜索指定文件中的指定字符串。为此, grep 将文件路径和字符串作为参数。然后,它读取文件,查找文件中包含字符串参数的行,并打印这些行。
在此过程中,我们将展示如何让我们的命令行工具使用许多其他命令行工具所使用的终端功能。我们将读取环境变量的值,以便用户配置工具的行为。我们还将把错误信息打印到标准错误控制台流 ( stderr ) 而不是标准输出 ( stdout ) 中,这样,用户就可以将成功输出重定向到文件,同时还能在屏幕上看到错误信息。
一位 Rust 社区成员 Andrew Gallant 已经创建了一个功能齐全、速度极快的 grep 版本,名为 ripgrep 。相比之下,我们的版本将相当简单,但本章将为你提供一些了解真实世界项目(如 ripgrep )所需的背景知识。
我们的 grep 项目将结合您目前所学到的多个概念:
①. 组织代码(使用第 7 章中所学的模块知识);
②. 使用向量和字符串(集合,第 8 章);
③. 处理错误 ( 第 9 章 );
④. 合理使用特质和生命周期(第 10 章);
⑤. 编写测试 ( 第 11 章 );
我们还将简要介绍闭包、迭代器和特质对象,第 13 章和第 17 章将详细介绍这些内容。
让我们创建一个新项目,一如既往地使用 cargo new 。我们将把项目命名为 minigrep ,以区别于系统中已有的 grep 工具。
- cargo.exe new minigrep
- Created binary (application) `minigrep` package
第一项任务是让 minigrep 接受两个命令行参数:文件路径和要搜索的字符串。也就是说,我们希望在运行我们的程序时,可以使用 cargo run 、两个连字符表示下面的参数是给我们的程序而不是给 cargo 、一个要搜索的字符串和一个要搜索的文件路径,就像这样:
$ cargo run -- searchstring example-filename.txt
现在, cargo new 生成的程序无法处理我们给它的参数。crates.io上的一些现有库可以帮助编写一个接受命令行参数的程序,但因为你才刚开始学习这个概念,所以还是让我们自己来实现这个功能吧。
为了使 minigrep 能够读取我们传递给它的命令行参数值,我们需要使用 Rust 标准库中提供的 std::env::args 函数。该函数返回传给 minigrep 的命令行参数的迭代器。我们将在第 13 章全面介绍迭代器。现在,你只需要知道关于迭代器的两个细节:迭代器会产生一系列值,我们可以调用迭代器上的 collect 方法将其转化为一个集合,例如向量,其中包含了迭代器产生的所有元素。
清单 12-1 中的代码允许 minigrep 程序读取传给它的任何命令行参数,然后将这些值收集到一个向量中。
- use std::env;
-
- fn main() {
- let args: Vec<String> = env::args().collect();
- dbg!(args);
- }
(清单 12-1:将命令行参数收集到矢量中并打印出来)
首先,我们使用 use 语句将 std::env 模块引入作用域,这样就可以使用其 args 函数。注意 std::env::args 函数嵌套在两级模块中。正如我们在第 7 章中所讨论的,在所需函数嵌套在多个模块中的情况下,我们选择将父模块而不是函数引入作用域。通过这种方法,我们可以很容易地使用 std::env 中的其他函数。这也比添加 use std::env::args ,然后只调用 args 的做法更容易引起歧义,因为 args 很容易被误认为是当前模块中定义的函数。
args 函数和无效的 Unicode
请注意,如果任何参数包含无效的 Unicode, std::env::args 就会出错。如果您的程序需要接受包含无效 Unicode 的参数,请使用 std::env::args_os 代替。该函数返回一个迭代器,生成 OsString 值而不是 String 值。为了简单起见,我们选择在此使用 std::env::args ,因为 OsString 的值因平台而异,处理起来比 String 的值更复杂。
在 main 的第一行,我们调用了 env::args ,并立即使用 collect 将迭代器转换为包含迭代器产生的所有值的向量。我们可以使用 collect 函数创建多种类型的集合,因此我们显式地注解了 args 的类型,指定我们需要一个字符串向量。虽然在 Rust 中我们很少需要注解类型,但 collect 是一个你经常需要注解的函数,因为 Rust 无法推断出你想要的集合类型。
最后,我们使用调试宏打印向量。让我们先在没有参数的情况下运行代码,然后在有两个参数的情况下运行代码:
- cargo.exe run
- Compiling minigrep v0.1.0 (E:\rustProj\minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 1.20s
- Running `target\debug\minigrep.exe`
- [src\main.rs:5] args = [
- "target\\debug\\minigrep.exe",
- ]
-
- cargo.exe run -- needle haystack
- Finished dev [unoptimized + debuginfo] target(s) in 0.00s
- Running `target\debug\minigrep.exe needle haystack`
- [src\main.rs:5] args = [
- "target\\debug\\minigrep.exe",
- "needle",
- "haystack",
- ]
请注意,向量中的第一个值是 "target/debug/minigrep" ,也就是二进制文件的名称。这与 C 语言参数列表的行为一致,让程序在执行时使用调用的名称。如果要在信息中打印程序名称,或者根据调用程序时使用的命令行别名来改变程序的行为,那么访问程序名称通常会很方便。但在本章中,我们将忽略它,只保存我们需要的两个参数。
程序目前可以访问作为命令行参数指定的值。现在,我们需要将这两个参数的值保存到变量中,以便在程序的其余部分中使用这些值。我们将在清单 12-2 中完成这项工作。
- 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:创建变量来保存查询参数和文件路径参数)
正如我们在打印向量时看到的那样,程序名称占用向量中的第一个值 args[0] ,因此我们从索引 1 开始输入参数。 minigrep 的第一个参数是我们要搜索的字符串,因此我们在变量 query 中放入对第一个参数的引用。第二个参数是文件路径,因此我们将第二个参数的引用放入变量 file_path 中。
我们暂时打印这些变量的值,以证明代码按我们的意图运行。让我们使用参数 test 和 sample.txt 再次运行这个程序:
- cargo.exe run -- test sample.txt
- Compiling minigrep v0.1.0 (E:\rustProj\minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.72s
- Running `target\debug\minigrep.exe test sample.txt`
- Searching for test
- In file sample.txt
很好,程序正在运行!我们需要的参数值被保存到了正确的变量中。稍后,我们将添加一些错误处理功能,以处理某些潜在的错误情况,例如用户没有提供参数时;现在,我们将忽略这种情况,转而努力添加文件读取功能。
现在,我们将添加读取 file_path 参数中指定文件的功能。首先,我们需要一个示例文件来进行测试:我们将使用一个包含多行少量文本和一些重复单词的文件。清单 12-3 中有一首艾米莉-狄金森的诗,可以很好地解决这个问题!在项目根目录下创建一个名为 poem.txt 的文件,然后输入诗歌 "I'm Nobody!你是谁?
- 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:艾米莉-狄金森的一首诗是一个很好的测试案例)
文本就绪后,编辑 src/main.rs,添加代码以读取文件,如清单 12-4 所示。
- use std::env;
- use std::fs;
-
- 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);
-
- 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 来处理文件。
在 main 中,新语句 fs::read_to_string 接收 file_path ,打开该文件,并返回该文件内容的 std::io::Result
之后,我们再次添加一条临时 println! 语句,在读取文件后打印 contents 的值,以便检查程序是否正常运行。
让我们运行这段代码,以任意字符串作为第一个命令行参数(因为我们还没有实现搜索部分),以 poem.txt 文件作为第二个参数:
- cargo.exe run -- the poem.txt
- Finished dev [unoptimized + debuginfo] target(s) in 0.00s
- Running `target\debug\minigrep.exe 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 函数有多个职责:一般来说,如果每个函数只负责一个想法,那么函数会更清晰,也更容易维护。另一个问题是,我们在处理错误方面做得不够好。这个程序还很小,所以这些缺陷并不是什么大问题,但随着程序的发展,要想干净利落地修复这些缺陷就会变得更加困难。在开发程序的早期就开始重构是一种很好的做法,因为重构少量代码要容易得多。接下来,我们将进行重构。
为了改进我们的程序,我们将修正四个问题,这些问题与程序的结构和处理潜在错误的方式有关。首先,我们的 main 函数现在执行两项任务:解析参数和读取文件。随着程序的增长, main 函数处理的独立任务数量也会增加。当一个函数的职责越来越多时,它就会变得更难推理、更难测试、更难在不破坏其中一个部分的情况下进行更改。最好的办法是将功能分开,让每个函数只负责一项任务。
这个问题也与第二个问题有关:虽然 query 和 file_path 是我们程序的配置变量,但 contents 等变量用于执行程序的逻辑。 main 的长度越长,我们需要纳入作用域的变量就越多;纳入作用域的变量越多,就越难跟踪每个变量的用途。最好将配置变量归类到一个结构中,以便明确它们的用途。
第三个问题是,当读取文件失败时,我们使用 expect 来打印错误信息,但错误信息只是打印 Should have been able to read the file 。读取文件失败的原因有很多:例如,文件可能丢失,或者我们没有打开文件的权限。现在,无论哪种情况,我们都会打印出相同的错误信息,这不会给用户提供任何信息!
第四,我们重复使用 expect 来处理不同的错误,如果用户在没有指定足够参数的情况下运行我们的程序,他们就会从 Rust 中得到一个 index out of bounds 错误,而这个错误并不能清楚地解释问题。如果能将所有错误处理代码集中在一处,那就再好不过了,这样,如果错误处理逻辑需要更改,未来的维护者只需在一处查阅代码即可。将所有错误处理代码放在一处还能确保我们打印的信息对最终用户有意义。
让我们通过重构项目来解决这四个问题。
将多个任务的责任分配给 main 函数是许多二进制项目的共同组织问题。因此,Rust 社区制定了指导原则,以便在 main 开始变得庞大时,将二进制程序的不同关注点拆分开来。这一过程包括以下步骤:
①. 将程序分为 main.rs 和 lib.rs,并将程序逻辑移至 lib.rs。
②. 只要命令行解析逻辑很小,就可以保留在 main.rs 中。
③. 当命令行解析逻辑开始变得复杂时,将其从 main.rs 中提取出来并移至 lib.rs。
在这一过程结束后, main ,其职责应限于以下方面:
①. 使用参数值调用命令行解析逻辑
②. 设置任何其他配置
③. 在 lib.rs 中调用 run 函数
④. 如果 run 返回错误,则进行错误处理
这种模式是将关注点分开:main.rs 处理程序的运行,lib.rs 处理手头任务的所有逻辑。由于无法直接测试 main 函数,因此这种结构可以将程序的所有逻辑移到 lib.rs 中的函数中进行测试。保留在 main.rs 中的代码将非常小,足以通过读取来验证其正确性。让我们按照这个过程重新编写程序。
我们将把解析参数的功能提取到 main 将调用的函数中,以便将命令行解析逻辑移至 src/lib.rs。清单 12-5 显示了 main 的新开头,它调用了一个新函数 parse_config ,我们将暂时在 src/main.rs 中定义该函数。
- use std::env;
- use std::fs;
-
- fn main() {
- let args: Vec<String> = env::args().collect();
-
- let (query, file_path) = parse_config(&args);
-
- // --snip--
- }
-
- fn parse_config(args: &[String]) -> (&str, &str) {
- let query = &args[1];
- let file_path = &args[2];
-
- (query, file_path)
- }
(清单 12-5:从 parse_config 中提取一个函数 main)
我们仍然将命令行参数收集到一个向量中,但在 main 函数中,我们不再将索引 1 的参数值赋值给变量 query ,也不再将索引 2 的参数值赋值给变量 file_path ,而是将整个向量传递给 parse_config 函数。然后, parse_config 函数将负责确定哪个参数放在哪个变量中,并将值传回 main 。我们仍然在 main 中创建 query 和 file_path 变量,但 main 不再负责确定命令行参数和变量的对应关系。
对于我们的小程序来说,这样的重构似乎有些矫枉过正,但我们是在以小步、渐进的方式进行重构。完成这一改动后,再次运行程序,验证参数解析是否仍然有效。经常检查进度很有好处,有助于在出现问题时找出原因。
我们还可以进一步改进 parse_config 函数。目前,我们返回的是一个元组,但紧接着我们又将这个元组分解成了单独的部分。这说明我们的抽象方法可能还不够正确。
parse_config 的 config 部分是另一个有待改进的指标,它意味着我们返回的两个值是相关的,都是一个配置值的一部分。目前,除了将两个值组合成一个元组之外,我们并没有在数据结构中传达这种含义;相反,我们将把这两个值放入一个结构体中,并给结构体的每个字段起一个有意义的名字。这样做可以让将来的代码维护者更容易理解不同值之间的关系以及它们的用途。
清单 12-6 显示了对 parse_config 函数的改进。
- use std::env;
- use std::fs;
-
- 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");
-
- // --snip--
- }
-
- 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 值。在 parse_config 的主体中,我们过去返回的是引用 args 中 String 值的字符串片段,现在我们定义 Config 为包含 String 值的所有者。 main 中的 args 变量是参数值的所有者,它只允许 parse_config 函数借用这些值,这意味着如果 Config 试图占有 args 中的值,我们将违反 Rust 的借用规则。
我们可以通过多种方法管理 String 数据;最简单的方法是在值上调用 clone 方法,虽然效率有点低。这将为 Config 实例制作一份完整的数据副本,这比存储字符串数据的引用耗费更多时间和内存。不过,克隆数据也使我们的代码变得非常简单,因为我们不必管理引用的生命周期;在这种情况下,为了获得简单性而牺牲一点性能是值得的。
使用的利弊得失 clone
许多 Rustaceans 都倾向于避免使用 clone 来解决所有权问题,因为它的运行成本很高。在第 13 章中,你将学习如何在这种情况下使用更有效的方法。但现在,复制几个字符串以继续取得进展是没有问题的,因为你只需复制一次,而且文件路径和查询字符串都非常小。有一个效率有点低的工作程序,总比第一次就试图对代码进行超优化要好。随着你对 Rust 的使用经验越来越丰富,从最高效的解决方案开始会变得更容易,但现在,调用 clone .
我们更新了 main ,将 parse_config 返回的 Config 实例放入一个名为 config 的变量中,并更新了之前使用单独 query 和 file_path 变量的代码,现在改用 Config 结构上的字段。
现在,我们的代码更清楚地表明 query 和 file_path 是相关的,它们的目的是配置程序的工作方式。任何使用这些值的代码都知道要在 config 实例中根据其目的命名的字段中找到它们。
到目前为止,我们已经从 main 中提取了负责解析命令行参数的逻辑,并将其置于 parse_config 函数中。这样做让我们明白, query 和 file_path 的值是相关的,这种关系应该在我们的代码中传达出来。然后,我们添加了一个 Config 结构,用于命名 query 和 file_path 的相关目的,并能够从 parse_config 函数中以结构字段名称的形式返回值的名称。
因此,既然 parse_config 函数的目的是创建 Config 实例,我们就可以将 parse_config 从一个普通函数改为与 Config 结构相关联的名为 new 的函数。这一改动将使代码更加习以为常。我们可以通过调用 String::new 来创建标准库中类型的实例,如 String 。同样,通过将 parse_config 改为与 Config 关联的 new 函数,我们就可以通过调用 Config::new 来创建 Config 的实例。清单 12-7 显示了我们需要进行的更改。
- use std::env;
- use std::fs;
-
- fn main() {
- let args: Vec<String> = env::args().collect();
-
- let config = Config::new(&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");
-
- // --snip--
- }
-
- struct Config {
- query: String,
- file_path: String,
- }
-
- 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 ,将原来调用 parse_config 的地方改为调用 Config::new 。我们将 parse_config 更名为 new ,并将其移至 impl 代码块中,该代码块将 new 函数与 Config 关联。请再次尝试编译这段代码,以确保它能正常工作。
现在我们来修正错误处理。回想一下,如果试图访问 args 向量中索引 1 或索引 2 的值,而该向量包含的项目少于三个,程序就会崩溃。试着在不带任何参数的情况下运行程序,结果会是这样的:
- cargo.exe run
-
- Finished dev [unoptimized + debuginfo] target(s) in 0.00s
- Running `target\debug\minigrep.exe`
- thread 'main' panicked at src\main.rs:25: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
- error: process didn't exit successfully: `target\debug\minigrep.exe` (exit code: 101)
index out of bounds: the len is 1 but the index is 1 行是针对程序员的错误信息。它无法帮助我们的最终用户理解他们应该做什么。让我们现在就解决这个问题。
在清单 12-8 中,我们在 new 函数中添加了一个检查,在访问索引 1 和索引 2 之前,该检查将验证切片是否足够长。如果片段不够长,程序就会惊慌失措,并显示更好的错误信息。
- impl Config {
- fn new(args: &[String]) -> Config {
- if args.len() < 3 {
- panic!("not enough arguments");
- }
-
- let query = args[1].clone();
- let file_path = args[2].clone();
-
- Config { query, file_path }
- }
- }
(清单 12-8:添加参数个数检查)
这段代码与清单 9-13 中编写的 Guess::new 函数类似,当 value 参数超出有效值范围时,我们调用了 panic! 。在这里,我们不是检查值的范围,而是检查 args 的长度是否至少为 3,函数的其余部分可以在满足这一条件的前提下运行。如果 args 的项目少于 3 个,则该条件为真,我们将调用 panic! 宏立即结束程序。
有了这些额外的几行代码 new ,让我们再次不带任何参数运行程序,看看现在的错误是什么样子的:
- cargo.exe run
- Compiling minigrep v0.1.0 (E:\rustProj\minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.49s
- Running `target\debug\minigrep.exe`
- thread 'main' panicked at src\main.rs:26:13:
- not enough arguments
- note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
- error: process didn't exit successfully: `target\debug\minigrep.exe` (exit code: 101)
这样的输出效果更好:我们现在有了一条合理的错误信息。不过,我们也有了不想提供给用户的无关信息。也许我们在清单 9-13 中使用的技术并不适合在这里使用:调用 panic! 更适合编程问题,而不是使用问题,这在第 9 章中已经讨论过。相反,我们将使用在第 9 章中学到的另一种技巧--返回一个表示成功或错误的 Result 。
相反,我们可以返回一个 Result 值,在成功的情况下,它将包含一个 Config 实例,而在错误的情况下,它将描述问题所在。我们还要将函数名称从 new 改为 build ,因为许多程序员希望 new 函数永远不会失败。当 Config::build 与 main 通信时,我们可以使用 Result 类型来提示出现了问题。然后,我们可以修改 main ,将 Err 变体转换为对用户来说更实用的错误,而不会像调用 panic! 时那样出现关于 thread 'main' 和 RUST_BACKTRACE 的周围文字。
清单 12-9 显示了我们需要对现在调用的函数 Config::build 的返回值以及返回 Result 所需的函数体进行的修改。需要注意的是,在我们更新 main 之前,这将无法编译,我们将在下一个清单中进行更新。
- impl Config {
- fn build(args: &[String]) -> Result
'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:从 Result 返回 Config::build)
我们的 build 函数会返回一个 Result ,成功时会返回一个 Config 实例,出错时会返回一个 &'static str 。我们的错误值将始终是具有 'static 生命周期的字符串文字。
我们在函数体中做了两处改动:当用户没有传入足够的参数时,我们不再调用 panic! ,而是返回一个 Err 值;我们将 Config 返回值封装在 Ok 中。这些更改使函数符合其新的类型签名。
从 Config::build 返回 Err 值可让 main 函数处理从 build 函数返回的 Result 值,并在出错情况下更干净利落地退出进程。
为了处理错误情况并打印用户友好的信息,我们需要更新 main 以处理 Config::build 返回的 Result ,如清单 12-10 所示。我们还将从 panic! 接管以非零错误代码退出命令行工具的职责,转而由人工来实现。非零退出状态是向调用我们程序的进程发出信号,表明程序以错误状态退出的约定。
- use std::env;
- use std::fs;
- 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);
- });
-
- 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");
-
- // --snip--
- }
-
- struct Config {
- query: String,
- file_path: String,
- }
-
- impl Config {
- fn build(args: &[String]) -> Result
'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-10:如果构建 Config 失败,则以错误代码退出)
在本列表中,我们使用了一种尚未详细介绍的方法: unwrap_or_else 该方法由标准库定义于 Result
我们添加了新的 use 行,以便将标准库中的 process 引入作用域。在错误情况下运行的闭包代码只有两行:我们打印 err 值,然后调用 process::exit 。 process::exit 函数将立即停止程序,并返回作为退出状态代码传递的数字。这与清单 12-8 中使用的基于 panic! 的处理方法类似,但我们不再获得所有额外的输出。让我们试试看:
- cargo.exe run
- Compiling minigrep v0.1.0 (E:\rustProj\minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.44s
- Running `target\debug\minigrep.exe`
- Problem parsing arguments: not enough arguments
- error: process didn't exit successfully: `target\debug\minigrep.exe` (exit code: 1)
很好!这样的输出对用户来说更友好了。
现在,我们已经完成了配置解析的重构,下面我们来看看程序的逻辑。正如我们在 "二进制项目的关注点分离 "中所述,我们将提取一个名为 run 的函数,该函数将保存当前 main 函数中不涉及配置设置或错误处理的所有逻辑。完成后, main 将变得简洁并易于通过检查进行验证,我们也可以为所有其他逻辑编写测试。
清单 12-11 显示了提取后的 run 函数。目前,我们只是对提取函数进行了小规模的渐进式改进。我们仍在 src/main.rs 中定义函数。
- 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);
- });
-
- 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}");
- }
-
- // --snip--
(清单 12-11:提取包含程序逻辑其余部分的 run 函数)
run 函数现在包含 main 的所有剩余逻辑,从读取文件开始。 run 函数将 Config 实例作为参数。
将剩余的程序逻辑分离到 run 函数后,我们可以改进错误处理,就像清单 12-9 中对 Config::build 所做的那样。当出现错误时, run 函数将返回 Result
- use std::env;
- use std::fs;
- use std::process;
- use std::error::Error;
-
- // --snip--
-
- 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)
我们在这里做了三处重大改动。首先,我们将 run 函数的返回类型改为 Result<(), Box
对于错误类型,我们使用了特质对象 Box
其次,我们删除了对 expect 的调用,转而使用 ? 操作符,正如我们在第 9 章中提到的那样。在出现错误时, ? 将返回当前函数的错误值供调用者处理,而不是 panic! 。
第三,在成功情况下, run 函数现在返回一个 Ok 值。我们在签名中将 run 函数的成功类型声明为 () ,这意味着我们需要在 Ok 值中封装单元类型值。这种 Ok(()) 语法初看起来可能有点奇怪,但像这样使用 () 是一种惯用的方式,表示我们调用 run 只是为了它的副作用;它并不返回我们需要的值。
运行这段代码时,会编译成功,但会显示警告:
- cargo.exe run the .\poem.txt
- Compiling minigrep v0.1.0 (E:\rustProj\minigrep)
- warning: unused `Result` that must be used
- --> src\main.rs:17:5
- |
- 17 | run(config);
- | ^^^^^^^^^^^
- |
- = note: this `Result` may be an `Err` variant, which should be handled
- = note: `#[warn(unused_must_use)]` on by default
- help: use `let _ = ...` to ignore the resulting value
- |
- 17 | let _ = run(config);
- | +++++++
-
- warning: `minigrep` (bin "minigrep") generated 1 warning
- Finished dev [unoptimized + debuginfo] target(s) in 0.66s
- Running `target\debug\minigrep.exe 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!
我们将使用类似于清单 12-10 中 Config::build 的技术来检查错误并处理错误,但两者略有不同:
- 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);
- });
-
- 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 是否返回 Err 值,如果返回,则调用 process::exit(1) 。 run 函数不会像 Config::build 返回 Config 实例那样返回我们希望 unwrap 返回的值。由于 run 在成功情况下返回 () ,我们只关心是否检测到错误,因此我们不需要 unwrap_or_else 返回未封装的值,因为它只会返回 () 。
if let 和 unwrap_or_else 函数的主体在两种情况下都是一样的:我们打印错误并退出。
到目前为止,我们的 minigrep 项目看起来还不错!现在,我们将拆分 src/main.rs 文件,把一些代码放到 src/lib.rs 文件中。这样我们就可以测试代码,并减少 src/main.rs 文件的责任。
让我们把所有不是 main 函数的代码从 src/main.rs 移到 src/lib.rs:
①. run 函数定义;
②. use 相关声明;
③. Config的定义;
④. Config::build 函数定义;
src/lib.rs 的内容应具有清单 12-13 所示的签名(为简洁起见,我们省略了函数体)。请注意,在修改清单 12-14 中的 src/main.rs 之前,该代码无法编译。
- use std::fs;
- use std::error::Error;
-
- pub struct Config {
- pub query: String,
- pub file_path: String,
- }
-
- impl Config {
- pub fn build(args: &[String]) -> Result
'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 })
- }
- }
-
- pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
- let contents = fs::read_to_string(config.file_path)?;
-
- println!("With text:\n{contents}");
-
- Ok(())
- }
(清单 12-13:将 Config 和 run 移入 src/lib.rs)
我们在 Config 、其字段和 build 方法以及 run 函数中大量使用了 pub 关键字。现在我们有了一个库crate,它有一个我们可以测试的公共 API!
现在,我们需要将移至 src/lib.rs 的代码引入 src/main.rs 中二进制 crate 的作用域,如清单 12-14 所示。
- use std::env;
- use std::process;
-
- use minigrep::Config;
-
- 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);
- });
-
- println!("Searching for {}", config.query);
- println!("In file {}", config.file_path);
-
- if let Err(e) = minigrep::run(config) {
- println!("Application error: {e}");
- process::exit(1);
- }
- }
(清单 12-14:在 src/main.rs 中使用 minigrep 库 crate)
我们添加了 use minigrep::Config 行,将库crate中的 Config 类型引入二进制crate的作用域,并在 run 函数前加上我们的crate名称。现在,所有功能都应连接起来并能正常工作。使用 cargo run 运行程序,确保一切运行正常。
呼!虽然工作量很大,但我们已经为将来的成功做好了准备。现在处理错误要容易得多,而且代码也更加模块化了。从现在起,我们几乎所有的工作都将在 src/lib.rs 中完成。
让我们利用这一新发现的模块化优势,做一些在旧代码中很困难,但在新代码中却很容易的事情:我们来写一些测试!
现在,我们已将逻辑提取到 src/lib.rs,而将参数收集和错误处理留在了 src/main.rs,这样编写代码核心功能测试就容易多了。我们可以直接使用各种参数调用函数并检查返回值,而不必从命令行调用二进制文件。
在本节中,我们将使用测试驱动开发(TDD)流程在 minigrep 程序中添加搜索逻辑,具体步骤如下:
①. 编写一个失败的测试并运行它,以确保失败的原因是你所期望的。
②. 编写或修改足够的代码,使新测试通过。
③. 重构刚刚添加或更改的代码,确保测试继续通过。
④. 重复步骤 1!
什么是测试驱动开发 ?
测试驱动开发(TDD, test-driven development)是一种软件开发方法,即在实际代码执行之前编写测试。它是一种用于确保所开发的软件满足指定要求的技术,并有助于在开发周期的早期捕捉错误。该过程遵循一个短暂的重复周期,Red-Green-Refactor,即 "红-绿-重构":
①. 红色编写一个定义函数或函数改进的测试,该测试最初失败的原因应该是函数不存在或不按预期执行。
②. 绿色:编写通过测试所需的最少代码量。这一阶段的主要目标是在遵守指定要求的前提下尽快通过测试。
③. 重构:测试通过后,下一步就是重构代码。这包括清理新添加的代码,改进其结构和可读性,但不改变其行为。重构也是为了确保代码符合良好的设计原则和编码标准。
TDD 的好处包括提高代码质量、改进设计、使代码库更易于维护和更新。它还鼓励开发人员在编写代码前对设计和需求进行深入思考,从而获得更周到、更高效的解决方案。此外,由于测试是先编写的,因此 TDD 可以产生一套全面的测试,为代码提供文档,并在将来出现问题时更容易识别。
虽然 TDD 只是编写软件的众多方法之一,但它有助于推动代码设计。在编写使测试通过的代码之前编写测试,有助于在整个过程中保持较高的测试覆盖率。
我们将试运行功能的实现,该功能将在文件内容中实际搜索查询字符串,并生成与查询匹配的行列表。我们将在名为 search 的函数中添加这一功能。
由于不再需要这些语句,我们将删除 src/lib.rs 和 src/main.rs 中用于检查程序行为的 println! 语句(12.6章节还需要用此打印)。然后,在 src/lib.rs 中添加一个带有测试函数的 tests 模块,就像我们在第 11 章中所做的那样。测试函数指定了我们希望 search 函数具有的行为:它将接受一个查询和要搜索的文本,并只返回文本中包含查询的行。清单 12-15 显示了这个测试,它还不能编译。
- #[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" 。我们要搜索的文本有三行,其中只有一行包含 "duct" (请注意,开头双引号后的反斜杠告诉 Rust 不要在该字符串字面内容的开头添加换行符)。我们断言从 search 函数返回的值只包含我们期望的那一行。
我们还不能运行这个测试并观察它是否失败,因为测试甚至没有编译: search 函数还不存在!根据 TDD 原则,我们只需添加足够的代码就能让测试编译和运行,方法是添加 search 函数的定义,该函数总是返回空向量,如清单 12-16 所示。然后,测试应该会编译失败,因为空向量与包含以下行的向量不匹配 "safe, fast, productive."
- pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
- vec![]
- }
(清单 12-16:只需定义 search 函数的足够部分,我们的测试就可以编译了)
请注意,我们需要在 search 的签名中定义一个明确的生命周期 'a ,并在 contents 参数和返回值中使用该生命周期。回顾第 10 章,生命周期参数指定了哪个参数的生命周期与返回值的生命周期相关联。在这种情况下,我们指出返回向量应包含引用参数 contents 的字符串片段(而不是参数 query )。
换句话说,我们告诉 Rust, search 函数返回的数据将与在 contents 参数中传入 search 函数的数据一样长。这一点很重要!如果编译器认为我们是在制作 query 而不是 contents 的字符串切片,那么它就会错误地进行安全检查。
如果我们忘记了 lifetime 注释,并尝试编译此函数,就会出现此错误:
- cargo.exe build
- Compiling minigrep v0.1.0 (E:\rustProj\minigrep)
- error[E0106]: missing lifetime specifier
- --> src\lib.rs:44:51
- |
- 44 | 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
- |
- 44 | 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 previous error
Rust 不可能知道我们需要这两个参数中的哪一个,因此我们需要明确地告诉它。因为 contents 是包含所有文本的参数,而我们希望返回文本中匹配的部分,所以我们知道 contents 是应该使用 lifetime 语法连接到返回值的参数。
其他编程语言并不要求在签名中将参数与返回值连接起来,但随着时间的推移,这种做法会变得越来越简单。您可以将本示例与第 10 章中的 "使用生命周期验证引用 "部分进行比较。
现在让我们运行测试:
- cargo.exe test
-
- test tests::one_result ... FAILED
-
- failures:
-
- ---- tests::one_result stdout ----
- thread 'tests::one_result' panicked at src\lib.rs:40:9:
- assertion `left == right` failed
- left: ["safe, fast, productive."]
- right: []
- note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-
- failures:
- tests::one_result
-
- test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
-
- error: test failed, to rerun pass `--lib`
很好,测试失败了,和我们预想的一样。让我们让测试通过吧!
目前,我们的测试失败是因为我们总是返回一个空向量。要解决这个问题并实现 search ,我们的程序需要遵循以下步骤:
①. 迭代内容的每一行。
②. 检查该行是否包含我们的查询字符串。
③. 如果有,则将其添加到我们返回的值列表中。
④. 如果没有,那就什么也别做。
⑤. 返回匹配的结果列表。
让我们从迭代各行开始,逐一完成每个步骤。
Rust 提供了一种有用的方法来处理字符串的逐行迭代,命名为 lines ,如清单 12-17 所示。注意,这个方法还不能编译。
- pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
- for line in contents.lines() {
- // do something with line
- }
- }
(清单 12-17:遍历 contents)
lines 方法返回一个迭代器。我们将在第 13 章深入讨论迭代器,但请记住,您在清单 3-5 中看到过这种使用迭代器的方法,在清单 3-5 中,我们使用带有迭代器的 for 循环对集合中的每个项运行一些代码。
接下来,我们要检查当前行是否包含我们的查询字符串。幸运的是,字符串中有一个名为 contains 的有用方法可以帮我们做到这一点!在 search 函数中添加对 contains 方法的调用,如清单 12-18 所示。请注意,这仍然无法编译。
- pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
- for line in contents.lines() {
- if line.contains(query) {
- // do something with line
- }
- }
- }
(清单 12-18:添加功能以查看该行是否包含以下字符串 query)
目前,我们正在构建功能。为了使其能够编译,我们需要从主体中返回一个值,就像我们在函数签名中指出的那样。
要完成这个函数,我们需要一种方法来存储想要返回的匹配行。为此,我们可以在 for 循环之前创建一个可变向量,并调用 push 方法在向量中存储 line 。在 for 循环之后,我们返回向量,如清单 12-19 所示。
- 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 的行,我们的测试应该会通过。让我们运行测试:
- cargo.exe test
- Compiling minigrep v0.1.0 (E:\rustProj\minigrep)
-
- 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-9cd602df4200fb7d.exe)
-
- 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
测试通过了,所以我们知道它能正常工作!
此时,我们可以考虑重构搜索函数的实现,同时保持测试通过,以维持相同的功能。搜索函数中的代码不算太差,但它没有利用到迭代器的一些有用特性。我们将在第 13 章回到这个示例,详细探讨迭代器,并研究如何改进它。
现在 search 函数已经正常工作并经过测试,我们需要从 run 函数中调用 search 。我们需要将 config.query 值和 run 从文件中读取的 contents 传递给 search 函数。然后, run 将打印从 search 返回的每一行:
- pub 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(())
- }
我们仍然使用 for 循环从 search 返回每一行并打印出来。
现在整个程序应该可以运行了!让我们试试看,首先用一个单词,它应该能准确返回艾米莉-狄金森的诗歌 "frog "中的一行:
- cargo.exe run -- frog .\poem.txt
- Compiling minigrep v0.1.0 (E:\rustProj\minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.65s
- Running `target\debug\minigrep.exe frog .\poem.txt`
- Searching for frog
- In file .\poem.txt
- How public, like a frog
酷!现在,让我们试试能匹配多行的单词,比如 "body":
- cargo.exe run -- body .\poem.txt
- Finished dev [unoptimized + debuginfo] target(s) in 0.00s
- Running `target\debug\minigrep.exe body .\poem.txt`
- Searching for body
- In file .\poem.txt
- I'm nobody! Who are you?
- Are you nobody, too?
- How dreary to be somebody!
最后,让我们确保在搜索诗歌中没有的单词时,不会得到任何诗行,比如 "monomorphization":
- cargo.exe run -- monomorphization .\poem.txt
- Finished dev [unoptimized + debuginfo] target(s) in 0.00s
- Running `target\debug\minigrep.exe monomorphization .\poem.txt`
- Searching for monomorphization
- In file .\poem.txt
好极了我们建立了自己的迷你版经典工具,学到了很多关于如何构建应用程序的知识。我们还学到了一些关于文件输入和输出、生命周期、测试和命令行解析的知识。
为了完善这个项目,我们将简要演示如何使用环境变量和如何打印到标准错误,这两样东西在编写命令行程序时都很有用。
我们将通过添加一项额外功能来改进 minigrep :用户可通过环境变量打开大小写不敏感搜索选项。我们可以将此功能设置为命令行选项,要求用户每次使用时都输入该选项,但将其设置为环境变量后,用户只需设置一次环境变量,就可以在该终端会话中进行大小写不敏感搜索。
我们首先添加一个新的 search_case_insensitive 函数,该函数将在环境变量有值时被调用。我们将继续遵循 TDD 流程,因此第一步还是要编写一个失败测试。我们将为新函数 search_case_insensitive 添加一个新测试,并将旧测试从 one_result 重命名为 case_sensitive ,以明确两个测试之间的区别,如清单 12-20 所示。
- #[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" 作为查询。在我们即将添加的 search_case_insensitive 函数中,查询 "rUsT" 应匹配包含大写 R 的 "Rust:" 行,并匹配 "Trust me." 行,即使这两行的大小写与查询不同。这是我们的失败测试,它将无法编译,因为我们还没有定义 search_case_insensitive 函数。请随意添加一个始终返回空向量的骨架实现,类似于清单 12-16 中对 search 函数所做的实现,以查看测试的编译和失败情况。
search_case_insensitive 函数(如清单 12-21 所示)与 search 函数几乎相同。唯一不同的是,我们将小写 query 和每个 line ,因此无论输入参数的大小写是什么,当我们检查该行是否包含查询时,它们的大小写都是一样的。
- 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 字符串小写,并将其存储在同名的阴影变量中。在查询时调用 to_lowercase 是必要的,这样无论用户的查询是 "rust" 、 "RUST" 、 "Rust" 还是 "rUsT" ,我们都会将查询当作 "rust" 处理,而不区分大小写。虽然 to_lowercase 可以处理基本的 Unicode,但并不是 100% 准确。如果我们编写的是一个真正的应用程序,我们会在这里做更多的工作,但这一部分是关于环境变量的,而不是 Unicode,所以我们在这里就不多说了。
请注意, query 现在是 String ,而不是字符串片段,因为调用 to_lowercase 会创建新数据,而不是引用现有数据。举例来说,如果查询结果是 "rUsT" :该字符串片段不包含小写的 u 或 t 供我们使用,因此我们必须分配一个新的 String ,其中包含 "rust" 。现在,当我们将 query 作为参数传递给 contains 方法时,我们需要添加一个 "ampersand",因为 contains 的签名被定义为接收一个字符串片段。
接下来,我们在每个 line 上添加对 to_lowercase 的调用,以小写所有字符。既然我们已经将 line 和 query 转换为小写,那么无论查询的大小写是什么,我们都能找到匹配项。
让我们看看这个实现是否通过了测试:
- cargo.exe test
- Compiling minigrep v0.1.0 (E:\rustProj\minigrep)
-
- 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-9cd602df4200fb7d.exe)
-
- 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 函数中调用新的 search_case_insensitive 函数。首先,我们将在 Config 结构中添加一个配置选项,以便在区分大小写搜索和不区分大小写搜索之间切换。添加这个字段会导致编译器错误,因为我们还没有在任何地方初始化这个字段:
- pub struct Config {
- pub query: String,
- pub file_path: String,
- pub ignore_case: bool,
- }
我们添加了保存布尔值的 ignore_case 字段。接下来,我们需要 run 函数来检查 ignore_case 字段的值,并以此决定是调用 search 函数还是 search_case_insensitive 函数,如清单 12-22 所示。这仍然无法编译。
- pub 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:根据 search 或 search_case_insensitive 中的值调用 config.ignore_case)
最后,我们需要检查环境变量。处理环境变量的函数在标准库的 env 模块中,因此我们在 src/lib.rs 的顶部将该模块引入作用域。然后,我们将使用 env 模块中的 var 函数来检查是否已为名为 IGNORE_CASE 的环境变量设置了值,如清单 12-23 所示。
- use std::env;
- // --snip--
-
- impl Config {
- pub fn build(args: &[String]) -> Result
'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)
在此,我们创建了一个新变量 ignore_case 。为设置其值,我们调用 env::var 函数,并将 IGNORE_CASE 环境变量的名称传给它。如果环境变量被设置为任何值, env::var 函数将返回一个 Result ,它将是成功的 Ok 变体,其中包含环境变量的值。如果环境变量未设置,它将返回 Err 变体。
我们在 Result 上使用 is_ok 方法来检查环境变量是否已设置,这意味着程序应进行大小写不敏感搜索。如果 IGNORE_CASE 环境变量未设置为任何内容, is_ok 将返回 false,程序将执行大小写敏感搜索。我们并不关心环境变量的值,只关心它是否被设置,所以我们要检查 is_ok ,而不是使用 unwrap 、 expect 或我们在 Result 上见过的其他方法。
我们将 ignore_case 变量中的值传递给 Config 实例,这样 run 函数就能读取该值,并决定是调用 search_case_insensitive 还是 search ,如清单 12-22 所示。
让我们试一试!首先,我们在不设置环境变量的情况下运行程序,并使用查询 to ,该查询应匹配任何包含小写单词 "to "的行:
- cargo.exe run -- to .\poem.txt
- Compiling minigrep v0.1.0 (E:\rustProj\minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.58s
- Running `target\debug\minigrep.exe to .\poem.txt`
- Searching for to
- In file .\poem.txt
- Are you nobody, too?
- How dreary to be somebody!
看起来这仍然有效!现在,让我们运行程序,将 IGNORE_CASE 设置为 1 ,但查询内容相同 to 。
$ IGNORE_CASE=1 cargo run -- to poem.txt
如果使用的是 PowerShell,则需要设置环境变量,并将程序作为单独的命令运行:
PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt
这将使 IGNORE_CASE 在 shell 会话的剩余时间内持续存在。可以使用Remove-Item命令 取消设置:
PS> Remove-Item Env:IGNORE_CASE
我们应该得到包含大写字母 "to "的行:
- $Env:IGNORE_CASE=1;cargo run -- to poem.txt
- Finished dev [unoptimized + debuginfo] target(s) in 0.00s
- Running `target\debug\minigrep.exe to poem.txt`
- Searching for to
- In file poem.txt
- Are you nobody, too?
- How dreary to be somebody!
- To tell your name the livelong day
- To an admiring bog!
很好,我们还得到了包含 "To "的行!现在,我们的 minigrep 程序可以在环境变量的控制下进行不区分大小写的搜索了。现在你知道如何管理使用命令行参数或环境变量设置的选项了吧。
有些程序允许在同一配置中使用参数和环境变量。在这种情况下,程序会决定其中一个优先。如果你想自己再做一次练习,可以尝试通过命令行参数或环境变量来控制大小写敏感性。如果程序运行时一个设置为区分大小写,一个设置为忽略大小写,请决定是命令行参数优先还是环境变量优先。
std::env 模块包含更多处理环境变量的有用功能:请查看其文档了解可用功能。
目前,我们使用 println! 宏将所有输出写入终端。在大多数终端中,有两种输出:用于一般信息的标准输出( stdout )和用于错误信息的标准错误( stderr )。这种区别使用户可以选择将程序的成功输出导出到文件,但仍将错误信息打印到屏幕上(stderr)。
println! 宏只能打印到标准输出,因此我们必须用其他方法打印到标准错误。
首先,让我们观察一下 minigrep 打印的内容目前是如何写入标准输出的,包括我们想写入标准错误的任何错误信息。为此,我们将重定向标准输出流到一个文件,同时故意造成错误。我们不会重定向标准错误流,因此发送到标准错误的任何内容都将继续显示在屏幕上。
命令行程序应将错误信息发送到标准错误流,因此即使我们将标准输出流重定向到文件,我们仍能在屏幕上看到错误信息。我们的程序目前并不乖巧:我们将看到它将错误信息输出保存到文件中!
为了演示这一行为,我们将运行程序 > ,并输入我们希望重定向标准输出流的文件路径 output.txt。我们将不传递任何参数,因为这会导致出错:
$ cargo run > output.txt
> 语法告诉 shell 将标准输出的内容写入 output.txt,而不是屏幕。我们没有看到预期的错误信息被打印到屏幕上,这就意味着它最终一定被写入了文件中。这就是 output.txt 文件的内容:
Problem parsing arguments: not enough arguments
没错,我们的错误信息被打印到了标准输出中。将类似的错误信息打印到标准错误中会更有用,这样只有成功运行的数据才会被保存到文件中。我们将改变这一点。
我们将使用清单 12-24 中的代码来更改错误信息的打印方式。由于本章前面的重构,所有打印错误信息的代码都在一个函数 main 中。标准库提供了可打印到标准错误流的 eprintln! 宏,因此我们将调用 println! 来打印错误信息的两个地方改为 eprintln! 。
- use std::env;
- use std::process;
-
- use minigrep::Config;
-
- 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);
- });
-
- println!("Searching for {}", config.query);
- println!("In file {}", config.file_path);
-
- if let Err(e) = minigrep::run(config) {
- eprintln!("Application error: {e}");
- process::exit(1);
- }
- }
(清单 12-24:将错误信息写入标准错误而不是标准输出,使用 eprintln!)
现在,让我们以同样的方式再次运行程序,不使用任何参数,并使用 > 重定向标准输出:
- $ cargo run > output.txt
- Problem parsing arguments: not enough arguments
现在我们在屏幕上看到了错误,而 output.txt 中什么也没有,这正是我们所期望的命令行程序的行为。
让我们再次运行该程序,使用不会导致错误但仍会将标准输出重定向到文件的参数,就像这样:
- cargo run -- to poem.txt > output.txt
- Compiling minigrep v0.1.0 (E:\rustProj\minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.39s
- Running `target\debug\minigrep.exe to poem.txt`
我们在终端上看不到任何输出,而 output.txt 将包含我们的结果:
- Searching for to
- In file poem.txt
- Are you nobody, too?
- How dreary to be somebody!
- To tell your name the livelong day
- To an admiring bog!
这表明我们现在使用标准输出进行成功输出,并酌情使用标准错误进行错误输出。
本章回顾了到目前为止所学到的一些主要概念,并介绍了如何在 Rust 中执行常见的 I/O 操作。通过使用命令行参数、文件、环境变量和用于打印错误的 eprintln! 宏,你已经为编写命令行应用程序做好了准备。结合前几章中的概念,你的代码将组织得井井有条,在适当的数据结构中有效地存储数据,很好地处理错误,并经过良好的测试。
下一篇: 13-功能语言特征:迭代器和闭包