I/O项目
项目将创建一个我们自己版本的经典命令行工具:grep。
grep 是 “Globally search a Regular Expression and Print.” 的首字母缩写。
grep 最简单的使用场景是在特定文件中搜索指定字符串。
为此,grep 获取一个文件名和一个字符串作为参数,接着读取文件并找到其中包含字符串参数的行,然后打印出这些行。
在这个过程中,我们会展示如何让我们的命令行工具利用很多命令行工具中用到的终端功能。
读取环境变量来使得用户可以配置工具的行为。
打印到标准错误控制流(stderr) 而不是标准输出(stdout),例如这样用户可以选择将成功输出重定向到文件中的同时仍然在屏幕上显示错误信息。
接受命令行参数
一如既往使用 cargo new 新建一个项目,我们称之为 minigrep 以便与可能已经安装在系统上的 grep 工具相区别:
1 2 3 |
|
第一个任务是让 minigrep 能够接受两个命令行参数:文件名和要搜索的字符串。
也就是说我们希望能够使用 cargo run、要搜索的字符串和被搜索的文件的路径来运行程序,像这样:
1 |
|
现在cargo new
生成的程序忽略任何传递给它的参数
读取参数值
为了确保 minigrep 能够获取传递给它的命令行参数的值,我们需要一个 Rust 标准库提供的函数,也就是std::env::args
。
这个函数返回一个传递给程序的命令行参数的 迭代器(iterator)。
但是现在只需理解迭代器的两个细节:迭代器生成一系列的值,可以在迭代器上调用 collect 方法将其转换为一个集合,比如包含所有迭代器产生元素的 vector。
使用示例中的代码来读取任何传递给 minigrep 的命令行参数并将其收集到一个 vector 中。
1 2 3 4 5 6 |
|
首先使用 use 语句来将std::env
模块引入作用域以便可以使用它的 args 函数。
注意std::env::args
函数被嵌套进了两层模块中。
当所需函数嵌套了多于一层模块时,通常将父模块引入作用域,而不是其自身。
这便于我们利用std::env
中的其他函数。这比增加了use std::env::args
; 后仅仅使用 args 调用函数要更明确一些,因为 args 容易被错认成一个定义于当前模块的函数。
args 函数和无效的 Unicode
注意std::env::args
在其任何参数包含无效 Unicode 字符时会 panic。
如果你需要接受包含无效 Unicode 字符的参数,使用std::env::args_os
代替。这个函数返回 OsString 值而不是 String 值。
在 main 函数的第一行,我们调用了env::args
,并立即使用 collect 来创建了一个包含迭代器所有值的 vector。
collect 可以被用来创建很多类型的集合,所以这里显式注明 args 的类型来指定我们需要一个字符串 vector。
虽然在 Rust 中我们很少会需要注明类型,然而 collect 是一个经常需要注明类型的函数,因为 Rust 不能推断出你想要什么类型的集合。
最后,我们使用调试格式:?
打印出 vector。让我们尝试分别用两种方式(不包含参数和包含参数)运行代码:
1 2 3 4 5 6 7 |
|
注意 vector 的第一个值是"target/debug/minigrep"
,它是我们二进制文件的名称。
将参数值保存进变量
打印出参数 vector 中的值展示了程序可以访问指定为命令行参数的值。
现在需要将这两个参数的值保存进变量这样就可以在程序的余下部分使用这些值了。
1 2 3 4 5 6 7 8 9 10 11 |
|
正如之前打印出 vector 时所所看到的,程序的名称占据了 vector 的第一个值args[0]
,所以我们从索引 1 开始。
minigrep 获取的第一个参数是需要搜索的字符串,所以将其将第一个参数的引用存放在变量 query 中。
第二个参数将是文件名,所以将第二个参数的引用放入变量 filename 中。
我们将临时打印出这些变量的值来证明代码如我们期望的那样工作。使用参数 test 和 sample.txt 再次运行这个程序:
1 2 3 4 5 |
|
好的,它可以工作!我们将所需的参数值保存进了对应的变量中。
之后会增加一些错误处理来应对类似用户没有提供参数的情况,不过现在我们将忽略他们并开始增加读取文件功能。
读取文件
现在我们要增加读取由 filename 命令行参数指定的文件的功能。
首先,需要一个用来测试的示例文件:用来确保 minigrep 正常工作的最好的文件是拥有多行少量文本且有一些重复单词的文件。
示例是一首艾米莉·狄金森(Emily Dickinson)的诗,它正适合这个工作!
在项目根目录创建一个文件 poem.txt,并输入诗 "I'm nobody! Who are you?":
文件名: poem.txt
1 2 3 4 5 6 7 8 9 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
首先,我们增加了一个 use 语句来引入标准库中的相关部分:我们需要std::fs
来处理文件。
在 main 中新增了一行语句:fs::read_to_string
接受 filename,打开文件,接着返回包含其内容的Result<String>
。
尝试运行这些代码,随意指定一个字符串作为第一个命令行参数(因为还未实现搜索功能的部分)而将 poem.txt 文件将作为第二个参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
好的!代码读取并打印出了文件的内容。
虽然它还有一些瑕疵:main 函数有着多个职能,通常函数只负责一个功能的话会更简洁并易于维护。
另一个问题是没有尽可能的处理错误。
虽然我们的程序还很小,这些瑕疵并不是什么大问题,不过随着程序功能的丰富,将会越来越难以用简单的方法修复他们。
在开发程序时,及早开始重构是一个最佳实践,因为重构少量代码时要容易的多,所以让我们现在就开始吧。
重构改进模块性和错误处理
为了改善我们的程序这里有四个问题需要修复,而且他们都与程序的组织方式和如何处理潜在错误有关。
第一,main 现在进行了两个任务:它解析了参数并打开了文件。
对于一个这样的小函数,这并不是一个大问题。然而如果 main 中的功能持续增加,main 函数处理的独立任务也会增加。
当函数承担了更多责任,它就更难以推导,更难以测试,并且更难以在不破坏其他部分的情况下做出修改。最好能分离出功能以便每个函数就负责一个任务。
这同时也关系到第二个问题:search 和 filename 是程序中的配置变量,而像 f 和 contents 则用来执行程序逻辑。
随着 main 函数的增长,就需要引入更多的变量到作用域中,而当作用域中有更多的变量时,将更难以追踪每个变量的目的。
最好能将配置变量组织进一个结构,这样就能使他们的目的更明确了。
第三个问题是如果打开文件失败我们使用 expect 来打印出错误信息,不过这个错误信息只是说 file not found。
除了缺少文件之外还有很多可以导致打开文件失败的方式:例如,文件可能存在,不过可能没有打开它的权限。
如果我们现在就出于这种情况,打印出的 file not found 错误信息就给了用户错误的建议!
第四,我们不停地使用 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
中做准备。
示例中展示了新 main 函数的开头,它调用了新函数 parse_config。目前它仍将定义在src/main.rs
中:
文件名:src/main.rs
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
我们仍然将命令行参数收集进一个 vector,不过不同于在 main 函数中将索引 1 的参数值赋值给变量 query 和将索引 2 的值赋值给变量 filename,我们将整个 vector 传递给 parse_config 函数。
接着 parse_config 函数将包含决定哪个参数该放入哪个变量的逻辑,并将这些值返回到 main。
仍然在 main 中创建变量 query 和 filename,不过 main 不再负责处理命令行参数与变量如何对应。
这对重构我们这小程序可能有点大材小用,不过我们将采用小的、增量的步骤进行重构。
在做出这些改变之后,再次运行程序并验证参数解析是否仍然正常。
经常验证你的进展是一个好习惯,这样在遇到问题时能帮助你定位问题的成因。
组合配置值
我们可以采取另一个小的步骤来进一步改善这个函数。
现在函数返回一个元组,不过立刻又将元组拆成了独立的部分。这是一个我们可能没有进行正确抽象的信号。
另一个表明还有改进空间的迹象是 parse_config 名称的 config 部分,它暗示了我们返回的两个值是相关的并都是一个配置值的一部分。
目前除了将这两个值组合进元组之外并没有表达这个数据结构的意义:我们可以将这两个值放入一个结构体并给每个字段一个有意义的名字。
这会让未来的维护者更容易理解不同的值如何相互关联以及他们的目的。
注意:一些同学将这种在复杂类型更为合适的场景下使用基本类型的反模式称为 基本类型偏执(primitive obsession)。
文件名:src/main.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
新定义的结构体 Config 中包含字段 query 和 filename。
parse_config 的签名表明它现在返回一个 Config 值。
在之前的 parse_config 函数体中,我们返回了引用 args 中 String 值的字符串 slice,现在我们定义 Config 来包含拥有所有权的 String 值。
main 中的 args 变量是参数值的所有者并只允许 parse_config 函数借用他们,这意味着如果 Config 尝试获取 args 中值的所有权将违反 Rust 的借用规则。
还有许多不同的方式可以处理 String 的数据,而最简单但有些不太高效的方式是调用这些值的 clone 方法。
这会生成 Config 实例可以拥有的数据的完整拷贝,不过会比储存字符串数据的引用消耗更多的时间和内存。
不过拷贝数据使得代码显得更加直白因为无需管理引用的生命周期,所以在这种情况下牺牲一小部分性能来换取简洁性的取舍是值得的。
使用 clone 的权衡取舍
由于其运行时消耗,许多 Rustacean 之间有一个趋势是倾向于避免使用 clone 来解决所有权问题。
我们将会学习如何更有效率的处理这种情况,不过现在,复制一些字符串来取得进展是没有问题的,因为只会进行一次这样的拷贝,而且文件名和要搜索的字符串都比较短。
在第一轮编写时拥有一个可以工作但有点低效的程序要比尝试过度优化代码更好一些。
随着你对 Rust 更加熟练,将能更轻松的直奔合适的方法,不过现在调用 clone 是完全可以接受的。
我们更新 main 将 parse_config 返回的 Config 实例放入变量 config 中,并将之前分别使用 search 和 filename 变量的代码更新为现在的使用 Config 结构体的字段的代码。
现在代码更明确的表现了我们的意图,query 和 filename 是相关联的并且他们的目的是配置程序如何工作。
任何使用这些值的代码就知道在 config 实例中对应目的的字段名中寻找他们。
创建一个 Config 的构造函数
目前为止,我们将负责解析命令行参数的逻辑从 main 提取到了 parse_config 函数中,这有助于我们看清值 query 和 filename 是相互关联的并应该在代码中表现这种关系。
接着我们增加了 Config 结构体来描述 query 和 filename 的相关性,并能够从 parse_config 函数中将这些值的名称作为结构体字段名称返回。
所以现在 parse_config 函数的目的是创建一个 Config 实例,我们可以将 parse_config 从一个普通函数变为一个叫做 new 的与结构体关联的函数。
做出这个改变使得代码更符合习惯:可以像标准库中的 String 调用 String::new 来创建一个该类型的实例那样,将 parse_config 变为一个与 Config 关联的 new 函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
这里将 main 中调用 parse_config 的地方更新为调用 Config::new。
我们将 parse_config 的名字改为 new 并将其移动到 impl 块中,这使得 new 函数与 Config 相关联。再次尝试编译并确保它可以工作。
修复错误处理
现在我们开始修复错误处理。回忆一下之前提到过如果 args vector 包含少于 3 个项并尝试访问 vector 中索引 1 或索引 2 的值会造成程序 panic。
尝试不带任何参数运行程序;这将看起来像这样:
1 2 3 4 5 |
|
index out of bounds: the len is 1 but the index is 1
是一个针对程序员的错误信息,然而这并不能真正帮助终端用户理解发生了什么和他们应该做什么。
现在就让我们修复它吧。
改善错误信息
在 new 函数中增加了一个检查在访问索引 1 和 2 之前检查 slice 是否足够长。
如果 slice 不够长,我们使用一个更好的错误信息 panic 而不是index out of bounds
信息:
1 2 3 4 5 6 |
|
有了 new 中这几行额外的代码,再次不带任何参数运行程序并看看现在错误看起来像什么:
1 2 3 4 5 6 |
|
这个输出就好多了,现在有了一个合理的错误信息。然而,还是有一堆额外的信息我们不希望提供给用户。
从 new 中返回 Result 而不是调用 panic!
我们可以选择返回一个 Result 值,它在成功时会包含一个 Config 的实例,而在错误时会描述问题。
当Config::new
与 main 交流时,可以使用 Result 类型来表明这里存在问题。
接着修改 main 将 Err 成员转换为对用户更友好的错误,而不panic!
调用产生的关于thread 'main'
和RUST_BACKTRACE
的文本。
示例展示了为了返回 Result 在 Config::new 的返回值和函数体中所需的改变。
注意这还不能编译,直到下一个示例同时也更新了 main 之后。
1 2 3 4 5 6 7 8 9 10 11 12 |
|
现在 new 函数返回一个 Result,在成功时带有一个 Config 实例而在出现错误时带有一个&'static str
new 函数体中有两处修改:当没有足够参数时不再调用 panic!,而是返回 Err 值。
同时我们将 Config 返回值包装进 Ok 成员中。这些修改使得函数符合其新的类型签名。
通过让Config::new
返回一个 Err 值,这就允许 main 函数处理 new 函数返回的 Result 值并在出现错误的情况更明确的结束进程。
Config::new 调用并处理错误
为了处理错误情况并打印一个对用户友好的信息,我们需要更新 main 函数来处理现在Config::new
返回的 Result。
1 2 3 4 5 6 7 8 9 10 11 |
|
在上面的示例中,使用了一个之前没有涉及到的方法:unwrap_or_else,它定义于标准库的Result<T, E>
上。
使用 unwrap_or_else 可以进行一些自定义的非 panic! 的错误处理。
当 Result 是 Ok 时,这个方法的行为类似于 unwrap:它返回 Ok 内部封装的值。
然而,当其值是 Err 时,该方法会调用一个 闭包(closure),也就是一个我们定义的作为参数传递给 unwrap_or_else 的匿名函数。
现在你需要理解的是 unwrap_or_else 会将 Err 的内部值,也就是not enough arguments
静态字符串的情况,传递给闭包中位于两道竖线间的参数 err。
闭包中的代码在其运行时可以使用这个 err 值。
我们新增了一个 use 行来从标准库中导入 process。
在错误的情况闭包中将被运行的代码只有两行:我们打印出了 err 值,接着调用了std::process::exit
。
process::exit
会立即停止程序并将传递给它的数字作为退出状态码。
1 2 3 4 5 |
|
非常好!现在输出对于用户来说就友好多了。
从 main 提取逻辑
现在我们完成了配置解析的重构:让我们转向程序的逻辑。
正如 “二进制项目的关注分离” 部分所展开的讨论,我们将提取一个叫做 run 的函数来存放目前 main 函数中不属于设置配置或处理错误的所有逻辑。
一旦完成这些,main 函数将简明的足以通过观察来验证,而我们将能够为所有其他逻辑编写测试。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
现在 run 函数包含了 main 中从读取文件开始的剩余的所有逻辑。
run 函数获取一个 Config 实例作为参数。
从 run 函数中返回错误
通过将剩余的逻辑分离进 run 函数而不是留在 main 中,就可以像Config::new
那样改进错误处理。
不再通过 expect 允许程序 panic,run 函数将会在出错时返回一个Result<T, E>
。
这让我们进一步以一种对用户友好的方式统一 main 中的错误处理。示例展示了 run 签名和函数体中的改变:
1 2 3 4 5 6 7 8 9 10 11 |
|
这里我们做出了三个明显的修改。首先,将 run 函数的返回类型变为 Result<(), Box<dyn Error>>
。
之前这个函数返回 unit 类型 (),现在它仍然保持作为 Ok 时的返回值。
对于错误类型,使用了 trait 对象 Box<dyn Error>
(在开头使用了 use 语句将std::error::Error
引入作用域)。
目前只需知道Box<dyn Error>
意味着函数会返回实现了 Error trait 的类型,不过无需指定具体将会返回的值的类型。
这提供了在不同的错误场景可能有不同类型的错误返回值的灵活性。这也就是 dyn,它是 “动态的”(“dynamic”)的缩写。
第二个改变是去掉了 expect 调用并替换为?
。
不同于遇到错误就panic!
,?
会从函数中返回错误值并让调用者来处理它。
第三个修改是现在成功时这个函数会返回一个 Ok 值。因为 run 函数签名中声明成功类型返回值是 (),这意味着需要将 unit 类型值包装进 Ok 值中。
Ok(()) 一开始看起来有点奇怪,不过这样使用 () 是表明我们调用 run 只是为了它的副作用的惯用方式;它并没有返回什么有意义的值。
上述代码能够编译,不过会有一个警告:
1 2 3 4 5 6 7 8 |
|
Rust 提示我们的代码忽略了 Result 值,它可能表明这里存在一个错误。
虽然我们没有检查这里是否有一个错误,而编译器提醒我们这里应该有一些错误处理代码!现在就让我们修正这个问题。
处理 main 中 run 返回的错误
1 2 3 4 5 6 7 8 9 10 11 12 |
|
我们使用 if let 来检查 run 是否返回一个 Err 值,不同于 unwrap_or_else,并在出错时调用process::exit(1)
。
run 并不返回像Config::new
返回的 Config 实例那样需要 unwrap 的值。
因为 run 在成功时返回 (),而我们只关心检测错误,所以并不需要 unwrap_or_else 来返回未封装的值,因为它只会是 ()。
不过两个例子中 if let 和 unwrap_or_else 的函数体都一样:打印出错误并退出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
|
将代码拆分到库 crate
现在我们的 minigrep 项目看起来好多了!
现在我们将要拆分src/main.rs
并将一些代码放入src/lib.rs
,这样就能测试他们并拥有一个含有更少功能的 main 函数。
让我们将所有不是 main 函数的代码从src/main.rs
移动到新文件src/lib.rs
中:
- run 函数定义
- 相关的 use 语句
- Config 的定义
Config::new
函数定义
现在src/lib.rs
的内容应该看起来像示例(为了简洁省略了函数体)。
注意直到下一个示例修改完src/main.rs
之后,代码还不能编译:
文件名: src/lib.rs
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
这里使用了公有的 pub 关键字:在 Config、其字段和其 new 方法,以及 run 函数上。
现在我们有了一个拥有可以测试的公有 API 的库 crate 了。
现在需要在src/main.rs
中将移动到src/lib.rs
的代码引入二进制 crate 的作用域中
Filename:src/main.rs
1 2 3 4 5 6 7 8 9 10 11 |
|
为了将库 crate 引入二进制 crate,我们使用了 use minigrep。
接着use minigrep::Config
将 Config 类型引入作用域,并使用 crate 名称作为 run 函数的前缀。
通过这些重构,所有功能应该能够联系在一起并运行了。运行cargo run
来确保一切都正确的衔接在一起。
Filename:src/lib.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
Filename:src/main.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
哇哦!这可有很多的工作,不过我们为将来的成功打下了基础。现在处理错误将更容易,同时代码也更加模块化。
从现在开始几乎所有的工作都将在src/lib.rs
中进行。
让我们利用这些新创建的模块的优势来进行一些在旧代码中难以展开的工作,这些工作在新代码中非常容易实现,那就是:编写测试!
采用测试驱动开发完善库的功能
现在我们将逻辑提取到了src/lib.rs
并将所有的参数解析和错误处理留在了src/main.rs
中,为代码的核心功能编写测试将更加容易。
我们可以直接使用多种参数调用函数并检查返回值而无需从命令行运行二进制文件了。
如果你愿意的话,请自行为Config::new
和 run 函数的功能编写一些测试。
在这一部分,我们将遵循测试驱动开发(Test Driven Development, TDD)的模式来逐步增加 minigrep 的搜索逻辑。
这是一个软件开发技术,它遵循如下步骤:
- 编写一个会失败的测试,并运行它以确保其因为你期望的原因失败。
- 编写或修改刚好足够的代码来使得新的测试通过。
- 重构刚刚增加或修改的代码,并确保测试仍然能通过。
- 从步骤 1 开始重复!
这只是众多编写软件的方法之一,不过 TDD 有助于驱动代码的设计。
在编写能使测试通过的代码之前编写测试有助于在开发过程中保持高测试覆盖率。
我们将测试驱动实现实际在文件内容中搜索查询字符串并返回匹配的行示例的功能。
我们将在一个叫做 search 的函数中增加这些功能。
编写失败测试
去掉src/lib.rs
和src/main.rs
中用于检查程序行为的 println! 语句,因为不再真正需要他们了。
接着我们增加一个 test 模块和一个测试函数。
测试函数指定了 search 函数期望拥有的行为:它会获取一个需要查询的字符串和用来查询的文本,并只会返回包含请求的文本行。
示例展示了这个测试,它还不能编译:
文件名:src/lib.rs
:
为了选择性地禁用测试代码的编译,您可以使用cfg attribute
和test
配置文件相应地标记它。
当以这种方式标记时,编译器知道在编译期间忽略该方法。
这类似于在其他语言中常用的ifdef
用法,如 C 或 C++,在这里告诉预处理器忽略封闭的代码,除非定义了TESTING
。
1 2 3 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
这里选择使用 "duct" 作为这个测试中需要搜索的字符串。
用来搜索的文本有三行,其中只有一行包含 "duct"。
我们断言 search 函数的返回值只包含期望的那一行。
我们还不能运行这个测试并看到它失败,因为它甚至都还不能编译:search 函数还不存在呢!
我们将增加足够的代码来使其能够编译:一个总是会返回空 vector 的 search 函数定义,如示例所示。然后这个测试应该能够编译并因为空 vector 并不匹配一个包含一行 "safe, fast, productive." 的 vector 而失败。
文件名:src/lib.rs
示例:刚好足够使测试通过编译的 search 函数定义
1 2 3 |
|
注意需要在 search 的签名中定义一个显式生命周期 'a 并用于 contents 参数和返回值。
生命周期参数指定哪个参数的生命周期与返回值的生命周期相关联。
在这个例子中,我们表明返回的 vector 中应该包含引用参数 contents(而不是参数query) slice 的字符串 slice。
换句话说,我们告诉 Rust 函数 search 返回的数据将与 search 函数中的参数 contents 的数据存在的一样久。这是非常重要的!
为了使这个引用有效那么 被 slice 引用的数据也需要保持有效;如果编译器认为我们是在创建 query 而不是 contents 的字符串 slice,那么安全检查将是不正确的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
好的,测试失败了,这正是我们所期望的。修改代码来让测试通过吧!
编写使测试通过的代码
目前测试之所以会失败是因为我们总是返回一个空的 vector。
为了修复并实现 search,我们的程序需要遵循如下步骤:
- 遍历内容的每一行文本。
- 查看这一行是否包含要搜索的字符串。
- 如果有,将这一行加入列表返回值中。
- 如果没有,什么也不做。
- 返回匹配到的结果列表
让我们一步一步的来,从遍历每行开始。
使用 lines 方法遍历每一行
Rust 有一个有助于一行一行遍历字符串的方法,出于方便它被命名为 lines,它如示例这样工作。注意这还不能编译:
文件名:src/lib.rs
:
1 2 3 4 5 |
|
lines 方法返回一个迭代器。
用查询字符串搜索每一行
接下来将会增加检查当前行是否包含查询字符串的功能。幸运的是,字符串类型为此也有一个叫做 contains 的实用方法!
文件名:src/lib.rs
:
1 2 3 4 5 6 7 |
|
存储匹配的行
我们还需要一个方法来存储包含查询字符串的行。
为此可以在 for 循环之前创建一个可变的 vector 并调用 push 方法在 vector 中存放一个 line。
在 for 循环之后,返回这个 vector
文件名:src/lib.rs
:
1 2 3 4 5 6 7 8 9 10 11 |
|
现在 search 函数应该返回只包含 query 的那些行,而测试应该会通过。让我们运行测试:
1 2 3 4 5 6 |
|
测试通过了,它可以工作了!
到此为止,我们可以考虑一下重构 search 的实现并时刻保持测试通过来保持其功能不变的机会了。
search 函数中的代码并不坏,不过并没有利用迭代器的一些实用功能。
在 run 函数中使用 search 函数
现在 search 函数是可以工作并测试通过了的,我们需要实际在 run 函数中调用 search。
需要将 config.query 值和 run 从文件中读取的 contents 传递给 search 函数。
接着 run 会打印出 search 返回的每一行:
文件名:src/lib.rs
1 2 3 4 5 6 7 8 9 |
|
这里仍然使用了 for 循环获取了 search 返回的每一行并打印出来。
Filename:src/lib.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
|
Filename:src/main.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
现在整个程序应该可以工作了!让我们试一试,首先使用一个只会在艾米莉·狄金森的诗中返回一行的单词 “frog”:
1 2 3 4 5 |
|
好的!现在试试一个会匹配多行的单词,比如 “body”:
1 2 3 4 5 6 |
|
最后,让我们确保搜索一个在诗中哪里都没有的单词时不会得到任何行,比如 "monomorphization":
1 2 3 4 |
|
非常好!我们创建了一个属于自己的迷你版经典工具,并学习了很多如何组织程序的知识。
我们还学习了一些文件输入输出、生命周期、测试和命令行解析的内容。
为了使这个项目更丰满,我们将简要的展示如何处理环境变量和打印到标准错误,这两者在编写命令行程序时都很有用。
处理环境变量
我们将增加一个额外的功能来改进 minigrep:用户可以通过设置环境变量来设置搜索是否是大小写敏感的 。
当然,我们也可以将其设计为一个命令行参数并要求用户每次需要时都加上它,不过在这里我们将使用环境变量。
这允许用户设置环境变量一次之后在整个终端会话中所有的搜索都将是大小写不敏感的。
编写一个大小写不敏感 search 函数的失败测试
我们希望增加一个新函数search_case_insensitive
,并将会在设置了环境变量时调用它。
这里将继续遵循 TDD 过程,其第一步是再次编写一个失败测试。
我们将为新的大小写不敏感搜索函数新增一个测试函数,并将老的测试函数从 one_result 改名为 case_sensitive 来更清楚的表明这两个测试的区别
文件名:src/lib.rs
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
|
注意我们也改变了老测试中 contents 的值。
还新增了一个含有文本 "Duct tape." 的行,它有一个大写的 D,这在大小写敏感搜索时不应该匹配 "duct"。
我们修改这个测试以确保不会意外破坏已经实现的大小写敏感搜索功能;这个测试现在应该能通过并在处理大小写不敏感搜索时应该能一直通过。
大小写不敏感搜索的新测试使用 "rUsT" 作为其查询字符串。
在我们将要增加的search_case_insensitive
函数中,"rUsT" 查询应该包含带有一个大写 R 的 "Rust:" 还有 "Trust me." 这两行,即便他们与查询的大小写都不同。
这个测试现在会编译失败因为还没有定义 search_case_insensitive 函数。
请随意增加一个总是返回空 vector 的骨架实现,正如示例中 search 函数为了使测试编译并失败时所做的那样。
实现 search_case_insensitive 函数
search_case_insensitive
函数,如示例 所示,将与 search 函数基本相同。
唯一的区别是它会将 query 变量和每一 line 都变为小写,这样不管输入参数是大写还是小写,在检查该行是否包含查询字符串时都会是小写。
文件名:src/lib.rs
:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
首先我们将 query 字符串转换为小写,并将其覆盖到同名的变量中。
对查询字符串调用 to_lowercase 是必需的,这样不管用户的查询是 "rust"、"RUST"、"Rust" 或者 "rUsT",我们都将其当作 "rust" 处理并对大小写不敏感。
注意 query 现在是一个 String 而不是字符串 slice,因为调用 to_lowercase 是在创建新数据,而不是引用现有数据。
如果查询字符串是 "rUsT",这个字符串 slice 并不包含可供我们使用的小写的 u 或 t,所以必需分配一个包含 "rust" 的新 String。
现在当我们将 query 作为一个参数传递给 contains 方法时,需要增加一个 & 因为 contains 的签名被定义为获取一个字符串 slice。
接下来在检查每个 line 是否包含 search 之前增加了一个 to_lowercase 调用将他们都变为小写。
现在我们将 line 和 query 都转换成了小写,这样就可以不管查询的大小写进行匹配了。
让我们看看这个实现能否通过测试:
1 2 3 4 5 6 |
|
好的!现在,让我们在 run 函数中实际调用新 search_case_insensitive 函数。
首先,我们将在 Config 结构体中增加一个配置项来切换大小写敏感和大小写不敏感搜索。
增加这些字段会导致编译错误,因为我们还没有在任何地方初始化这些字段:
文件名:src/lib.rs
:
1 2 3 4 5 |
|
这里增加了 case_sensitive 字符来存放一个布尔值。
接着我们需要 run 函数检查 case_sensitive 字段的值并使用它来决定是否调用 search 函数或 search_case_insensitive 函数
文件名: src/lib.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
最后需要实际检查环境变量。
处理环境变量的函数位于标准库的 env 模块中,所以我们需要在src/lib.rs
的开头增加一个use std::env;
行将这个模块引入作用域中。
接着在Config::new
中使用 env 模块的 var 方法来检查一个叫做CASE_INSENSITIVE
的环境变量,如示例所示:
文件名: src/lib.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
这里创建了一个新变量 case_sensitive。为了设置它的值,需要调用 env::var 函数并传递我们需要寻找的环境变量名称,
CASE_INSENSITIVE。env::var 返回一个 Result,它在环境变量被设置时返回包含其值的 Ok 成员,并在环境变量未被设置时返回 Err 成员。
我们使用 Result 的 is_err 方法来检查其是否是一个 error(也就是环境变量未被设置的情况),这也就意味着我们 需要 进行一个大小写敏感搜索。
如果 CASE_INSENSITIVE 环境变量被设置为任何值,is_err 会返回 false 并将进行大小写不敏感搜索。
我们并不关心环境变量所设置的 值,只关心它是否被设置了,所以检查 is_err 而不是 unwrap、expect 或任何我们已经见过的 Result 的方法。
我们将变量 case_sensitive 的值传递给 Config 实例,这样 run 函数可以读取其值并决定是否调用 search 或者 search_case_insensitive。
让我们试一试吧!首先不设置环境变量并使用查询 to 运行程序,这应该会匹配任何全小写的单词 “to” 的行:
1 2 3 4 5 6 |
|
1 2 3 4 5 6 7 8 |
|
好极了,我们也得到了包含 “To” 的行!
现在 minigrep 程序可以通过环境变量控制进行大小写不敏感搜索了。
现在你知道了如何管理由命令行参数或环境变量设置的选项了!
将错误信息输出到标准错误而不是标准输出
目前为止,我们将所有的输出都 println! 到了终端。
大部分终端都提供了两种输出:标准输出(standard output,stdout)对应一般信息,标准错误(standard error,stderr)则用于错误信息。
这种区别允许用户选择将程序正常输出定向到一个文件中并仍将错误信息打印到屏幕上。
但是 println! 函数只能够打印到标准输出,所以我们必需使用其他方法来打印到标准错误。
检查错误应该写入何处
首先,让我们观察一下目前 minigrep 打印的所有内容是如何被写入标准输出的,包括那些应该被写入标准错误的错误信息。
可以通过将标准输出流重定向到一个文件同时有意产生一个错误来做到这一点。
我们没有重定向标准错误流,所以任何发送到标准错误的内容将会继续显示在屏幕上。
命令行程序被期望将错误信息发送到标准错误流,这样即便选择将标准输出流重定向到文件中时仍然能看到错误信息。
目前我们的程序并不符合期望;相反我们将看到它将错误信息输出保存到了文件中。
我们通过`>
和文件名output.txt
来运行程序,我们期望重定向标准输出流到该文件中。在这里,我们没有传递任何参数,所以会产生一个错误:
1 |
|
>
语法告诉 shell 将标准输出的内容写入到 output.txt 文件中而不是屏幕上。
我们并没有看到期望的错误信息打印到屏幕上,所以这意味着它一定被写入了文件中。如下是 output.txt 所包含的:
1 |
|
是的,错误信息被打印到了标准输出中。
像这样的错误信息被打印到标准错误中将会有用的多,并且只将成功运行的信息写入文件。
接下来我们将对程序进行修改从而以这种方式进行输出。
将错误打印到标准错误
得益于早些时候的重构,所有打印错误信息的代码都位于 main 一个函数中。
标准库提供了 eprintln! 宏来打印到标准错误流,所以将两个调用 println! 打印错误信息的位置替换为 eprintln!:
文件名: src/main.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
将 println! 改为 eprintln! 之后,让我们再次尝试用同样的方式运行程序,不使用任何参数并通过>
重定向标准输出:
1 2 |
|
现在我们看到了屏幕上的错误信息,同时output.txt
里什么也没有,这正是命令行程序所期望的行为。
如果使用不会造成错误的参数再次运行程序,不过仍然将标准输出重定向到一个文件,像这样:
1 |
|
我们并不会在终端看到任何输出,同时 output.txt 将会包含其结果:
文件名: output.txt
1 2 |
|
这一部分展示了现在我们适当的使用了成功时产生的标准输出和错误时产生的标准错误。