Rust 从 0 到 1- 错误处理 -panic! 还是 Result
那么什么时候应该使用 panic!,什么时候又应该返回 Result 呢?如果使用 panic!,那么程序就没有从错误中恢复的机会了。我们可以选择在发生任何错误时都调用 panic!,不过这样调用我们代码的程序就没有恢复的机会了,不管是否有可能恢复。如果返回 Result 的话,那么调用方就有机会按照实际场景进行选择,可以尝试进行恢复,避免程序终止;也可以就认为错误是不可恢复的,调用 panic!,终止程序(如果是子线程 panic,并不会终止主线程,如果主线程 panic,程序就会终止)。因此,看起来返回 Result 是更好的选择。
但是也有一些情况选择 panic! 比返回 Result 更为合适,不过并不常见。下面我们会讨论为何 panic! 适用于示例、原型代码和测试中。另外,我们还会讨论编译器无法判断是否一定不会失败,但是根据实际应用场景我们可以做出判断的情况。
示例、原型代码和测试
当我们编写示例用来展示一些概念时,健壮的错误处理代码反而有可能会分散焦点,除非我们是想演示如何处理错误。因此,在示例程序里我们通常会使用类似 unwrap 的方法对错误进行处理,它通常是被看作为处理错误的占位符,这样可以让代码更加简洁,帮助我们聚焦于示例程序所要表达的内容。
出于类似的目的,在我们在进行程序的原型设计时,通常会使用 unwrap 和 expect 方法,让我们聚焦于对设计的验证,将对错误的处理放在后面进行补充。他们在代码中也起到占位的作用,当我们对原型设计验证通过,准备完善我们的代码时候可以很容易找到他们。
而当我们在编写测试代码时,如果方法调用发生了错误,我们希望这个测试明确的表明失败,即使这个方法并不是直接需要测试的功能。因为 panic! 就是用来标记测试失败的,因此在这里使用 unwrap 或 expect 也非常适合。
我们比编译器知道更多的时候
虽然从单纯的程序逻辑上,失败是可能的,但是有些时候我们可以“确保” Result 的返回永远是 Ok (编译器还没聪明到可以理解这种“确保”),这种场景下也可以直接使用 unwrap 进行处理。通常我们仍然需要对 Result 进行处理:因为一般我们所调用的方法在通常情况下是会可能失败的,虽然我们通过某种方法在特定场景下保证了其不会调用失败:
上例中我们通过解析一个硬编码的 IP 地址字符串来给一个 IpAddr 类型的变量赋值。可很明显 127.0.0.1 是一个有效的 IP 地址,所以我们在这里直接使用 unwrap 对错误进行处理。虽然我们通过编码一个有效的 IP 字符串保证了解析会成功,但是,并不能改变 parse 方法的返回值类型,即 Result ,因此编译器仍然会要求我们对 Result 进行处理(目前编译器还没有如此智能,可以识别出这个字符串总是一个有效的 IP 地址)。当 IP 地址字符串来源于用户输入而不是像例子中硬编码的话,那么就存在失败的可能,这时我们就需要以一种更健壮的方式对 Result 进行处理。
错误处理的指导原则
在当错误有可能会导致我们的程序进入一种不良状态(bad state)情况下建议执行 panic! 。不良状态是指当一些我们程序的假设、保证、约定或不变性(invariant,这个怎么理解呢?我理解是不管在任何情况下,一些约束条件始终保持不变,譬如两个正数相加一定是正数,如果这个约束被打破了,那我们程序肯定是有问题了)被打破的状态,例如,无效的值、相互矛盾的值或者缺少值的情况等,但是,需要考虑以下几种情况:
不良状态不包括我们已经预期可能会发生的错误
之后代码的运行并不依赖于这种不良状态
没有好的办法将这些信息编码到你使用的类型中(There’s not a good way to encode this information in the types you use,不是太理解,原文放到这里,欢迎大家指教)
如果别人在调用你的代码时传递了一个没有意义的值,最好也是执行 panic! 用于发出警告,告诉调用者他的代码中有 bug ,以便在开发阶段就能提前修复它。反之,同样 panic! 也适用于我们调用在我们控制以外的外部代码时,如果其返回了我们无法进行修复的无效状态。
然而,当我们预期错误会出现时,返回 Result 要比调用 panic! 更为合适。譬如,解析器接收到错误格式的数据,或者 HTTP 请求返回了触发限流的状态等等。在这些场景中,应该使用 Result 进行返回,把错误向上进行传播,将如何处理这个错误交由调用者决定。
当我们对值进行操作时,应该首先验证值的有效性,并在其无效时 panic!。这么做主要是出于安全的考虑:尝试对无效的数据进行操作可能会暴露代码潜在的缺陷。也是出于安全的考虑,当我们尝试越界访问数组时标准库会调用 panic! :因为尝试访问不属于当前数据的内存空间是常见的一种安全隐患。函数通常会遵守以下约定:只有在输入的参数满足预期条件时,输出结果才能得到保证。否则,panic ,因为这通常代表调用方的代码出现 bug,并且也不是前面我们说的需要交由调用方决定如何处理的错误。实际上,对于调用方来说,通常也无法对错误进行恢复,要对代码进行修复才能解决(我们日常经常会碰到因为没有考虑到某种情况导致了程序报错,也就是 bug,需要对程序进行修复)。而约定应该在函数的 API 文档中得到解释,特别是会造成 panic 的情况。
在所有函数中都要进行许多错误场景的检查,想起来是比较繁琐的。幸运的是,我们可以利用 Rust 的类型系统(以及编译器的类型检查)为我们分担一些工作。如果我们的函数指定了参数类型,那么编译器会确保其拥有一个有效的符合我们指定类型的值。因此,假如我们指定了一种参数类型(非 Option 类型),而且我们预期它始终是有值的。那么我们的代码无需考虑值为空的情况,它只会有一种情况就是有值。尝试向函数传递空值的代码是无法编译通过的(这和很多其它语言不同),所以我们在代码中也就无需再做非空判断。另外再举一个例子,譬如,像 u32 这样的无符号整型,Rust 同样也会确保它永远不会为负数。
使用自定义类型进行验证
下面我们将通过尝试创建一个自定义类型,来利用 Rust 这种类型检查的思想进一步确保参数的有效性。假设,我们的代码要求用户猜测一个 1 到 100 之间的数字,再将其与一个秘密数字做比较。如果我们把参数定义为一个 u32 ,Rust 会帮我们验证它是否为正,但是并不会帮我们验证是否位于 1 到 100 之间。在这种情况下,即使我们不做后面的验证,其影响也并不会很严重:我们的输出结果“高了” 或 “低了” 看起来仍然是正确的,但是引导用户进行有效的猜测可以提升用户体验,例如当用户猜测一个超出范围的数字或者输入字母给出提醒。下面我们首先尝试在代码逻辑中进行主动检查:
上例中,我们在循环中使用 if 表达式检查了输入值是否超出我们预期的范围,并打印提示信息,然后调用 continue 进入下一次循环。然而,这并不是一个理想的解决方案:如果这个检查很重要,并且在很多函数中都要做检查,那么这样做将显得非常繁琐,后续维护也不方便(同时还可能存在潜在的性能问题)。
还有另外一种方法,我们可以创建一个新类型,并将验证逻辑放入创建其实例的函数中,而不是在代码中重复这些检查。这样就可以在代码逻辑中放心的使用它了:
上例中,我们定义了一个包含 i32 类型字段的结构体 Guess,用来存储用户猜测的数字。接着我们在 Guess 上实现了一个叫做 new 的关联函数来创建 Guess 实例。我们在 new 函数的里确保了其值是在 1 到 100 之间的,否则将调用 panic! 警告调用方有一个需要修改的 bug,因为其数值超出了 Guess 所允许的范围。同时,Guess::new 的 panic 的条件应该写在其 API 文档中。(后面章节会介绍 API 文档中说明 panic! 可能性的相关规则)。接着,我们实现 value 方法,它只有 self 的引用作为参数,除此之外没有任何其他参数,它返回一个 i32 类型的值。这类方法有时被称为 getter,因为它的目的就是返回对应字段的数据,因为 value 是私有的,这样的公有方法是必要的;同时由于 Guess 包含的 value 字段是私有的,不允许外部代码(模块之外)直接设置 value 的值,而必须使用 Guess::new 方法,这就确保了必须通过 Guess::new 函数的检查。
官方文档的这个例子我觉得举的不太好,因为虽然 Guess 确保了自身的正确性,但是为了避免 panic 我们还是要在外部对输入做检查,我觉得在这里可能返回一个 Result 可能会更好,这个应该属于我们预期会发生的错误,属于可恢复错误,让用户再猜一次就好了,不过如果把这个仅仅看做是“示例”,那么 panic 也是合适的;)。
总结
Rust 的错误处理机制目的也是为了帮助我们编写更加健壮的代码。panic! 宏代表程序进入无法继续正常处理的状态,它会停止程序而不是继续使用无效或不正确的值进行处理,从而避免对外暴露潜在的缺陷。而 Result 代表失败是我们预期可能会发生的,并且是可以恢复的。我们可以使用 Result 来告诉上层代码,他们需要对预期可能的错误进行处理。正确的选择使用 panic! 和 Result 将会使我们的代码变得更加可靠。
版权声明: 本文为 InfoQ 作者【山】的原创文章。
原文链接:【http://xie.infoq.cn/article/ecdd953fdffc0bb3333b5ee06】。文章转载请联系作者。
评论