trpl-zh-cn/src/ch09-03-to-panic-or-not-to-panic.md
2017-03-23 22:37:22 +08:00

11 KiB
Raw Blame History

panic!还是不panic!

ch09-03-to-panic-or-not-to-panic.md
commit 3f2a1bd8dbb19cc48b210fc4fb35c305c8d81b56

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

有一些情况 panic 比返回Result更为合适,不过他们并不常见。让我们讨论一下为何在示例、代码原型和测试中,以及那些人们认为不会失败而编译器不这么看的情况下, panic 是合适的,最后会总结一些在库代码中如何决定是否要 panic 的通用指导原则。

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

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

类似的,unwrapexpect方法在原型设计时非常方便,在你决定该如何处理错误之前。他们在代码中留下了明显的记号,以便你准备使程序变得更健壮时作为参考。

如果方法调用在测试中失败了,我们希望这个测试都失败,即便这个方法并不是需要测试的功能。因为panic!是测试如何被标记为失败的,调用unwrapexpect都是非常有道理的。

当你比编译器知道更多的情况

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

use std::net::IpAddr;

let home = "127.0.0.1".parse::<IpAddr>().unwrap();

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

错误处理指导原则

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

  • 有害状态并不包含预期会偶尔发生的错误
  • 之后的代码的运行依赖于不再处于这种有害状态
  • 当没有可行的手段来将有害状态信息编码进可用的类型中(并返回)的情况

如果别人调用你的代码并传递了一个没有意义的值,最好的情况也许就是panic!并警告使用你的库的人他的代码中有 bug 以便他能在开发时就修复它。类似的,panic!通常适合调用不能够控制的外部代码时,这时无法修复其返回的无效状态。

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

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

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

创建自定义类型作为验证

让我们借用 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
}

if表达式检查了值是否超出范围,告诉用户出了什么问题,并调用continue开始下一次循环,请求另一个猜测。if表达式之后,就可以在知道guess在 1 到 100 之间的情况下与秘密数字作比较了。

然而,这并不是一个理想的解决方案:程序只处理 1 到 100 之间的值是绝对不可取的,而且如果有很多函数都有这样的要求,在每个函数中都有这样的检查将是非常冗余的(并可能潜在的影响性能)。

相反我们可以创建一个新类型来将验证放入创建其实例的函数中,而不是到处重复这些检查。这样就可以安全的在函数签名中使用新类型并相信他们接收到的值。列表 9-8 中展示了一个定义Guess类型的方法,只有在new函数接收到 1 到 100 之间的值时才会创建Guess的实例:

struct Guess {
    value: u32,
}

impl Guess {
    pub fn new(value: u32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess {
            value: value,
        }
    }

    pub fn value(&self) -> u32 {
        self.value
    }
}

Listing 9-8: A Guess type that will only continue with values between 1 and 100

首先,我们定义了一个包含u32类型字段value的结构体Guess。这里是储存猜测值的地方。

接着在Guess上实现了一个叫做new的关联函数来创建Guess的实例。new定义为接收一个u32类型的参数value并返回一个Guessnew函数中代码的测试确保了其值是在 1 到 100 之间的。如果value没有通过测试则调用panic!,这会警告调用这个函数的程序员有一个需要修改的 bug因为创建一个value超出范围的Guess将会违反Guess::new所遵循的契约。Guess::new会出现 panic 的条件应该在其公有 API 文档中被提及;第十四章会涉及到在 API 文档中表明panic!可能性的相关规则。如果value通过了测试,我们新建一个Guess,其字段value将被设置为参数value的值,接着返回这个Guess

接着,我们实现了一个借用了self的方法value,它没有任何其他参数并返回一个u32。这类方法有时被称为 getter,因为它的目的就是返回对应字段的数据。这样的公有方法是必要的,因为Guess结构体的value字段是私有的。私有的字段value是很重要的,这样使用Guess结构体的代码将不允许直接设置value的值:调用者必须使用Guess::new方法来创建一个Guess的实例,这就确保了不会存在一个value没有通过Guess::new函数的条件检查的Guess

如此获取一个参数并只返回 1 到 100 之间数字的函数就可以声明为获取或返回一个Guess,而不是u32,同时其函数体中也无需进行任何额外的检查。

总结

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

现在我们已经见识过了标准库中OptionResult泛型枚举的能力了,让我们聊聊泛型是如何工作的,以及如果在你的代码中利用他们。