重构以改进模块化和错误处理
为了改进我们的程序,我们将修复四个与程序结构及其处理潜在错误的方式有关的问题。首先,我们的 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 中定义它。
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
部分,这意味着我们返回的两个值是相关的,并且都是一个配置值的一部分。我们目前没有在数据结构中传达这种含义,只是通过将两个值分组到一个元组中;我们将改为将这两个值放入一个 struct 中,并为 struct 的每个字段赋予有意义的名称。这样做将使此代码的未来维护人员更容易理解不同值之间的关系以及它们的用途。
列表 12-6 显示了对 parse_config
函数的改进。
parse_config
以返回 Config
struct 的实例我们添加了一个名为 Config
的 struct,定义为具有名为 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
struct 上的字段。
现在,我们的代码更清楚地传达了 query
和 file_path
是相关的,并且它们的目的是配置程序的工作方式。任何使用这些值的代码都知道在 config
实例中名为其用途的字段中找到它们。
为 Config
创建构造函数
到目前为止,我们已经从 main
中提取了负责解析命令行参数的逻辑,并将其放置在 parse_config
函数中。这样做帮助我们看到 query
和 file_path
值是相关的,并且这种关系应该在我们的代码中传达出来。然后,我们添加了一个 Config
struct 来命名 query
和 file_path
的相关用途,并能够从 parse_config
函数返回值的名称作为 struct 字段名称。
所以现在 parse_config
函数的目的是创建一个 Config
实例,我们可以将 parse_config
从一个普通函数更改为一个名为 new
的函数,该函数与 Config
struct 相关联。进行此更改将使代码更符合惯用法。我们可以通过调用 String::new
来创建标准库中类型的实例,例如 String
。类似地,通过将 parse_config
更改为与 Config
关联的 new
函数,我们将能够通过调用 Config::new
来创建 Config
的实例。列表 12-7 显示了我们需要进行的更改。
parse_config
更改为 Config::new
我们已经更新了 main
,在我们调用 parse_config
的地方改为调用 Config::new
。我们将 parse_config
的名称更改为 new
,并将其移动到 impl
块中,这会将 new
函数与 Config
关联起来。再次尝试编译此代码以确保它有效。
修复错误处理
现在我们将致力于修复我们的错误处理。回想一下,尝试访问 args
vector 中索引 1 或索引 2 处的值将导致程序 panic,如果 vector 包含少于三个项目。尝试在没有任何参数的情况下运行程序;它看起来像这样
$ 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 之前验证切片是否足够长。如果切片不够长,程序会 panic 并显示更好的错误消息。
此代码类似于 我们在列表 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
,它指示成功或错误。
返回 Result
而不是调用 panic!
我们可以改为返回一个 Result
值,该值将在成功情况下包含一个 Config
实例,并在错误情况下描述问题。我们还将函数名称从 new
更改为 build
,因为许多程序员期望 new
函数永远不会失败。当 Config::build
与 main
通信时,我们可以使用 Result
类型来发出存在问题的信号。然后我们可以更改 main
以将 Err
变体转换为对我们的用户来说更实用的错误,而没有关于 thread 'main'
和 RUST_BACKTRACE
的周围文本,而调用 panic!
会导致这些文本。
列表 12-9 显示了我们需要对我们现在调用的函数 Config::build
的返回值以及返回 Result
所需的函数主体进行的更改。请注意,在我们更新 main
之前,这不会编译,我们将在下一个列表中进行更新。
Config::build
返回 Result
我们的 build
函数返回一个 Result
,在成功情况下包含一个 Config
实例,在错误情况下包含一个字符串字面量。我们的错误值将始终是具有 'static
生命周期字符串字面量。
我们在函数主体中进行了两处更改:当用户没有传递足够的参数时,我们现在返回一个 Err
值,而不是调用 panic!
,并且我们将 Config
返回值包装在 Ok
中。这些更改使函数符合其新的类型签名。
从 Config::build
返回 Err
值允许 main
函数处理从 build
函数返回的 Result
值,并在错误情况下更干净地退出进程。
调用 Config::build
并处理错误
为了处理错误情况并打印用户友好的消息,我们需要更新 main
以处理 Config::build
返回的 Result
,如列表 12-10 所示。我们还将从 panic!
中移除使用非零错误代码退出命令行工具的责任,而是手动实现它。非零退出状态是一种约定,用于向调用我们程序的进程发出信号,表明程序以错误状态退出。
Config
失败,则以错误代码退出在此列表中,我们使用了一个我们尚未详细介绍的方法:unwrap_or_else
,它在标准库中由 Result<T, E>
定义。使用 unwrap_or_else
允许我们定义一些自定义的、非 panic!
错误处理。如果 Result
是一个 Ok
值,则此方法的行为类似于 unwrap
:它返回 Ok
包装的内部值。但是,如果该值是一个 Err
值,则此方法调用 closure 中的代码,这是一个匿名函数,我们定义并将其作为参数传递给 unwrap_or_else
。我们将在 第 13 章中更详细地介绍 closures。现在,您只需要知道 unwrap_or_else
会将 Err
的内部值(在本例中是我们在列表 12-9 中添加的静态字符串 "not enough arguments"
)传递到参数 err
中的 closure,该参数出现在竖线之间。closure 中的代码可以在运行时使用 err
值。
我们添加了一个新的 use
行,将标准库中的 process
引入作用域。将在错误情况下运行的 closure 中的代码只有两行:我们打印 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
函数run
函数现在包含 main
中的所有剩余逻辑,从读取文件开始。run
函数将 Config
实例作为参数。
从 run
函数返回错误
通过将剩余的程序逻辑分离到 run
函数中,我们可以改进错误处理,就像我们在列表 12-9 中对 Config::build
所做的那样。run
函数将返回一个 Result<T, E>
,而不是允许程序通过调用 expect
来 panic。这将使我们能够进一步将围绕处理错误的逻辑整合到 main
中,以用户友好的方式。列表 12-12 显示了我们需要对 run
的签名和主体进行的更改。
run
函数以返回 Result
我们在这里做了三个重大更改。首先,我们将 run
函数的返回类型更改为 Result<(), Box<dyn Error>>
。此函数以前返回单元类型 ()
,我们将其保留为 Ok
情况下的返回值。
对于错误类型,我们使用了 trait object Box<dyn Error>
(并且我们使用顶部的 use
语句将 std::error::Error
引入作用域)。我们将在 第 18 章中介绍 trait objects。现在,只需知道 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)]` 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
我们使用 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/main.rs 中 main
函数之外的所有代码移动到 src/lib.rs
run
函数定义- 相关的
use
语句 Config
的定义Config::build
函数定义
src/lib.rs 的内容应具有列表 12-13 中显示的签名(为了简洁起见,我们省略了函数的主体)。请注意,在我们修改列表 12-14 中的 src/main.rs 之前,这不会编译。
Config
和 run
移动到 src/lib.rs我们自由地使用了 pub
关键字:在 Config
上,在其字段和 build
方法上,以及在 run
函数上。我们现在有一个库 crate,它有一个我们可以测试的公共 API!
现在我们需要将我们移动到 src/lib.rs 的代码引入到 src/main.rs 中二进制 crate 的作用域中,如列表 12-14 所示。
minigrep
库 crate我们添加了一个 use minigrep::Config
行,将库 crate 中的 Config
类型引入到二进制 crate 的作用域中,并在 run
函数前加上了我们的 crate 名称。现在所有功能都应该连接起来并且应该可以工作。使用 cargo run
运行程序,并确保一切正常运行。
哇!这做了很多工作,但我们为未来的成功做好了准备。现在处理错误要容易得多,并且我们使代码更模块化。从现在开始,我们几乎所有的工作都将在 src/lib.rs 中完成。
让我们利用这种新发现的模块化,做一些在旧代码中很难做到,但在新代码中很容易做到的事情:我们将编写一些测试!