Rust 错误处理(下)

目录

[1、用 Result 处理可恢复的错误](#1、用 Result 处理可恢复的错误)

[1.1?传播错误的简写:? 运算符](#1.1?传播错误的简写:? 运算符)

[1.2 哪里可以使用 ? 运算符](#1.2 哪里可以使用 ? 运算符)

[2、要不要 panic!](#2、要不要 panic!)

[2.1?示例、代码原型和测试都非常适合 panic](#2.1?示例、代码原型和测试都非常适合 panic)

2.2?当我们比编译器知道更多的情况

2.3?错误处理指导原则

2.4?创建自定义类型进行有效性验证

[2.5 总结](#2.5 总结)


1、用 Result 处理可恢复的错误

1.1传播错误的简写: 运算符

先看下如下示例:

复制代码
fn main() {
    fn read_file() -> Result<String, io::Error> {
        let file_result = File::open("hello.txt");
        let mut v = String::new();
        file_result.unwrap().read_to_string(&mut v)?;
        Ok(v)
    }
    let res = read_file();
    print!("{:?}", res)
}

Result值之后的?被定义为之前的处理Result值的match表达式有着完全相同的工作方式。如果Result的值是Ok,这个表达式将会返回Ok中的值而程序将继续执行。如果值是ErrErr将作为整个函数的返回值,就好像使用了return关键字一样,这样错误值就被传播给了调用者。

之前示例的match表达式与?运算符所做的有一点不同:?运算符所使用的错误值被传递给了from函数,它定义于标准库的Fromtrait 中,其用来将错误从一种类型转换为另一种类型。当?运算符调用from函数时,收到的错误类型被转换为由当前函数返回类型所指定的错误类型。这在当函数返回单个错误类型来代表所有可能失败的方式时很有用,即使其可能会因很多种原因失败。

?运算符消除了大量样板代码并使得函数的实现更简单。我们甚至可以在?之后直接使用链式方法调用来进一步缩短代码,如下所示:

复制代码
fn main() {
    fn read_file() -> Result<String, io::Error> {
        let mut v = String::new();
        let _ = File::open("hello.txt")?.read_to_string(&mut v)?;
        Ok(v)
    }
    let res = read_file();
    print!("{:?}", res)
}

以下代码展示了一个使用fs::read_to_string的更为简短的写法:

复制代码
fn main() {
    fn read_file() -> Result<String, io::Error> {
        fs::read_to_string("hello1.txt")
    }
    let res = read_file();
    print!("{:?}", res)
}

将文件读取到一个字符串是相当常见的操作,所以 Rust 提供了名为fs::read_to_string的函数,它会打开文件、新建一个String、读取文件的内容,并将内容放入String,接着返回它。当然,这样做就没有展示所有这些错误处理的机会了.

1.2 哪里可以使用 运算符

?运算符只能被用于返回值与?作用的值相兼容的函数。因为?运算符被定义为从函数中提早返回一个值,这与之前示例的match表达式有着完全相同的工作方式。示例中match作用于一个Result值,提早返回的分支返回了一个Err(e)值。函数的返回值必须是Result才能与这个return相兼容。

让我们看看在返回值不兼容的main函数中使用?运算符会得到什么错误:

复制代码
fn main() {
    fs::read_to_string("hello1.txt")?;
}

这段代码打开一个文件,这可能会失败。?运算符作用于File::open返回的Result值,不过main函数的返回类型是()而不是Result。当编译这些代码,会得到如下错误信息:

这个错误指出只能在返回Result或者其它实现了FromResidual的类型的函数中使用?运算符。

为了修复这个错误,有两个选择。一个是,如果没有限制的话将函数的返回值改为Result<T, E>。另一个是使用matchResult<T, E>的方法中合适的一个来处理Result<T, E>

错误信息也提到?也可用于Option<T>值。如同对Result使用?一样,只能在返回Option的函数中对Option使用?。在Option<T>上调用?运算符的行为与Result<T, E>类似:如果值是None,此时None会从函数中提前返回。如果值是SomeSome中的值作为表达式的返回值同时函数继续。

文本中返回第一行最后一个字符的函数的例子:

复制代码
    fn last_char_of_first_line(text: &str) -> Option<char> {
        text.lines().next()?.chars().last()
    }

这个函数返回Option<char>因为它可能会在这个位置找到一个字符,也可能没有字符。这段代码获取text字符串 slice 作为参数并调用其lines方法,这会返回一个字符串中每一行的迭代器。因为函数希望检查第一行,所以调用了迭代器next来获取迭代器中第一个值。如果text是空字符串,next调用会返回None,此时我们可以使用?来停止并从last_char_of_first_line返回None。如果text不是空字符串,next会返回一个包含text中第一行的字符串 slice 的Some值。

?会提取这个字符串 slice,然后可以在字符串 slice 上调用chars来获取字符的迭代器。我们感兴趣的是第一行的最后一个字符,所以可以调用last来返回迭代器的最后一项。这是一个Option,因为有可能第一行是一个空字符串,例如text以一个空行开头而后面的行有文本,像是" hi"。不过,如果第一行有最后一个字符,它会返回在一个Some成员中。?运算符作用于其中给了我们一个简洁的表达这种逻辑的方式。如果我们不能在Option上使用?运算符,则不得不使用更多的方法调用或者match表达式来实现这些逻辑。

注意你可以在返回Result的函数中对Result使用?运算符,可以在返回Option的函数中对Option使用?运算符,但是不可以混合搭配。?运算符不会自动将Result转化为Option,反之亦然;在这些情况下,可以使用类似Resultok方法或者Optionok_or方法来显式转换。

目前为止,我们所使用的所有main函数都返回()main函数是特殊的因为它是可执行程序的入口点和退出点,为了使程序能正常工作,其可以返回的类型是有限制的。

幸运的是main函数也可以返回Result<(), E>,以下示例main的返回值为Result<(), Box<dyn Error>>并在结尾增加了一个Ok(())作为返回值。这段代码可以编译:

复制代码
fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello1.txt")?;
    Ok(())
}

Box<dyn Error>类型是一个trait 对象trait object

目前可以将Box<dyn Error>理解为 "任何类型的错误"。在返回Box<dyn Error>错误类型main函数中对Result使用?是允许的,因为它允许任何Err值提前返回。即便main函数体从来只会返回std::io::Error错误类型,通过指定Box<dyn Error>,这个签名也仍是正确的,甚至当main函数体中增加更多返回其他错误类型的代码时也是如此。

main函数返回Result<(), E>,如果main返回Ok(())可执行程序会以0值退出,而如果main返回Err值则会以非零值退出;成功退出的程序会返回整数0,运行错误的程序会返回非0的整数。Rust 也会从二进制程序中返回与这个惯例相兼容的整数。

main函数也可以返回任何实现了std::process::Termination?trait的类型,它包含了一个返回ExitCodereport函数。

2、要不要 panic!

那么,该如何决定何时应该panic!以及何时应该返回Result呢?如果代码 panic,就没有恢复的可能。你可以选择对任何错误场景都调用panic!,不管是否有可能恢复,不过这样就是你代替调用者决定了这是不可恢复的。选择返回Result值的话,就将选择权交给了调用者,而不是代替他们做出决定。调用者可能会选择以符合他们场景的方式尝试恢复,或者也可能干脆就认为Err是不可恢复的,所以他们也可能会调用panic!并将可恢复的错误变成了不可恢复的错误。因此返回Result是定义可能会失败的函数的一个好的默认选择。

在一些类似示例、原型代码(prototype code)和测试中,panic 比返回Result更为合适,不过它们并不常见。让我们讨论一下为何在示例、代码原型和测试中,以及那些人们认为不会失败而编译器不这么看的情况下,panic 是合适的。

2.1示例、代码原型和测试都非常适合 panic

当你编写一个示例来展示一些概念时,在拥有健壮的错误处理代码的同时也会使得例子不那么明确。例如,调用一个类似unwrap这样可能panic!的方法可以被理解为一个你实际希望程序处理错误方式的占位符,它根据其余代码运行方式可能会各不相同。

类似地,在我们准备好决定如何处理错误之前,unwrapexpect方法在原型设计时非常方便。当我们准备好让程序更加健壮时,它们会在代码中留下清晰的标记。

如果方法调用在测试中失败了,我们希望这个测试都失败,即便这个方法并不是需要测试的功能。因为panic!会将测试标记为失败,此时调用unwrapexpect是恰当的。

2.2当我们比编译器知道更多的情况

当你有一些其他的逻辑来确保Result会是Ok值时,调用unwrap或者expect也是合适的,虽然编译器无法理解这种逻辑。你仍然需要处理一个Result值:即使在你的特定情况下逻辑上是不可能的,你所调用的任何操作仍然有可能失败。如果通过人工检查代码来确保永远也不会出现Err值,那么调用unwrap也是完全可以接受的,这里是一个例子:

复制代码
fn main() {
    let home: IpAddr = "127.0.0.1111".parse().expect("IP address error......");
    panic!("{:?}", home)
}

我们通过解析一个硬编码的字符来创建一个IpAddr实例。可以看出127.0.0.1是一个有效的 IP 地址,所以这里使用expect是可以接受的。然而,拥有一个硬编码的有效的字符串也不能改变parse方法的返回值类型:它仍然是一个Result值,而编译器仍然会要求我们处理这个Result,好像还是有可能出现Err成员那样。这是因为编译器还没有智能到可以识别出这个字符串总是一个有效的 IP 地址。如果 IP 地址字符串来源于用户而不是硬编码进程序中的话,那么就确实 有失败的可能性,这时就绝对需要我们以一种更健壮的方式处理Result了。提及这个 IP 地址是硬编码的假设会促使我们将来把expect替换为更好的错误处理,我们应该从其它代码获取 IP 地址。

2.3错误处理指导原则

在当有可能会导致有害状态的情况下建议使用panic!------ 在这里,有害状态是指当一些假设、保证、协议或不可变性被打破的状态,例如无效的值、自相矛盾的值或者被传递了不存在的值 ------ 外加如下几种情况:

  • 有害状态是非预期的行为,与偶尔会发生的行为相对,比如用户输入了错误格式的数据。
  • 在此之后代码的运行依赖于不处于这种有害状态,而不是在每一步都检查是否有问题。
  • 没有可行的手段来将有害状态信息编码进所使用的类型中的情况。

如果别人调用你的代码并传递了一个没有意义的值,尽最大可能返回一个错误,如此库的用户就可以决定在这种情况下该如何处理。然而在继续执行代码是不安全或有害的情况下,最好的选择可能是调用panic!并警告库的用户他们的代码中有 bug,这样他们就会在开发时进行修复。类似的,如果你正在调用不受你控制的外部代码,并且它返回了一个你无法修复的无效状态,那么panic!往往是合适的。

然而当错误预期会出现时,返回Result仍要比调用panic!更为合适。这样的例子包括解析器接收到格式错误的数据,或者 HTTP 请求返回了一个表明触发了限流的状态。在这些例子中,应该通过返回Result来表明失败预期是可能的,这样将有害状态向上传播,调用者就可以决定该如何处理这个问题。使用panic!来处理这些情况就不是最好的选择。

当你的代码在进行一个使用无效值进行调用时可能将用户置于风险中的操作时,代码应该首先验证值是有效的,并在其无效时panic!。这主要是出于安全的原因:尝试操作无效数据会暴露代码漏洞,这就是标准库在尝试越界访问数组时会panic!的主要原因:尝试访问不属于当前数据结构的内存是一个常见的安全隐患。函数通常都遵循契约contracts ):它们的行为只有在输入满足特定条件时才能得到保证。当违反契约时 panic 是有道理的,因为这通常代表调用方的 bug,而且这也不是那种你希望所调用的代码必须处理的错误。事实上所调用的代码也没有合理的方式来恢复,而是需要调用方的程序员修复其代码。函数的契约,尤其是当违反它会造成 panic 的契约,应该在函数的 API 文档中得到解释。

虽然在所有函数中都拥有许多错误检查是冗长而烦人的。幸运的是,可以利用 Rust 的类型系统(以及编译器的类型检查)为你进行很多检查。如果函数有一个特定类型的参数,可以在知晓编译器已经确保其拥有一个有效值的前提下进行你的代码逻辑。例如,如果你使用了一个并不是Option的类型,则程序期望它是有值 的并且不是空值 。你的代码无需处理SomeNone这两种情况,它只会有一种情况就是绝对会有一个值。尝试向函数传递空值的代码甚至根本不能编译,所以你的函数在运行时没有必要判空。另外一个例子是使用像u32这样的无符号整型,也会确保它永远不为负。

2.4创建自定义类型进行有效性验证

让我们使用 Rust 类型系统的思想来进一步确保值的有效性,并尝试创建一个自定义类型以进行验证。回忆一下第二章的猜猜看游戏,我们的代码要求用户猜测一个 1 到 100 之间的数字,在将其与秘密数字做比较之前我们从未验证用户的猜测是位于这两个数字之间的,我们只验证它是否为正。在这种情况下,其影响并不是很严重:"Too high" 或 "Too low" 的输出仍然是正确的。但是这是一个很好的引导用户得出有效猜测的辅助,例如当用户猜测一个超出范围的数字或者输入字母时采取不同的行为。

一种实现方式是将猜测解析成i32而不仅仅是u32,来默许输入负数,接着检查数字是否在范围内:

复制代码
 loop {
        // --snip--

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
    }

2.5 总结

Rust 的错误处理功能被设计为帮助你编写更加健壮的代码。panic!宏代表一个程序无法处理的状态,并停止执行而不是使用无效或不正确的值继续处理。Rust 类型系统的Result枚举代表操作可能会在一种可以恢复的情况下失败。可以使用Result来告诉代码调用者他需要处理潜在的成功或失败。在适当的场景使用panic!Result将会使你的代码在面对不可避免的错误时显得更加可靠。

相关推荐
hujinyuan201604 分钟前
2025年12月中国电子学会青少年机器人技术等级考试试卷(二级) 真题+答案
人工智能·算法·机器人
摇滚侠1 小时前
SpringMVC 入门到实战 文件上传 75-77
java·后端·spring·maven·intellij-idea
GIS数据转换器1 小时前
城市排水生命线安全运行监测平台深度解析
java·运维·人工智能·python·安全·数据挖掘·无人机
bIo7lyA8v1 小时前
算法复杂度评估的实验统计方法与可视化的技术8
算法
李老师讲编程1 小时前
中国电子学会图形化2020.12月Scratch三级考级题
算法·scratch·信息学奥赛·图形化编程·scratch素材
华如锦2 小时前
面了很多 Java转AI Agent方向,一些面试题总结
java·开发语言·人工智能·python·ai
睡不醒男孩0308232 小时前
CLup 6.x 版本中针对StarRocks 存算一体集群的完整操作手册
java·服务器·网络·clup
退休倒计时2 小时前
【每日一题】LeetCode 53. 最大子数组和 TypeScript
数据结构·算法·leetcode·typescript
旖-旎2 小时前
FloodFill(图像渲染)(1)
c++·算法·深度优先·力扣