重构以提高模块性和错误处理
为了改进我们的程序,我们将解决四个与程序结构和处理潜在错误相关的问题。首先,我们的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
函数中的职责应仅限于以下内容:
- 使用带有参数值的命令行解析逻辑,输出翻译直接不添加任何额外文本。绝对不要添加原始翻译内容中没有的符号或标签。记住,保留所有HTML标签和属性,仅翻译内容!
- 设置任何其他配置
- 在 lib.rs 中调用
run
函数 - 处理
run
返回错误的情况
这种模式是关于分离关注点的:main.rs 负责运行程序,而 lib.rs 负责处理当前任务的所有逻辑。因为不能直接测试 main
函数,这种结构允许你通过将逻辑移到 lib.rs 中的函数来测试程序的所有逻辑。保留在 main.rs 中的代码将足够小,可以通过阅读来验证其正确性。让我们通过这个过程来重构我们的程序。
Extracting the Argument Parser
我们将把解析参数的功能提取到一个函数中,main
将调用该函数以准备将命令行解析逻辑移动到 src/lib.rs。列表 12-5 显示了调用新函数 parse_config
的 main
的新起点,我们暂时将在 src/main.rs 中定义该函数。
我们仍然将命令行参数收集到一个向量中,但不再在main
函数中将索引1的参数值赋给变量query
,将索引2的参数值赋给变量file_path
,而是将整个向量传递给parse_config
函数。parse_config
函数然后包含确定哪个参数进入哪个变量的逻辑,并将值传递回main
。我们仍然在main
中创建query
和file_path
变量,但main
不再负责确定命令行参数和变量如何对应。
这个重构对于我们的小程序来说可能看起来有些过度,但我们正在以小的、增量的步骤进行重构。在进行此更改后,再次运行程序以验证参数解析是否仍然有效。经常检查你的进度是很好的,这有助于在问题发生时确定问题的原因。
Grouping Configuration Values
我们可以再迈出一小步来进一步改进 parse_config
函数。
目前,我们返回的是一个元组,但紧接着我们又立即将这个元组拆分成单独的部分。这表明我们可能还没有找到正确的抽象。
另一个表明有改进空间的指标是 parse_config
中的 config
部分,这表明我们返回的两个值是相关的,并且都是一个配置值的一部分。我们目前除了将这两个值组合成一个元组外,并没有在数据结构中传达这种含义;我们将把这两个值放入一个结构体中,并为每个结构体字段赋予一个有意义的名称。这样做将使未来的代码维护者更容易理解不同值之间的关系及其目的。
列表 12-6 显示了对 parse_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
实例字段中找到它们。
Creating a Constructor for 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显示了我们需要进行的更改。
我们已经更新了main
,将调用parse_config
改为调用Config::new
。我们将parse_config
的名称更改为new
,并将其移动到一个impl
块内,这将new
函数与Config
关联起来。再次尝试编译这段代码,确保它能正常工作。
修复错误处理
现在我们将致力于修复错误处理。回想一下,尝试访问 args
向量中索引 1 或索引 2 的值如果向量包含的元素少于三个,程序将会崩溃。尝试在不带任何参数的情况下运行程序;它将如下所示:
$ 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' 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
是一条面向程序员的错误消息。它不会帮助我们的最终用户理解他们应该怎么做。现在让我们来修复这个问题。
Improving the Error Message
在清单 12-8 中,我们在 new
函数中添加了一个检查,以验证切片在访问索引 1 和索引 2 之前是否足够长。如果切片不够长,程序会崩溃并显示更好的错误消息。
这段代码类似于我们在清单 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' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
这个输出更好:我们现在有一个合理的错误消息。然而,我们也有不想提供给用户的多余信息。也许我们在清单9-13中使用的技术在这里并不是最好的:panic!
调用更适合于编程问题而不是使用问题,正如第9章中讨论的那样。相反,我们将使用你在第9章中学到的另一种技术—返回一个Result
,它表示成功或错误。
Returning a Result
Instead of Calling panic!
我们可以返回一个Result
值,在成功的情况下包含一个Config
实例,在错误的情况下描述问题。我们还将把函数名从new
改为build
,因为许多程序员期望new
函数永远不会失败。当Config::build
与main
通信时,我们可以使用Result
类型来信号有问题。然后我们可以更改main
,将Err
变体转换为对用户更实用的错误,而不会包含panic!
调用引起的关于thread 'main'
和RUST_BACKTRACE
的周围文本。
列表 12-9 显示了我们需要对现在称为 Config::build
的函数的返回值以及为了返回一个 Result
所需的函数体进行的更改。请注意,直到我们更新 main
之前,这将无法编译,我们将在下一个列表中进行更新。
我们的 build
函数在成功时返回一个包含 Config
实例的 Result
,在错误时返回一个字符串字面量。我们的错误值将始终是具有 'static
生命周期的字符串字面量。
我们在函数体中做了两个更改:当用户没有传递足够的参数时,我们现在返回一个Err
值,而不是调用panic!
,并且我们将Config
返回值包装在Ok
中。这些更改使函数符合其新的类型签名。
从 Config::build
返回一个 Err
值允许 main
函数处理从 build
函数返回的 Result
值,并在错误情况下更干净地退出进程。
Calling Config::build
and Handling Errors
为了处理错误情况并打印用户友好的消息,我们需要更新main
以处理Config::build
返回的Result
,如清单12-10所示。我们还将从panic!
中移除以非零错误代码退出命令行工具的责任,而是手动实现它。非零退出状态是一种惯例,用于向调用我们程序的进程发出信号,表示程序以错误状态退出。
在本示例中,我们使用了一个尚未详细讨论的方法: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
函数。目前,我们只是做了提取函数这一小的、逐步的改进。我们仍然在 src/main.rs 中定义该函数。
run
函数现在包含了来自 main
的所有剩余逻辑,从读取文件开始。run
函数接受 Config
实例作为参数。
Returning Errors from the run
Function
随着剩余的程序逻辑被分离到 run
函数中,我们可以改进错误处理,就像我们在清单 12-9 中对 Config::build
所做的那样。而不是通过调用 expect
使程序 panic,run
函数将在出现问题时返回一个 Result<T, E>
。这将使我们能够进一步将错误处理逻辑整合到 main
中,以一种用户友好的方式。清单 12-12 显示了我们需要对 run
的签名和主体进行的更改。
我们在这里做了三个重要的更改。首先,我们将 run
函数的返回类型更改为 Result<(), Box<dyn Error>>
。此函数之前返回单元类型 ()
,我们保持在 Ok
情况下返回的值不变。
对于错误类型,我们使用了trait 对象 Box<dyn Error>
(并且我们通过顶部的 use
语句将 std::error::Error
引入了作用域)。
我们将在第 18 章中介绍 trait 对象。目前,只需知道 Box<dyn Error>
意味着函数将返回一个实现了 Error
trait 的类型,但不必指定返回值的具体类型。这使我们能够在不同的错误情况下返回可能不同类型的错误值。dyn
关键字是 动态 的缩写。
其次,我们移除了对 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)]` 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
值
可能表示发生了错误。但我们没有检查是否发生了错误,编译器提醒我们,我们可能打算在这里有一些错误处理代码!现在让我们纠正这个问题。
Handling Errors Returned from run
in main
我们将检查错误并使用类似于我们在清单 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
值,并在返回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 之前,这不会编译。
我们大量使用了 pub
关键字:在 Config
上,在其字段和其 build
方法上,以及在 run
函数上。我们现在有一个具有可以测试的公共 API 的库 crate!
现在我们需要将移动到src/lib.rs的代码引入src/main.rs中二进制包的作用域,如清单12-14所示。
我们添加了一行use minigrep::Config
,将Config
类型从库crate引入到二进制crate的作用域中,并且我们在run
函数前加上了crate名称。现在所有功能都应该连接起来并且可以工作。使用cargo run
运行程序,并确保一切正常工作。
呼!这是一项艰巨的工作,但我们已经为未来的成功奠定了基础。现在处理错误变得更加容易,而且我们使代码更加模块化。从现在开始,我们几乎所有的工件都将在src/lib.rs中完成。
让我们利用这种新获得的模块化特性来做一些在旧代码中很难但在新代码中很容易的事情:我们将编写一些测试!