使用测试驱动开发添加功能
现在我们已经把搜索逻辑放在 src/lib.rs 中,与 main
函数分离开来,这样就更容易为代码的核心功能编写测试了。我们可以直接使用各种参数调用函数并检查返回值,而不必从命令行调用我们的二进制程序。
在本节中,我们将使用测试驱动开发(TDD)过程向 minigrep 程序添加搜索逻辑,步骤如下:
- 编写一个会失败的测试,并运行它以确保它因你预期的原因而失败。
- 编写或修改少量代码,使新测试通过。
- 重构刚刚添加或修改的代码,并确保测试仍然通过。
- 从第 1 步开始重复!
尽管 TDD 只是众多编写软件的方式之一,但它可以帮助驱动代码设计。在编写使测试通过的代码之前先编写测试,有助于在整个过程中保持较高的测试覆盖率。
我们将使用测试驱动的方式来实现这个功能:在文件内容中搜索查询字符串,并生成匹配查询的行列表。我们将在一个名为 search 的函数中添加这个功能。
编写失败测试
在 src/lib.rs 中,我们将添加一个带有测试函数的 tests 模块,就像我们在
第 11 章中所做的那样。该测试函数指定了我们希望 search 函数具有的行为:它接收一个查询字符串和要搜索的文本,并只返回文本中包含查询字符串的那些行。列表 12-15 展示了这个测试。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
// --snip--
#[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));
}
}
search 函数功能创建一个失败测试这个测试搜索字符串 "duct"。我们要搜索的文本有三行,其中只有一行包含 "duct"(注意,开头双引号后的反斜杠告诉 Rust 不要在该字符串字面量的内容开头放置换行符)。我们断言从 search 函数返回的值只包含我们期望的那一行。
如果我们运行这个测试,它目前会失败,因为 unimplemented! 宏会以“not implemented”消息引发 panic。根据 TDD 原则,我们将迈出一小步,只添加足够的代码,通过定义 search 函数始终返回一个空 vector,使得调用该函数时不会 panic,如列表 12-16 所示。然后,测试应该能够编译并失败,因为空 vector 与包含行 "safe, fast, productive." 的 vector 不匹配。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
#[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));
}
}
search 函数代码,使得调用它不会 panic现在让我们讨论一下为什么需要在 search 的签名中定义一个显式生命周期 'a,并将该生命周期用于 contents 参数和返回值。回想一下
第 10 章中,生命周期参数指定哪个参数的生命周期与返回值的生命周期相关联。在本例中,我们表明返回的 vector 应包含引用参数 contents(而不是参数 query)的切片的字符串切片。
换句话说,我们告诉 Rust,search 函数返回的数据将与传入 search 函数的 contents 参数中的数据存活得一样久。这一点很重要!切片所引用的数据需要有效,引用才能有效;如果编译器假定我们是对 query 而不是 contents 制作字符串切片,它就会错误地进行安全检查。
如果我们忘记生命周期注解并尝试编译这个函数,将会得到以下错误:
$ 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 是包含所有文本的参数,而我们希望返回该文本中匹配的部分,所以我们知道 contents 是唯一应该使用生命周期语法与返回值关联的参数。
其他编程语言不要求你在签名中将参数与返回值关联起来,但这种做法随着时间的推移会变得更容易。你可能想将这个示例与第 10 章中“使用生命周期验证引用”一节的示例进行比较。
编写通过测试的代码
目前,我们的测试失败是因为我们总是返回一个空 vector。为了解决这个问题并实现 search,我们的程序需要遵循以下步骤:
- 遍历 contents 的每一行。
- 检查该行是否包含我们的查询字符串。
- 如果包含,将其添加到我们要返回的值列表中。
- 如果不包含,什么也不做。
- 返回匹配的结果列表。
让我们逐步完成每一步,从遍历行开始。
使用 lines 方法遍历每一行
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
}
}
#[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));
}
}
contents 中的每一行lines 方法返回一个迭代器。我们将在第 13 章中深入讨论迭代器。但回想一下,你在列表 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
}
}
}
#[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));
}
}
query 中的字符串目前,我们正在逐步构建功能。为了让代码能够编译,我们需要按照函数签名中的指示从函数体返回一个值。
存储匹配的行
为了完成这个函数,我们需要一种方法来存储我们想要返回的匹配行。为此,我们可以在 for 循环之前创建一个可变 vector,并调用 push 方法将 line 存储到 vector 中。在 for 循环之后,我们返回该 vector,如列表 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
}
#[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));
}
}
现在 search 函数应该只返回包含 query 的行,我们的测试应该通过。让我们运行测试:
$ 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 函数的实现。search 函数中的代码并不算太差,但它没有利用迭代器的一些有用特性。我们将在第 13 章中回到这个示例,届时我们将详细探讨迭代器,并研究如何改进它。
现在整个程序应该可以工作了!让我们试用一下,首先用一个应该从 Emily Dickinson 的诗中恰好返回一行的词:frog。
$ 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:
$ 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:
$ 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`
太棒了!我们构建了自己的经典工具迷你版本,并学到了很多关于如何组织应用程序的知识。我们还学习了一些关于文件输入输出、生命周期、测试和命令行解析的知识。
为了完善这个项目,我们将简要演示如何使用环境变量以及如何打印到标准错误,这两者在编写命令行程序时都很有用。