为了改善这个程序,我们需要修复四个问题,这些问题和程序的结构、潜在发生的错误有关。我们的主函数主要执行两个主要任务:解析参数,读取文件。当程序增长,主函数将处理更多的任务,随着函数承担更多的责任,它将变得越来越难以测试,越来越难以修改,最好的方法是将的功能分拆为独立的功能,每一个功能只负责自己的任务就好。
第二个问题是:尽管需要query和file_path这样的配置变量和contents用于执行程序逻辑的变量,当时主函数会变得更长,作用域内引入的变量越多,作用域内存在的变量就越多,跟踪每一个变量就越难。最好的方式是将配置变量放入一个结构体内是的其目的性更加清晰。
第三个问题是使用expect来显示读取文件失败然后显示错误信息,但是错误信息只能显示一条,不能精确表明详细的错误原因。因为读取文件内容导致出错的原因有很多,例如:没找到,或者没有权限等。但是当前的代码无论哪种错误都只显示"无法读取这个文件",不会在给终端用户更多的信息。
第四个问题是,当用户没有给足参数,系统只会显示"index out of bounds"这个错误信息,并不能清晰的说明这个问题。最好的方式是统一处理所有错误,以便只在一个地方维护错误处理逻辑。这样做也有利于输出更加友好的错误信息给终端用户。
现在让我们通过重构来解决这四个问题吧:
14.3.1 关注分离点
许多项目都存在一个组织性问题,就是将太多的责任分配给了主函数。最终,程序员们发现当主函数变得过大,将其分拆为各自独立的二进制程序是非常实用的。这个过程按下面的步骤进行:
- 将你的程序分拆为main.rs和lib.rs两个文件,将业务逻辑迁移至lib.rs文件中。
- 如果命令行解析逻辑足够小,他就可以保留在main函数中。
- 当解析逻辑变得复杂,可以将其抽取出main函数并保持到其他函数或类型中。
保留在main函数的功能主要为:
- 调用命令行解析逻辑,并给其传递参数
- 设置其他相关的配置
- 调用保存在lib.rs中的run函数
- 如果run返回了错误处理这些错误
这种模式就是业务分离:main函数负责程序的运行,lib负责业务逻辑的处理。由于不能直接测试main函数,通过从主函数中抽离业务,这种架构就允许你可以测试所有的业务逻辑。保留在主函数中的代码小的足以通过阅读它就可以验证其正确性。让我们开干吧!
14.3.1.1 剥离参数解析器
我们新建一个parse_config函数,然后将主函数中的解析参数逻辑迁移到这个新建的函数中。
rust
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
let (query, file_path) = parse_config(&args);
println!("query={query};file_path={file_path}");
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}
主函数依旧获取命令行参数并保存到向量集合中,但是它已经不再负责解析参数和参数的对应关系,parse_config负责这些处理,将处理结果传回给主函数,主函数依旧创建变量query和file_paht用于保存解析函数传回的参数。
这个重构工作看上去有点裁剪过度了,但是我们的重构是微量的,逐步增加的修改。做完上面的工作,运行程序,查看参数解析是否正常。经常检查程序运行结果是一个非常好的习惯,当问题发生时,有助于找出问题源。
14.3.1.2 配置参数编组
接下来再前进一小步,上一步中我们返回的是元祖,接着又将其进行拆分为各自独立的变量。这意味着我们还没有正确的进行代码抽象。
可以将parse_config返回的元祖数据转化为结构体数据,并分配给每一个变量一个有真实含义的名字,以便后续的代码维护者可以更容易的理解这段代码的含义,以及这两个变量之间的相互关系和它们的目的是什么。
rust
use std::env;
use std::fs;
fn main() {
let args:Vec<String> = env::args().collect();
let config:Config = parse_config(&args);
println!("config={},{}",config.query,config.file_path);
let contents = fs::read_to_string(config.file_path).expect("读取文件失败!");
}
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类型。在它的内部不再使用字符串借用作为返回值,而是返回拥有所有权的字符串。args的所有权归主函数所有,如果Config占据args的所有权会违反Rust的借用规则。
有很多方法可以管理字符串数据,但是最容易的是使用clone函数,复制一份现有字符串的数据,虽然会损失一些效率。这将会在生成Config实例时对现有数据进行全拷贝,以便拥有其所有权。相比保存字符串数据的引用,clone操作会花费更多的时间和更多的内存。然而clone操作还是值得的,因为我们不必再费心管理引用的生命周期了。在此场景下,放弃些许性能获取简洁性是值得的。
14.3.1.3 使用clone函数的代价
现下有一种趋势,尽量避免使用clone来修正所有权问题,因为它会有运行时开销。后面我们会介绍这种情况下如何使用其他更有效的方法。但是在此示例中,使用clone函数完全可以,因为这些拷贝工作只需一次,并且数据量非常小。比起第一步过度优化代码而言,损失些许效率让程序快速推进更加值得。当你成为了经验丰富的工程师,你会很容易的使用最有效的代码,但是现在,使用clone时最合适的。
现在代码变得更加清晰,query和file_path是相互关联的,它们代表配置参数,这些参数将决定程序如何工作。使用这些值的代码知道应该在config实例中按名知意去使用。
14.3.1.4 给Config创建构造器
目前,我们将解析参数的业务逻辑从主函数中抽离,放入parse_config函数中。同时query和file_path这两值是有关联的,它们之间的关系应该在代码中一起传递,于是我们又增加了Config结构体保存这两个变量,使其看名知意;最后将其作为parse_config函数的返回参数,保留了这两个变量的关联性。
接下来,我们的目标是将parse_config转换为结构体Config的new函数,这种改变使得代码变得更加符合使用习惯(更加地道,味道更加纯正)。我们使用字符串String类型,也可以调用new函数,类似的我们通过将parse_config转换为Config的new函数,然后就可以调用Config::new函数来创建新的Config实例。
rust
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config: Config = Config::new(&args);
println!("config={},{}", config.query, config.file_path);
let contents = fs::read_to_string(config.file_path).expect("读取文件失败!");
}
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函数。修改parse_config的名称为new并移入impl Config块中。编译此代码确保运行正常。
14.3.2 完善错误处理
现在我们需要完善我们的错误处理了,当执行该程序时,携带的参数少于3个,参数索引值1或2将会爆出索引值超界的错误信息,程序会产生panic并停止运行。运行少于三个参数时,程序输出以下错误信息:
rust
thread 'main' (28664) panicked at src\main.rs:18: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
error: process didn't exit successfully: `target\debug\lession14_004.exe` (exit code: 101)
这段信息:index out of bounds: the len is 1 but the index is 1是给程序员的提示信息,它对于终端用户来说是没有帮助的,现在让我们修复它。
14.3.2.1 改进错误消息的显示
我们可以在new函数中增加检查信息,用于验证参数是否满足要求,如果不满足程序终止运行并给出明确的且有帮助的错误信息提示。
rust
impl Config {
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("命令参数不足,命令格式为:程序名称 -- 要搜索的字符串 要搜索的文件名称");
}
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
我们检查args的长度是否小于3,如果满足则继续执行,如果不满足,则停止执行,退出程序的运行。执行cargo run命令,程序输出信息为:
rust
thread 'main' (38076) panicked at src\main.rs:19:13:
命令参数不足,命令格式为:程序名称 -- 要搜索的字符串 要搜索的文件名称
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: process didn't exit successfully: `target\debug\lession14_004.exe` (exit code: 101)
上面的错误信息就十分的完美,明确告知操作失败的原因,并给出正确输入的格式。
14.3.2.2 错误返回Result而不是panic宏
我们使用Result类型作为返回值,而不是直接触发paic,这样就可以在成功时返回Config实例,失败时返回错误信息。我们也将函数名从new修改为build,这是因为大部分程序员任务new函数永不会失败。当使用Config::build和main 函数交互时,可以使用Result告知出现了什么问题。接着,就改变main函数为用户将Err变量转换为更加具有操作性的错误信息而不是哪些专业用语。
下面是修改后build函数:
rust
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("文件参数不足!");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
这个build函数在成功时返回config实例,失败时返回字符串字面量。错误中的值将一直是字符串字面量,因为它使用了'static生命周期。
这些修改替换了之前的panic!宏的调用,当用户执行命令参数不足时,返回Err值,成功时返回Ok包裹的Config实例。这些改变符合了函数的签名。
使用Err值使得main函数处理Result时条理更加清晰。我们自己负责实现在错误情况下使用非零的错误码退出程序,而不再使用panic。
14.3.2.3 调用Config::build和错误处理
为了处理build返回的错误并显示出用户友好的信息提示,我们需要修改main函数处理Config::build函数返回的Result数据类型。还要负责在错误时返回非零的退出码。这个非零的状态码会返回给调用的进程。
rust
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("解析参数出错:{err}");
process::exit(1);
});
上面的代码中,我们使用了之前从未涉及到的unwrap_or_else函数,它是有标准库提供给Result的方法。使用这个函数可以让我们有一些个性化的错误处理。如果Result是Ok,这个方法的行为如同unwrap,返回Ok的内部值。但是如果错误值,这个方法将会调用闭包,闭包是一个匿名函数,它会将Err的内部值传递给闭包,闭包就会处理对应的值并显示给用户。闭包的参数封装在两个竖线内,一般称之为管道。
我们从标准库中引入了新的模块:process。在闭包中的代码对错误的处理只有两行:显示错误信息,返回错误状态码并退出程序。退出程序并返回错误码使用process::exit函数,这和panic!宏类似,但我们不会在输出多余的信息。
rust
解析参数出错:文件参数不足!
error: process didn't exit successfully: `target\debug\lession14_005.exe` (exit code: 1)
非常棒!这个输出信息就友好多了。
14.3.2 主函数抽离业务逻辑
现在我们完成了配置参数的解析的重构,让我们回到程序的主逻辑中。之前我们将其抽离至run函数中,
我们之前将部分业务逻辑抽离至run函数中,接手了出参数解析和错误处理之外的所有业务处理,之后,主函数就变得足够精简,只需要人工审核就可以验证其正确性。抽离了所有的业务处理之后,就可以写测试程序为这些业务逻辑进行测试。下一步写run函数抽离其他业务。
rust
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config: Config = Config::new(&args);
println!("config={},{}", config.query, config.file_path);
let contents = fs::read_to_string(config.file_path).expect("读取文件失败!");
}
struct Config {
query: String,
file_path: String,
}
impl Config {
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("命令参数不足,命令格式为:程序名称 -- 要搜索的字符串 要搜索的文件名称");
}
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
run函数接收Config类型数据作为参数,然后根据接收到的参数读取文件内容,并显示给用户。
14.3.3.1 从run函数返回错误
有了分拆逻辑到run函数中,我们就可以改进错误处理的方式,正如我们在Config::build函数中所做的,不是调用expect函数返回panic,而是返回Result<T,E>。我们必须坚定使用用户友好的方式处理错误的信念。下面是改进后的run函数签名:
rust
use std::error::Error;
// --snip--
fn run(config: Config) ->Result<(),Box<dyn Error>>{
let contents = fs::read_to_string(config.file_path)?;
println!("文件读取失败");
Ok(())
}
这段代码有三处更改,首先更改了函数的返回值,之前返回了空元祖类型,在函数运行成功时依然返回空元祖类型,但是需要使用Ok封装起来。
错误类型,我们使用特性对象Box<dyn Error>(Error是标准库中的std::error:Error)。它表示函数返回的类型必须实现了Error接口。我们不必指定返回的值类型。这就拥有了非常大的灵活性,可以在不同的错误场景下返回不同的类型。dyn是单词dynamic的缩写。
- 我们使用"?"替换了expect函数的调用,我们之前学习过:?返回错误的值给调用者处理。
- run函数在成功时返回Ok类型的值,在此,它返回空单元类型("()"),这就意味着需要使用Ok封装这个空单元类型。即:Ok(())。第一次看到这个符号可能会很奇怪,但是空单元类型是一个习惯用法,它表明调用run函数只需要它的附带功能,不是真的需要返回一个值。
当执行以上代码,会出现下面的警告信息:
rust
--> src\main.rs:27:5
|
27 | 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
|
27 | let _ = run(config);
| +++++++
上面的信息告诉我们,代码会忽略掉Result值,尽管Result可能正表明一个错误发生。但是代码并没有检查错误是否发生了,编译器提醒我们,我们也许希望有一些代码处理错误。
14.3.3.2 处理返回的错误
让我们处理错误吧,首先检查错误然后处理错误,过程类似Config::build但是还是有些不同的:
rust
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("解析参数出错:{err}");
process::exit(1);
});
println!("要搜索的字符串是:{}",config.query);
println!("要搜素的文件是:{}",config.file_path);
if let Err(e) =run(config){
println!("错误信息是:{e}");
process::exit(1);
};
}
我们使用if let替换unwrap_or_else来检查run函数是否返回了Err值,之后调用process::exit(1)退出程序运行。在成功时run函数返回一个值,它只会返回包裹在Ok中的空单元类型。
if let 和unwrap_or_else接下来的工作是相同的,都是显示错误信息并退出。
14.3.4 将代码分拆到库中
现在这个项目看上去已经很完美了,但是我们还要将main.rs文件的一些代码迁移到lib.rs中。只有这样才能进行代码测试,并且进一步精简main.rs文件。
在lib文件中,我们定义搜索文本的功能,这样其他人也可以调用这个库或者其他场景下调用搜索功能。
首先在lib.rs中定义一个search函数签名,函数内部使用unimplemented!宏,之后会详细说明该函数的签名:
rust
pub fn search<'a>(query:&str,contents:&'a str)->Vec<&'a str> {
unimplemented!();
}
我们使用pub关键字定义search作为库的公共API。现在我们有了库箱,我们可以使用它进行测试。
rust
use lession14_005::search;
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(())
}
在main.rs中使用use引入库箱的search函数,这样在二进制箱中就可以使用该函数了。然后在run函数中,调用search函数,并传入config.query和contents作为参数。run使用一个loop循环来显示出搜素匹配的行。此时可以删除掉main函数中显式query和文件路径的代码了,以便程序只显示搜素结果(如果不发生错误)。
注意搜索会将所有的结果放置在一个向量集合中,然后返回,在这之前不会有任何信息显示。当搜素大型文件时,可能会很慢,因为搜素结果不会立即显示;之后的章节中会讨论如何改善这一缺陷。