重构以改进模块化和错误处理
为了改进我们的程序,我们将修复四个与程序结构以及处理潜在错误方式有关的问题。首先,我们的 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函数中。 - 当命令行解析逻辑开始变得复杂时,将其从
main函数中提取到其他函数或类型中。
经过这个过程后,main 函数中保留的职责应仅限于以下内容:
- 用参数值调用命令行解析逻辑
- 设置任何其他配置
- 调用 lib.rs 中的
run函数 - 如果
run返回错误,则处理该错误
这种模式是关于关注点分离的:main.rs 负责运行程序,lib.rs 负责处理手头任务的所有逻辑。由于你无法直接测试 main 函数,这种结构让你可以通过将程序逻辑移出 main 函数来测试所有逻辑。main 函数中保留的代码会足够小,可以通过阅读来验证其正确性。让我们按照这个过程重新组织我们的程序。
提取参数解析器
我们将把解析参数的功能提取到一个 main 会调用的函数中。列表 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--
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}");
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}
main 中提取一个 parse_config 函数我们仍然将命令行参数收集到一个 vector 中,但不再在 main 函数中将索引 1 处的参数值赋给变量 query,将索引 2 处的参数值赋给变量 file_path,而是将整个 vector 传给 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--
println!("With text:\n{contents}");
}
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 }
}
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 实例中以其用途命名的字段中查找它们。
为 Config 创建构造函数
到目前为止,我们已经从 main 中提取了负责解析命令行参数的逻辑,并将其放入 parse_config 函数中。这样做帮助我们看到 query 和 file_path 值是相关的,这种关系应该在我们的代码中传达出来。然后我们添加了一个 Config 结构体来命名 query 和 file_path 的相关用途,并能够从 parse_config 函数中以结构体字段名的形式返回这些值的名称。
所以,既然 parse_config 函数的目的是创建一个 Config 实例,我们可以将 parse_config 从一个普通函数改为一个名为 new 的、与 Config 结构体关联的函数。做出这个更改会让代码更符合惯用法。我们可以通过调用 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");
println!("With text:\n{contents}");
// --snip--
}
// --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 }
}
}
parse_config 改为 Config::new我们更新了 main 中调用 parse_config 的地方,改为调用 Config::new。我们将 parse_config 的名称改为 new,并将其移到一个 impl 块中,这将 new 函数与 Config 关联起来。尝试再次编译这段代码以确保它能正常工作。
修复错误处理
现在我们将着手修复错误处理。回顾一下,尝试访问 args vector 中索引 1 或索引 2 处的值会在 vector 包含少于三个项时导致程序 panic。尝试不带任何参数运行程序;它看起来会是这样:
$ 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 并显示更好的错误消息。
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");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
impl Config {
// --snip--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
// --snip--
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
这段代码与我们在列表 9-13 中编写的 Guess::new 函数类似,当 value 参数超出有效值范围时我们调用了 panic!。这里我们不是检查值的范围,而是检查 args 的长度至少为 3,函数的其余部分可以在这个条件已满足的假设下运行。如果 args 少于三项,这个条件将为 true,我们调用 panic! 宏立即结束程序。
在 new 中添加了这几行代码之后,让我们再次不带任何参数运行程序,看看现在的错误是什么样的:
$ 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
这个输出更好了:我们现在有了一个合理的错误消息。然而,我们也有一些不想给用户的多余信息。也许我们在列表 9-13 中使用的技术在这里不是最好的选择:正如第 9 章所讨论的,调用 panic! 更适合编程问题而非使用问题。相反,我们将使用你在第 9 章学到的另一种技术——返回一个表示成功或错误的 Result。
返回 Result 而非调用 panic!
相反,我们可以返回一个 Result 值,在成功的情况下包含一个 Config 实例,在错误的情况下描述问题。我们还将把函数名从 new 改为 build,因为许多程序员期望 new 函数永远不会失败。当 Config::build 向 main 通信时,我们可以使用 Result 类型来表示出现了问题。然后,我们可以更改 main,将 Err 变体转换为对用户更实用的错误,而无需调用 panic! 所带来的关于 thread 'main' 和 RUST_BACKTRACE 的附带文本。
列表 12-9 展示了我们需要对现在称为 Config::build 的函数的返回值以及函数体所需的更改,以返回一个 Result。请注意,在我们更新 main 之前,这不会编译,我们将在下一个列表中完成。
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");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
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 })
}
}
Config::build 返回一个 Result我们的 build 函数在成功情况下返回一个带有 Config 实例的 Result,在错误情况下返回一个字符串字面量。我们的错误值将始终是具有 'static 生命周期的字符串字面量。
我们在函数体中做了两处更改:当用户没有传递足够的参数时,我们现在返回一个 Err 值,而不是调用 panic!,并且我们将 Config 返回值包装在 Ok 中。这些更改使函数符合其新的类型签名。
从 Config::build 返回 Err 值允许 main 函数处理 build 函数返回的 Result 值,并在错误情况下更干净地退出进程。
调用 Config::build 并处理错误
为了处理错误情况并打印用户友好的消息,我们需要更新 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);
});
// --snip--
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");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
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 })
}
}
Config 失败则以错误码退出在这个列表中,我们使用了一个尚未详细介绍的方法:unwrap_or_else,它由标准库定义在 Result<T, E> 上。使用 unwrap_or_else 允许我们定义一些自定义的、非 panic! 的错误处理。如果 Result 是一个 Ok 值,这个方法的行为类似于 unwrap:它返回 Ok 包装的内部值。但是,如果该值是一个 Err 值,这个方法会调用闭包中的代码,闭包是我们定义并作为参数传递给 unwrap_or_else 的匿名函数。我们将在第 13 章中更详细地介绍闭包。目前,你只需要知道 unwrap_or_else 会将 Err 的内部值——在本例中是我们在列表 12-9 中添加的静态字符串 "not enough arguments"——传递给我们在竖线之间出现的参数 err 中的闭包。闭包中的代码在运行时可以使用 err 值。
我们添加了一个新的 use 行,将 process 从标准库引入作用域。在错误情况下将运行的闭包代码只有两行:我们打印 err 值,然后调用 process::exit。process::exit 函数将立即停止程序并返回作为退出状态码传入的数字。这与我们在列表 12-8 中使用的基于 panic! 的处理类似,但我们不再得到所有的额外输出。让我们试试:
$ 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 展示了提取 run 函数的小而渐进的改进。
use std::env;
use std::fs;
use std::process;
fn main() {
// --snip--
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--
struct Config {
query: String,
file_path: String,
}
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 })
}
}
run 函数run 函数现在包含了 main 中从读取文件开始的所有剩余逻辑。run 函数接受 Config 实例作为参数。
从 run 返回错误
随着剩余的程序逻辑被分离到 run 函数中,我们可以像在列表 12-9 中对 Config::build 所做的那样改进错误处理。与其通过调用 expect 让程序 panic,run 函数将在出现问题时返回一个 Result<T, E>。这将让我们以一种对用户友好的方式进一步将处理错误的逻辑整合到 main 中。列表 12-12 展示了我们需要对 run 的签名和函数体所做的更改。
use std::env;
use std::fs;
use std::process;
use std::error::Error;
// --snip--
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) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
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 })
}
}
run 函数以返回 Result我们在这里做了三处重大更改。首先,我们将 run 函数的返回类型改为 Result<(), Box<dyn Error>>。这个函数以前返回单元类型 (),我们保留它作为 Ok 情况下返回的值。
对于错误类型,我们使用了 trait 对象 Box<dyn Error>(并且我们在顶部用 use 语句将 std::error::Error 引入作用域)。我们将在第 18 章中介绍 trait 对象。目前,只需知道 Box<dyn Error> 意味着该函数将返回一个实现了 Error trait 的类型,但我们不必指定返回值的具体类型。这为我们提供了在不同错误情况下返回可能属于不同类型的错误值的灵活性。dyn 关键字是dynamic(动态)的缩写。
其次,我们移除了 expect 的调用,转而使用 ? 运算符,正如我们在第 9 章中所讨论的。与在错误时调用 panic! 不同,? 会从当前函数返回错误值供调用者处理。
第三,run 函数现在在成功情况下返回一个 Ok 值。我们在签名中将 run 函数的成功类型声明为 (),这意味着我们需要将单元类型值包装在 Ok 值中。这种 Ok(()) 语法一开始可能看起来有点奇怪。但像这样使用 () 是表示我们调用 run 只是为了其副作用的惯用方式;它不返回我们需要的值。
当你运行这段代码时,它会编译但会显示一个警告:
$ 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 告诉我们,我们的代码忽略了 Result 值,而 Result 值可能表明发生了错误。但我们没有检查是否存在错误,编译器提醒我们,我们可能应该在这里放置一些错误处理代码!让我们现在纠正这个问题。
在 main 中处理从 run 返回的错误
我们将使用一种与我们在列表 12-10 中对 Config::build 使用的技术类似的方法来检查错误并处理它们,但有一点不同:
文件名:src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
fn main() {
// --snip--
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);
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
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 })
}
}
我们使用 if let 而不是 unwrap_or_else 来检查 run 是否返回 Err 值,如果是则调用 process::exit(1)。run 函数不像 Config::build 返回 Config 实例那样返回我们想要 unwrap 的值。因为 run 在成功情况下返回 (),我们只关心检测错误,所以不需要 unwrap_or_else 来返回解包后的值,因为那个值只会是 ()。
if let 和 unwrap_or_else 函数的主体在两种情况下都是相同的:我们打印错误并退出。
将代码拆分为库 crate
我们的 minigrep 项目到目前为止看起来不错!现在我们将拆分 src/main.rs 文件,并将一些代码放入 src/lib.rs 文件中。这样,我们就可以测试代码,并且拥有一个职责更少的 src/main.rs 文件。
让我们在 src/lib.rs 中而不是 src/main.rs 中定义负责搜索文本的代码,这将让我们(或任何其他使用我们 minigrep 库的人)从比我们的 minigrep 二进制文件更多的上下文中调用搜索函数。
首先,让我们在 src/lib.rs 中定义 search 函数的签名,如列表 12-13 所示,函数体调用 unimplemented! 宏。我们将在填写实现时更详细地解释这个签名。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
search 函数我们在函数定义上使用了 pub 关键字,将 search 指定为库 crate 公共 API 的一部分。现在我们有了一个可以从二进制 crate 使用且可以测试的库 crate!
现在我们需要将 src/lib.rs 中定义的代码引入 src/main.rs 中二进制 crate 的作用域并调用它,如列表 12-14 所示。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
// --snip--
use minigrep::search;
fn main() {
// --snip--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
// --snip--
struct Config {
query: String,
file_path: String,
}
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 })
}
}
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(())
}
minigrep 库 crate 的 search 函数我们添加了一行 use minigrep::search,将 search 函数从库 crate 引入二进制 crate 的作用域。然后,在 run 函数中,我们不再打印文件的内容,而是调用 search 函数,并将 config.query 值和 contents 作为参数传递。然后,run 将使用 for 循环打印 search 返回的每一行匹配查询的结果。现在也是移除 main 函数中显示查询和文件路径的 println! 调用的好时机,这样我们的程序只打印搜索结果(如果没有错误发生)。
请注意,搜索函数会在打印发生之前将所有结果收集到它返回的 vector 中。在搜索大文件时,这种实现可能会显示结果很慢,因为结果不是在找到时就打印的;我们将在第 13 章中讨论一种使用迭代器来修复这个问题的可能方法。
哇!这是一项艰巨的工作,但我们已经为未来的成功做好了准备。现在处理错误要容易得多,而且我们使代码更加模块化了。从现在起,我们几乎所有的工作都将在 src/lib.rs 中完成。
让我们利用这种新获得的模块化来做一些在旧代码中很难但在新代码中很容易的事情:我们将编写一些测试!