完成了最基本的文本文件搜索功能,下一步就是让项目的错误处理更优雅。
欢迎来到我的 Rust 命令行软件开发入门教程第三篇,如果你还没看过上篇文章的内容,欢迎查看。
也欢迎关注这个系列专栏,我将更新更多精彩内容。
1. 初版实现代码拆解
上篇文章结束时,我们实现了grrs的基本功能,给出了第一版实现方案:
use clap::Parser;
/// Search for a pattern in a file and display the lines that contain it.
#[derive(Parser)]
struct Cli {
/// The pattern to look for
pattern: String,
/// The path to the file to read
path: std::path::PathBuf,
}
fn main() {
let args = Cli::parse();
let content = std::fs::read_to_string(&args.path).expect("could not read file");
for line in content.lines() {
if line.contains(&args.pattern) {
println!("{}", line);
}
}
}
我们已经详细讨论过和Cli结构体相关的信息,现在,请大家将注意力集中在main函数上。
在完成参数解析后,我们使用content来储存被查询文件的内容。这个文件读取的过程是怎样的呢?
注意到,我们调用了标准库中的std::fs::read_to_string函数,并且给这个函数传入了&args.path,也就是刚刚我们读取的文件路径。
这里的&符号表示的是引用,因为这里我们无需向函数转移args的所有权。这是rust语言的内存管理机制之一,如果还不是很熟悉的朋友可以再查阅一下官方语言教程。
这里就有个问题:要是用户传入的文件根本不存在,怎么办?显然,这是有可能的。
比如:

目前这个版本,是在完成这个函数之后链式调用了另一个函数:.expect()。这个函数的作用是,如果出现了无法读取的路径,立刻崩溃退出程序。显然,这不算是一种足够优雅的错误处理办法。
2. 理解Result和模式匹配
为能够更好实现错误处理,我们必须理解read_to_string到底返回了什么。实际上,返回的是一个Result<String, Error>结构。
Result<T, E>是rust语言中一种用于辅助错误处理的独特枚举结构,返回的结果既可以是包含成功值的Ok(T),也可以是包含错误信息的Err(E)。
在这里,我们可以使用模式匹配来完成对这个函数结果的处理,比如:
let result = std::fs::read_to_string("nonexistent.txt");
match result {
Ok(content) => { println!("File content: {}", content); }
Err(error) => { println!("Oh noes: {}", error); }
}
就像拆快递一样,模式匹配会拆开Result这个包裹:如果是Ok就取出值,如果是Err就处理错误。如果你之前有过函数式语言的经历,这样的模式匹配语法相信你一定倍感亲切。
利用这种机制,我们可以稍稍改写一下之前的代码。
3. 通过unwrap进行错误处理
再rust中,panic!宏可以让程序立刻崩溃并退出。所以,开头我们的代码也可以这么写:
let result = std::fs::read_to_string("nonexistent.txt");
let content = match result {
Ok(content) => { content },
Err(error) => { panic!("Can't deal with {}, just exit here", error); }
};
println!("file content: {}", content);
简单来说,就是如果Ok()那么我们继续,如果不幸报Err()那么我们立即崩溃退出。
实际上,这还有一种简写版代码:
let content = std::fs::read_to_string("nonexistent.txt").unwrap();
我们把所有这个代码整合进本来的main函数:
fn main() {
let args = Cli::parse();
let content = std::fs::read_to_string("nonexistent.txt").unwrap();
for line in content.lines() {
if line.contains(&args.pattern) {
println!("{}", line);
}
}
}
尝试执行一个不存在的命令,我们会看到:

和我们预计的一样,程序在读取文件失败后立即崩溃退出。
事实上,这也就是为什么大家经常看到提醒,生产环境中一定要慎用unwrap()函数。
除非我们十分确认程序不会崩溃,或者像目前我们的程序一样,崩溃了不会产生太严重的后果,否则尽量不要使用这个函数,还是深思熟虑一下如何进行错误处理吧。
4. 更优雅的无panic错误处理
你可能会说,那这个unwrap还是立刻崩溃退出,没什么区别啊。别着急,铺垫这么多就是为了最后这一步优化的。
既然直接退出不好,而结果可能成功也可能有报错,那我们为什么不也使用一个Result呢?
请看示例代码:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let result = std::fs::read_to_string("nonexistent.txt");
let content = match result {
Ok(content) => { content },
Err(error) => { return Err(error.into()); }
};
println!("file content: {}", content);
Ok(())
}
这里,如果说result结果是报错,我们不会崩溃结束程序,而是返回一个Err(error.into())值。反之,则返回Ok(())。
你可能注意到最后的Ok(())前没有用关键字return。事实上,由于rust中所有由大括号包裹的代码块最后一行都是默认返回值,这里我们可以简写忽略return。
rust需要开发者对所有有返回值的函数进行详细的类型标注。这里,我们看到返回类型是Result<(), Box<dyn std::error::Error>>。
这里的Box<dyn std::error::Error>实际上是一种可以存储任何错误的“盒子”,只要该错误类型实现了Error trait。而main函数中的error.into()实际上在做类型转换,将原本的 Error 转换为我们指定的Box<dyn Error>结构。
如果你不确定这段讨论的细节,没关系,继续往下读,这不影响我们后续的开发。
事实上,和unwrap一样,我们刚刚通过模式匹配完成的错误处理也有一种简写法:
let content = std::fs::read_to_string("nonexistent.txt")?;
对,你没看错,只是加了个问号?就搞定了,这个?就是等同于match模式匹配 +return Err(error.into)的语法糖。是不是很方便呢?
5. 总结
现在,我们将刚刚学会的?上抛错误法整合进项目,你应该得到的完整项目代码是:
use clap::Parser;
/// Search for a pattern in a file and display the lines that contain it.
#[derive(Parser)]
struct Cli {
/// The pattern to look for
pattern: String,
/// The path to the file to read
path: std::path::PathBuf,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Cli::parse();
let content = std::fs::read_to_string(&args.path)?;
for line in content.lines() {
if line.contains(&args.pattern) {
println!("{}", line);
}
}
Ok(())
}
我们尝试一个可以成功执行的搜索命令:

再尝试一个应当报错的命令:

可以看到,之前一直有的程序崩溃提示,现在就不存在了!在错误处理上,我们向前走了坚实的一步。
在rust中,?是传播错误传播的惯用方式。如果你之前有过别的编程语言的经验,你应当发现这种错误处理方式比一般的try...catch...finally甚至if err != nil要方便的多,对,说的就是你,golang。
不知道本文所讲的Result类型和?上抛错误的方式,你是否学会了呢?
到这里,我们会发现出错以后返回的 Error 是Debug格式展示的,对于开发者来说没什么问题,但对终端用户不够友好。
下篇文章,我将聊聊如何让命令行应用的输出结果更优美。
希望这篇博客能帮助到你,也欢迎关注交流。愿与君共勉!