简介
欢迎来到「重构以提高模块化和错误处理能力」实验。本实验是 Rust 程序设计语言 的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将通过分离任务、对配置变量进行分组、提供有意义的错误消息以及整合错误处理代码来重构程序,以提高模块化和错误处理能力。
This tutorial is from open-source community. Access the source code
💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版
欢迎来到「重构以提高模块化和错误处理能力」实验。本实验是 Rust 程序设计语言 的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将通过分离任务、对配置变量进行分组、提供有意义的错误消息以及整合错误处理代码来重构程序,以提高模块化和错误处理能力。
为了改进我们的程序,我们将修复与程序结构以及它如何处理潜在错误相关的四个问题。首先,我们的 main
函数现在执行两项任务:解析参数和读取文件。随着程序的发展,main
函数处理的单独任务数量将会增加。随着一个函数承担的职责增多,它会变得更难理解、更难测试,并且在不破坏其某个部分的情况下更难更改。最好将功能分开,以便每个函数负责一项任务。
这个问题还与第二个问题相关:尽管 query
和 file_path
是我们程序的配置变量,但像 contents
这样的变量却用于执行程序的逻辑。main
函数变得越长,我们需要引入作用域的变量就越多;我们在作用域中拥有的变量越多,就越难跟踪每个变量的用途。最好将配置变量分组到一个结构体中,以使它们的用途清晰明了。
第三个问题是,当读取文件失败时,我们使用 expect
来打印错误消息,但错误消息只打印 Should have been able to read the file
。读取文件可能会以多种方式失败:例如,文件可能缺失,或者我们可能没有权限打开它。目前,无论情况如何,我们都会为所有情况打印相同的错误消息,这不会给用户提供任何信息!
第四,我们多次使用 expect
来处理不同的错误,如果用户在没有指定足够参数的情况下运行我们的程序,他们会从 Rust 得到一个 index out of bounds
错误,这个错误没有清楚地解释问题。如果所有的错误处理代码都在一个地方,那么如果错误处理逻辑需要更改,未来的维护者只需要在一个地方查阅代码就好了。将所有的错误处理代码放在一个地方还将确保我们打印的消息对最终用户有意义。
让我们通过重构项目来解决这四个问题。
将多个任务的职责分配给 main
函数的组织问题在许多二进制项目中都很常见。因此,当 main
函数开始变得庞大时,Rust 社区已经制定了一些准则来拆分二进制程序的不同关注点。这个过程包括以下步骤:
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
中定义这个函数。
文件名:src/main.rs
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:从 main
函数中提取 parse_config
函数
我们仍然将命令行参数收集到一个向量中,但不是在 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
函数的改进。
文件名:src/main.rs
fn main() {
let args: Vec<String> = env::args().collect();
1 let config = parse_config(&args);
println!("Searching for {}", 2 config.query);
println!("In file {}", 3 config.file_path);
let contents = fs::read_to_string(4 config.file_path)
.expect("Should have been able to read the file");
--snip--
}
5 struct Config {
query: String,
file_path: String,
}
6 fn parse_config(args: &[String]) -> Config {
7 let query = args[1].clone();
8 let file_path = args[2].clone();
Config { query, file_path }
}
清单 12-6:重构 parse_config
函数以返回 Config
结构体的实例
我们添加了一个名为 Config
的结构体,它有两个字段,分别名为 query
和 file_path
[5]。parse_config
的签名现在表明它返回一个 Config
值 [6]。在 parse_config
的主体中,我们以前返回的是引用 args
中 String
值的字符串切片,现在我们定义 Config
来包含拥有所有权的 String
值。main
中的 args
变量是参数值的所有者,它只是让 parse_config
函数借用它们,这意味着如果 Config
试图获取 args
中值的所有权,我们将违反 Rust 的借用规则。
我们可以通过多种方式来管理 String
数据;最简单的方法(尽管有点低效)是对这些值调用 clone
方法 [7] [8]。这将为 Config
实例创建数据的完整副本以供其拥有,这比存储对字符串数据的引用需要更多的时间和内存。然而,克隆数据也使我们的代码非常简单,因为我们不必管理引用的生命周期;在这种情况下,为了获得简单性而牺牲一点性能是值得的权衡。
使用 clone 的权衡
由于其运行时成本,许多 Rust 开发者倾向于避免使用
clone
来解决所有权问题。在第 13 章中,你将学习如何在这种情况下使用更高效的方法。但目前,复制几个字符串以继续推进是可以的,因为你只会复制一次,而且你的文件路径和查询字符串非常小。拥有一个有点低效但能正常工作的程序比在第一次尝试时就过度优化代码要好。随着你对 Rust 越来越有经验,从最有效的解决方案开始会更容易,但目前,调用clone
是完全可以接受的。
我们更新了 main
函数,使其将 parse_config
返回的 Config
实例放入一个名为 config
的变量中 [1],并且我们更新了之前使用单独的 query
和 file_path
变量的代码,现在改为使用 Config
结构体的字段 [2] [3] [4]。
现在我们的代码更清楚地表明 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 展示了我们需要做的更改。
文件名:src/main.rs
fn main() {
let args: Vec<String> = env::args().collect();
1 let config = Config::new(&args);
--snip--
}
--snip--
2 impl Config {
3 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
[1]。我们将 parse_config
的名称改为 new
[3],并将其移动到一个 impl
块中 [2],这将 new
函数与 Config
关联起来。再次尝试编译这段代码,以确保它能正常工作。
现在我们来处理错误处理的问题。回想一下,如果 args
向量中的元素少于三个,尝试访问索引为 1 或 2 的 args
向量中的值会导致程序恐慌。试着在不传入任何参数的情况下运行程序;它会像这样:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1 but
the index is 1', src/main.rs:27:21
note: run with `RUST_BACKTRACE=1` environment variable to display
a backtrace
“索引越界:长度为 1 但索引为 1” 这一行是给程序员的错误信息。它无助于我们的终端用户理解他们应该做什么。现在让我们来修复这个问题。
在清单 12-8 中,我们在 new
函数中添加了一个检查,在访问索引 1 和索引 2 之前,会先验证切片是否足够长。如果切片不够长,程序就会恐慌并显示一个更好的错误信息。
文件名:src/main.rs
--snip--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
--snip--
清单 12-8:添加对参数数量的检查
这段代码与我们在清单 9-13 中编写的 Guess::new
函数类似,在那里当 value
参数超出有效值范围时,我们调用了 panic!
。在这里,我们不是检查值的范围,而是检查 args
的长度是否至少为 3
,并且函数的其余部分可以在假设这个条件已经满足的情况下运行。如果 args
的元素少于三个,这个条件将为 true
,我们就调用 panic!
宏立即结束程序。
在 new
函数中添加了这几行额外的代码后,让我们再次在不传入任何参数的情况下运行程序,看看现在的错误是什么样的:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at 'not enough arguments',
src/main.rs:26:13
note: run with `RUST_BACKTRACE=1` environment variable to display
a backtrace
这个输出更好了:我们现在有了一个合理的错误信息。然而,我们也有一些不想给用户的额外信息。也许我们在清单 9-13 中使用的技术在这里不是最好的:正如第 9 章所讨论的,调用 panic!
对于编程问题比对于使用问题更合适。相反,我们将使用你在第 9 章中学到的另一种技术 —— 返回一个 Result
,它表示成功或错误。
我们可以改为返回一个 Result
值,在成功的情况下它将包含一个 Config
实例,在错误的情况下它将描述问题。我们还打算将函数名从 new
改为 build
,因为许多程序员期望 new
函数永远不会失败。当 Config::build
与 main
通信时,我们可以使用 Result
类型来表明出现了问题。然后我们可以修改 main
函数,将 Err
变体转换为对用户来说更实用的错误信息,而不会出现调用 panic!
时产生的关于 thread'main'
和 RUST_BACKTRACE
的周围文本。
清单 12-9 展示了我们需要对现在称为 Config::build
的函数的返回值以及返回 Result
所需的函数体所做的更改。请注意,在我们更新 main
函数之前,这段代码不会编译,我们将在下一个清单中进行更新。
文件名:src/main.rs
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 })
}
}
清单 12-9:从 Config::build
返回 Result
我们的 build
函数在成功的情况下返回一个包含 Config
实例的 Result
,在错误的情况下返回一个 &'static str
。我们的错误值将始终是具有 'static
生命周期的字符串字面量。
我们在函数体中做了两处更改:当用户传入的参数不足时,我们不再调用 panic!
,而是返回一个 Err
值,并且我们将 Config
返回值包装在一个 Ok
中。这些更改使函数符合其新的类型签名。
从 Config::build
返回一个 Err
值允许 main
函数处理从 build
函数返回的 Result
值,并在错误情况下更干净地退出进程。
为了处理错误情况并打印用户友好的消息,我们需要更新 main
函数来处理 Config::build
返回的 Result
,如清单 12-10 所示。我们还将退出命令行工具并返回非零错误码的责任从 panic!
中移除,而是手动实现它。非零退出状态是一种约定,用于向调用我们程序的进程表明程序以错误状态退出。
文件名:src/main.rs
1 use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
2 let config = Config::build(&args).3 unwrap_or_else(|4 err| {
5 println!("Problem parsing arguments: {err}");
6 process::exit(1);
});
--snip--
清单 12-10:如果构建 Config
失败则以错误码退出
在本清单中,我们使用了一个尚未详细介绍的方法:unwrap_or_else
,它由标准库在 Result<T, E>
上定义 [2]。使用 unwrap_or_else
允许我们定义一些自定义的、非 panic!
的错误处理。如果 Result
是一个 Ok
值,此方法的行为类似于 unwrap
:它返回 Ok
所包装的内部值。然而,如果值是一个 Err
值,此方法会调用闭包中的代码,闭包是我们定义并作为参数传递给 unwrap_or_else
的匿名函数 [3]。我们将在第 13 章更详细地介绍闭包。目前,你只需要知道 unwrap_or_else
会将 Err
的内部值(在这种情况下是我们在清单 12-9 中添加的静态字符串 "not enough arguments"
)作为参数 err
传递给我们在竖线之间出现的闭包 [4]。闭包中的代码在运行时可以使用 err
值。
我们添加了一条新的 use
语句,将标准库中的 process
引入作用域 [1]。在错误情况下运行的闭包中的代码只有两行:我们打印 err
值 [5],然后调用 process::exit
[6]。process::exit
函数将立即停止程序并返回作为退出状态码传递的数字。这类似于我们在清单 12-8 中使用的基于 panic!
的处理方式,但我们不再得到所有额外的输出。让我们试试:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
很好!这个输出对我们的用户来说友好得多。
既然我们已经完成了配置解析的重构,现在来看看程序的逻辑。正如我们在“二进制项目的关注点分离”中所说,我们将提取一个名为 run
的函数,它将包含当前 main
函数中所有与配置设置或错误处理无关的逻辑。完成后,main
函数将变得简洁且易于通过检查进行验证,并且我们能够为所有其他逻辑编写测试。
清单 12-11 展示了提取的 run
函数。目前,我们只是进行了提取函数这个小的渐进式改进。我们仍然在 src/main.rs
中定义这个函数。
文件名:src/main.rs
fn main() {
--snip--
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
函数将不再通过调用 expect
使程序恐慌,而是返回一个 Result<T, E>
。这将使我们能够以用户友好的方式进一步将错误处理逻辑整合到 main
函数中。清单 12-12 展示了我们需要对 run
函数的签名和主体所做的更改。
文件名:src/main.rs
1 use std::error::Error;
--snip--
2 fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)3?;
println!("With text:\n{contents}");
4 Ok(())
}
清单 12-12:更改 run
函数以返回 Result
我们在这里做了三个重大更改。首先,我们将 run
函数的返回类型更改为 Result<(), Box<dyn Error>>
[2]。这个函数之前返回单元类型 ()
,我们在 Ok
情况下仍然返回这个值。
对于错误类型,我们使用了 trait 对象 Box<dyn Error>
(并且我们在顶部使用 use
语句将 std::error::Error
引入作用域 [1])。我们将在第 17 章介绍 trait 对象。目前,只需知道 Box<dyn Error>
意味着该函数将返回一个实现 Error
trait 的类型,但我们不必指定返回值将是哪种具体类型。这使我们能够灵活地在不同的错误情况下返回可能不同类型的错误值。dyn
关键字是 dynamic 的缩写。
其次,我们删除了对 expect
的调用,转而使用 ?
运算符 [3],就像我们在第 9 章中讨论的那样。?
不会在错误时使程序恐慌,而是会从当前函数返回错误值供调用者处理。
第三,run
函数现在在成功情况下返回一个 Ok
值 [4]。我们在签名中将 run
函数的成功类型声明为 ()
,这意味着我们需要将单元类型值包装在 Ok
值中。这种 Ok(())
语法乍一看可能有点奇怪,但像这样使用 ()
是表示我们调用 run
只是为了其副作用的惯用方式;它不会返回我们需要的值。
当你运行这段代码时,它会编译,但会显示一个警告:
warning: unused `Result` that must be used
--> src/main.rs:19:5
|
19 | run(config);
| ^^^^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: this `Result` may be an `Err` variant, which should be
handled
Rust 告诉我们,我们的代码忽略了 Result
值,并且这个 Result
值可能表示发生了错误。但是我们没有检查是否发生了错误,编译器提醒我们这里可能应该有一些错误处理代码!现在让我们纠正这个问题。
我们将检查错误并使用一种与清单 12-10 中处理 Config::build
类似的技术来处理错误,但有一个细微的差别:
文件名:src/main.rs
fn main() {
--snip--
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
实例那样返回一个我们想要解包的值。因为 run
在成功的情况下返回 ()
,我们只关心检测到错误,所以我们不需要 unwrap_or_else
来返回解包后的值,因为解包后的值只会是 ()
。
在这两种情况下,if let
和 unwrap_or_else
函数的主体是相同的:我们打印错误并退出。
到目前为止,我们的 minigrep
项目看起来很不错!现在我们将拆分 src/main.rs
文件,并将一些代码放入 src/lib.rs
文件中。这样,我们就可以测试代码,并且让 src/main.rs
文件承担的职责更少。
让我们将 src/main.rs
中不在 main
函数内的所有代码移动到 src/lib.rs
中:
run
函数定义use
语句Config
的定义Config::build
函数定义src/lib.rs
的内容应该具有清单 12-13 中所示的签名(为简洁起见,我们省略了函数体)。请注意,在我们按照清单 12-14 修改 src/main.rs
之前,这段代码不会编译。
文件名:src/lib.rs
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub fn build(
args: &[String],
) -> Result<Config, &'static str> {
--snip--
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
--snip--
}
清单 12-13:将 Config
和 run
移动到 src/lib.rs
中
我们大量使用了 pub
关键字:应用于 Config
、其字段和 build
方法,以及 run
函数。现在我们有了一个具有可测试公共 API 的库包!
现在,我们需要将移动到 src/lib.rs
中的代码引入到 src/main.rs
中二进制包的作用域内,如清单 12-14 所示。
文件名:src/main.rs
use std::env;
use std::process;
use minigrep::Config;
fn main() {
--snip--
if let Err(e) = minigrep::run(config) {
--snip--
}
}
清单 12-14:在 src/main.rs
中使用 minigrep
库包
我们添加了一行 use minigrep::Config
,将库包中的 Config
类型引入到二进制包的作用域内,并且在调用 run
函数时加上了我们的包名作为前缀。现在所有功能应该都连接起来并且能正常工作了。使用 cargo run
运行程序,确保一切正常。
呼!这工作量可不小,但我们为未来的成功奠定了基础。现在处理错误变得容易多了,并且我们使代码更具模块化。从现在开始,几乎所有工作都将在 src/lib.rs
中完成。
让我们利用这种新获得的模块化来做一些用旧代码很难做到但用新代码却很容易做到的事情:编写一些测试!
恭喜你!你已经完成了“重构以提高模块化和错误处理”实验。你可以在 LabEx 中练习更多实验来提升你的技能。