9.3panic!最佳实践
panic! 还是不panic!
那么,如何决定何时调用 panic!,何时返回 Result 呢?当代码发生 panic 时,是无法恢复的。你可以在任何错误情况下调用 panic!,无论是否有可能恢复,但这样就是代表调用方代码做出了该情况不可恢复的决定。当你选择返回 Result 值时,你给了调用方代码更多选项。调用方可以根据自身情况尝试恢复,也可以认为 Err 值是不可恢复的,从而调用 panic! 将你的可恢复错误变成不可恢复。因此,当你定义一个可能失败的函数时,返回 Result 是一个很好的默认选择。
在示例、原型代码和测试等场景中,更适合写出会导致 panic 的代码,而不是返回 Result。接下来我们将探讨原因,然后讨论编译器无法判断失败不可能发生但你作为人类能判断的情况。本章最后会总结一些关于库代码中如何决定是否使用 panic 的通用指导原则。
示例、原型代码和测试
当你写示例来说明某个概念时,同时包含健壮的错误处理逻辑反而会让示例变得不清晰。在示例中,可以理解对 unwrap 这类可能引发 panic 的方法的调用,只是表示应用程序未来希望以某种方式处理错误,这种方式因其他部分代码不同而异。
同样,在原型设计阶段还未准备好确定如何处理错误之前,unwrap 和 expect 方法非常方便。它们在你的代码里留下明确标记,以便日后增强程序鲁棒性。
如果测试中的某个方法调用失败,即使该方法本身不是被测功能,也希望整个测试失败。而因为通过 panic! 标记测试失败,所以直接调用 unwrap 或 expect 就是正确做法。
比编译器更了解信息的情况
当你拥有其他逻辑保证 Result 一定为 Ok,但编译器无法理解这些逻辑时,也适合使用 unwrap 或 expect。此时仍然需要处理 Result:所调操作一般来说仍有失败可能,但就你的具体情形而言这是不可能出现 Err。如果通过人工检查确认永远不会得到 Err 变体,那么使用 unwrap 是完全合理的,更好的是在 expect 文本中注明为何认为不会出现 Err。例如:
use std::net::IpAddr;let home: IpAddr = "127.0.0.1".parse().expect("硬编码 IP 地址应有效");
这里我们通过解析硬编码字符串创建了一个 IpAddr 实例。“127.0.0.1”显然是有效 IP,因此用 expect 合理。然而,硬编码有效字符串并没有改变 parse 方法的返回类型:依旧是 Result 类型,并且编译器仍要求我们像面对潜在 Err 一样去处理,因为它不能智能地识别这个字符串总是合法 IP。如果 IP 字符串来自用户输入,有失败风险,我们肯定要更稳妥地处理结果。提及“IP 地址为硬编码”的假设,会提醒我们若将来改从其它来源获取地址,应把 expect 替换成更完善的错误处理逻辑。
错误处理指南
当代码可能进入不良状态时,建议让代码触发 panic。在此上下文中,不良状态指的是某些假设、保证、契约或不变量被破坏,例如传入了无效值、矛盾值或缺失值——并且满足以下一项或多项条件:
- 不良状态是意料之外的,而非偶尔发生的情况,比如用户输入格式错误的数据。
- 之后的代码需要依赖于未处于该不良状态,而不是在每一步都检查问题。
- 无法通过所用类型编码这些信息。我们将在第18章“将状态和行为编码为类型”中举例说明。
如果有人调用你的代码并传入无意义的值,最好返回错误,让库使用者决定如何处理。但在继续执行可能导致安全风险或损害时,最佳选择是调用 panic! 并提醒库使用者修复其开发中的 bug。同样,如果你调用外部不可控代码且它返回无法修复的无效状态,也常适合调用 panic!。
然而,当失败是预期内时,更适合返回 Result 而非 panic!。例如解析器接收到格式错误数据,或者 HTTP 请求返回表示达到速率限制的状态码。这种情况下,返回 Result 表明失败是一种预期可能性,由调用方决定如何处理。
当你的代码执行操作,如果使用无效参数会使用户面临风险,应先验证参数有效性,无效则触发 panic。这主要出于安全考虑:对无效数据操作可能引发漏洞。这也是标准库尝试越界访问内存时会触发 panic 的主要原因,因为访问不属于当前数据结构的内存是常见安全问题。
函数通常有契约:只有输入满足特定要求,其行为才有保障。当违反契约时触发 panic 是合理的,因为这总表明调用方存在 bug,这类错误无需由调用方显式处理。实际上,没有合理方式让调用方恢复;程序员需修正代码。函数契约(尤其违约会导致 panic)应在 API 文档中明确说明。
不过,在所有函数里大量进行错误检查既冗长又烦人。幸运的是,你可以利用 Rust 类型系统(及编译器做出的类型检查)完成许多校验。如果函数参数具有特定类型,可以放心地按逻辑继续,因为编译器已确保获得有效值。例如,有具体类型而非 Option 时,程序期待一定有值,无需分别处理 Some 和 None 两种情况;尝试传递空值根本无法编译,因此运行时无需检测。另外,如 u32 等无符号整数确保参数永远不会为负数。
自定义验证类型
进一步利用 Rust 类型系统确保有效值,我们来看创建自定义验证类型示例。回想第2章猜数字游戏,我们从未验证用户猜测是否介于1到100之间,只确认猜测为正数。在这种情况下后果不严重:“太大”或“太小”的输出仍然正确。但若能引导用户作出合法猜测,并区分超范围数字与字母等非法输入,则更佳。
一种方法是将猜测解析成 i32 而非仅 u32,以允许负数,然后添加范围检查,如下:
文件名: src/main.rs
loop {// --省略--let guess: i32 = match guess.trim().parse() {Ok(num) => num,Err(_) => continue,};if guess < 1 || guess > 100 {println!("秘密数字必须介于1到100之间");continue;}match guess.cmp(&secret_number) {// --省略--}
}
if 表达式判断是否超出范围,并提示用户,再用 continue 开始下一轮循环请求新输入。在 if 后可安心比较 guess 与 secret_number,因为已知其必在1至100间。
但这方案并不理想:若程序绝对要求只接受1至100间数且多个函数均需此限制,每个函数都写类似校验既繁琐又影响性能。
相反,可新建模块定义新类型,将校验封装进构造实例的方法,从而避免重复校验,使得各函数签名直接使用该新类型即可放心使用其中数值。如清单9-13所示,实现一个 Guess 类型,仅当 new 函数接收介于1至100间的数才创建实例:
文件名: src/guessing_game.rs
pub struct Guess {value: i32,
}impl Guess {pub fn new(value: i32) -> Guess {if value < 1 || value > 100 {panic!("Guess 值必须介于 1 到 100 ,实际得到 {value} 。");}Guess { value }}pub fn value(&self) -> i32 {self.value}
}
清单9-13:仅允许取值位于1到100之间的 Guess 类型
首先创建 guessing_game 模块,然后定义包含私有字段 value (i32) 的结构体 Guess,用以存储数字;
随后实现关联函数 new 接受一个 i32 参数并返回一个 Guess 实例,该方法内部检验参数是否落在[1, 100]区间,否则触发panic!提醒开发者修复bug,因为构造超限Guess违反了new方法依赖之契约;new 方法潜在panic情形应记录在公共API文档中(第14章将介绍标注panic可能性的文档规范)。若通过测试,则生成带指定value字段的新Guess实例并返回;
再实现 getter 方法 value 返回内部私有字段value,此公开接口用于获取数据,同时防止外部直接修改value字段,从而强制外部只能通过Guess::new来创建实例保证合法性;
拥有只接受和产生限定范围(如[1, 100])数字的新型别后,相应功能可声明签名采用Guess替代i32,无须额外重复校验逻辑,提高安全性与简洁度。
总结
Rust 的错误处理功能旨在帮助你编写更健壮的代码。panic! 宏表示程序处于无法处理的状态,并允许你指示进程停止,而不是尝试继续执行无效或错误的值。Result 枚举利用 Rust 的类型系统来表明操作可能失败,但你的代码可以从中恢复。你可以使用 Result 告诉调用你的代码的部分需要处理潜在的成功或失败。在适当情况下使用 panic! 和 Result 会使你的代码在面对不可避免的问题时更加可靠。现在,你已经看到了标准库如何通过 Option 和 Result 枚举使用泛型,我们将讨论泛型是如何工作的,以及你如何在代码中使用它们。