完成了最基本的文本文件搜索功能,下一步就是让项目的错误处理更优雅

欢迎来到我的 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格式展示的,对于开发者来说没什么问题,但对终端用户不够友好。

下篇文章,我将聊聊如何让命令行应用的输出结果更优美


希望这篇博客能帮助到你,也欢迎关注交流。愿与君共勉!

点击阅读下一篇文章