12.3 重构改进模块性和错误处理
INFO
ch12-03-improving-error-handling-and-modularity.md
commit 83788ff212a3281328e2f8f223ce9e0f69220b97
为了改善我们的程序这里有四个问题需要修复,而且它们都与程序的组织方式和如何处理潜在错误有关。第一,main
现在进行了两个任务:它解析了参数并打开了文件。对于一个这样的小函数,这并不是一个大问题。然而如果 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
函数中的责任应该被限制为:
- 使用参数值调用命令行解析逻辑
- 设置任何其他的配置
- 调用 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 parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}
我们仍然将命令行参数收集进一个 vector,不过不同于在 main
函数中将索引 1 的参数值赋值给变量 query
和将索引 2 的值赋值给变量 file_path
,我们将整个 vector 传递给 parse_config
函数。接着 parse_config
函数将包含决定哪个参数该放入哪个变量的逻辑,并将这些值返回到 main
。仍然在 main
中创建变量 query
和 file_path
,不过 main
不再负责处理命令行参数与变量如何对应。
这对重构我们这小程序可能有点大材小用,不过我们将采用小的、增量的步骤进行重构。在做出这些改变之后,再次运行程序并验证参数解析是否仍然正常。经常验证你的进展是一个好习惯,这样在遇到问题时能帮助你定位问题的成因。
组合配置值
我们可以采取另一个小的步骤来进一步改善这个函数。现在函数返回一个元组,不过立刻又将元组拆成了独立的部分。这是一个我们可能没有进行正确抽象的信号。
另一个表明还有改进空间的迹象是 parse_config
名称的 config
部分,它暗示了我们返回的两个值是相关的并都是一个配置值的一部分。目前除了将这两个值组合进元组之外并没有表达这个数据结构的意义:我们可以将这两个值放入一个结构体并给每个字段一个有意义的名字。这会让未来的维护者更容易理解不同的值如何相互关联以及它们的目的。
INFO
注意:一些同学将这种在复杂类型更为合适的场景下使用基本类型的反模式称为 基本类型偏执(primitive obsession)。
示例 12-6 展示了 parse_config
函数的改进。
文件名:src/main.rs
}
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 }
}
新定义的结构体 Config
中包含字段 query
和 file_path
。 parse_config
的签名表明它现在返回一个 Config
值。在之前的 parse_config
函数体中,我们返回了引用 args
中 String
值的字符串 slice,现在我们定义 Config
来包含拥有所有权的 String
值。main
中的 args
变量是参数值的所有者并只允许 parse_config
函数借用它们,这意味着如果 Config
尝试获取 args
中值的所有权将违反 Rust 的借用规则。
还有许多不同的方式可以处理 String
的数据,而最简单但有些不太高效的方式是调用这些值的 clone
方法。这会生成 Config
实例可以拥有的数据的完整拷贝,不过会比储存字符串数据的引用消耗更多的时间和内存。不过拷贝数据使得代码显得更加直白因为无需管理引用的生命周期,所以在这种情况下牺牲一小部分性能来换取简洁性的取舍是值得的。
INFO
clone
的权衡取舍
使用 由于其运行时消耗,许多 Rustacean 之间有一个趋势是倾向于避免使用
clone
来解决所有权问题。在关于迭代器的第十三章中,我们将会学习如何更有效率的处理这种情况,不过现在,复制一些字符串来取得进展是没有问题的,因为只会进行一次这样的拷贝,而且文件路径和要搜索的字符串都比较短。在第一轮编写时拥有一个可以工作但有点低效的程序要比尝试过度优化代码更好一些。随着你对 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
的与结构体关联的函数。做出这个改变使得代码更符合习惯:可以像标准库中的 String
调用 String::new
来创建一个该类型的实例那样,将 parse_config
变为一个与 Config
关联的 new
函数。示例 12-7 展示了需要做出的修改:
文件名:src/main.rs
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
这里将 main
中调用 parse_config
的地方更新为调用 Config::new
。我们将 parse_config
的名字改为 new
并将其移动到 impl
块中,这使得 new
函数与 Config
相关联。再次尝试编译并确保它可以工作。
修复错误处理
现在我们开始修复错误处理。回忆一下之前提到过如果 args
vector 包含少于 3 个项并尝试访问 vector 中索引 1
或索引 2
的值会造成程序 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' 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
之前检查 slice 是否足够长。如果 slice 不够长,程序会打印一个更好的错误信息并 panic:
文件名:src/main.rs
// --snip--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
// --snip--
这类似于 示例 9-13 中的 Guess::new
函数,那里如果 value
参数超出了有效值的范围就调用 panic!
。不同于检查值的范围,这里检查 args
的长度至少是 3
,而函数的剩余部分则可以在假设这个条件成立的基础上运行。如果 args
少于 3 个项,则这个条件将为真,并调用 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-9 中的技术可能不是最好的;正如 第九章 所讲到的一样,panic!
的调用更趋向于程序上的问题而不是使用上的问题。相反我们可以使用第九章学习的另一个技术 —— 返回一个可以表明成功或错误的 Result
。
new
中返回 Result
而不是调用 panic!
从 我们可以选择返回一个 Result
值,它在成功时会包含一个 Config
的实例,而在错误时会描述问题。我们还将把函数名从new
改为build
,因为许多程序员希望 new
函数永远不会失败。当 Config::new
与 main
交流时,可以使用 Result
类型来表明这里存在问题。接着修改 main
将 Err
成员转换为对用户更友好的错误,而不是 panic!
调用产生的关于 thread 'main'
和 RUST_BACKTRACE
的文本。
示例 12-9 展示了为了返回 Result
在 Config::new
的返回值和函数体中所需的改变。注意这还不能编译,直到下一个示例同时也更新了 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 })
}
}
现在 build
函数返回一个 Result
,在成功时带有一个 Config
实例而在出现错误时带有一个 &'static str
。回忆一下第十章 “静态生命周期” 中讲到 &'static str
是字符串字面值的类型,也是目前的错误信息。
build
函数体中有两处修改:当没有足够参数时不再调用 panic!
,而是返回 Err
值。同时我们将 Config
返回值包装进 Ok
成员中。这些修改使得函数符合其新的类型签名。
通过让 Config::build
返回一个 Err
值,这就允许 main
函数处理 build
函数返回的 Result
值并在出现错误的情况更明确的结束进程。
Config::build
并处理错误
调用 为了处理错误情况并打印一个对用户友好的信息,我们需要像示例 12-10 那样更新 main
函数来处理现在 Config::build
返回的 Result
。另外还需要手动实现原先由 panic!
负责的工作,即以非零错误码退出命令行工具的工作。非零的退出状态是一个惯例信号,用来告诉调用程序的进程:该程序以错误状态退出了。
文件名:src/main.rs
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--
在上面的示例中,使用了一个之前没有详细说明的方法:unwrap_or_else
,它定义于标准库的 Result<T, E>
上。使用 unwrap_or_else
可以进行一些自定义的非 panic!
的错误处理。当 Result
是 Ok
时,这个方法的行为类似于 unwrap
:它返回 Ok
内部封装的值。然而,当其值是 Err
时,该方法会调用一个 闭包(closure),也就是一个我们定义的作为参数传递给 unwrap_or_else
的匿名函数。第十三章 会更详细的介绍闭包。现在你需要理解的是 unwrap_or_else
会将 Err
的内部值,也就是示例 12-9 中增加的 not enough arguments
静态字符串的情况,传递给闭包中位于两道竖线间的参数 err
。闭包中的代码在其运行时可以使用这个 err
值。
我们新增了一个 use
行来从标准库中导入 process
。在错误的情况闭包中将被运行的代码只有两行:我们打印出了 err
值,接着调用了 std::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 中定义这个函数:
文件名:src/main.rs
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--
现在 run
函数包含了 main
中从读取文件开始的剩余的所有逻辑。run
函数获取一个 Config
实例作为参数。
run
函数中返回错误
从 通过将剩余的逻辑分离进 run
函数而不是留在 main
中,就可以像示例 12-9 中的 Config::build
那样改进错误处理。不再通过 expect
允许程序 panic,run
函数将会在出错时返回一个 Result<T, E>
。这让我们进一步以一种对用户友好的方式统一 main
中的错误处理。示例 12-12 展示了 run
签名和函数体中的改变:
文件名:src/main.rs
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
这里我们做出了三个明显的修改。首先,将 run
函数的返回类型变为 Result<(), Box<dyn Error>>
。之前这个函数返回 unit 类型 ()
,现在它仍然保持作为 Ok
时的返回值。
对于错误类型,使用了 trait 对象 Box<dyn Error>
(在开头使用了 use
语句将 std::error::Error
引入作用域)。第十七章 会涉及 trait 对象。目前只需知道 Box<dyn Error>
意味着函数会返回实现了 Error
trait 的类型,不过无需指定具体将会返回的值的类型。这提供了在不同的错误场景可能有不同类型的错误返回值的灵活性。这也就是 dyn
,它是 “动态的”(“dynamic”)的缩写。
第二个改变是去掉了 expect
调用并替换为 第九章 讲到的 ?
。不同于遇到错误就 panic!
,?
会从函数中返回错误值并让调用者来处理它。
第三个修改是现在成功时这个函数会返回一个 Ok
值。因为 run
函数签名中声明成功类型返回值是 ()
,这意味着需要将 unit 类型值包装进 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
值,它可能表明这里存在一个错误。但我们却没有检查这里是否有一个错误,而编译器提醒我们这里应该有一些错误处理代码!现在就让我们修正这个问题。
main
中 run
返回的错误
处理 我们将检查错误并使用类似示例 12-10 中 Config::build
处理错误的技术来处理它们,不过有一些细微的不同:
文件名:src/main.rs
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
来检查 run
是否返回一个 Err
值,不同于 unwrap_or_else
,并在出错时调用 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,这样就能测试它们并拥有一个含有更少功能的 main
函数。
让我们将所有不是 main
函数的代码从 src/main.rs 移动到新文件 src/lib.rs 中:
run
函数定义- 相关的
use
语句 Config
的定义Config::build
函数定义
现在 src/lib.rs 的内容应该看起来像示例 12-13(为了简洁省略了函数体)。注意直到下一个示例修改完 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--
}
这里使用了公有的 pub
关键字:在 Config
、其字段和其 build
方法,以及 run
函数上。现在我们有了一个拥有可以测试的公有 API 的库 crate 了。
现在需要在 src/main.rs 中将移动到 src/lib.rs 的代码引入二进制 crate 的作用域中,如示例 12-14 所示:
文件名:src/main.rs
}
}
我们添加了一行 use minigrep::Config
,它将 Config
类型引入作用域,并使用 crate 名称作为 run
函数的前缀。通过这些重构,所有功能应该能够联系在一起并运行了。运行 cargo run
来确保一切都正确的衔接在一起。
哇哦!我们做了大量的工作,不过我们为将来的成功打下了基础。现在处理错误将更容易,同时代码也更加模块化。从现在开始几乎所有的工作都将在 src/lib.rs 中进行。
让我们利用这些新创建的模块的优势来进行一些在旧代码中难以展开的工作,这些工作在新代码中非常容易实现,那就是:编写测试!