开发环境
- Windows 10
- Rust 1.68.0
- VS Code 1.76.2
项目工程
这里继续沿用上次工程
rust
-demo
panic!还是不要panic!
那么,你如何决定何时应该调用
panic!
以及何时应该返回
Result?
当代码失败时,没有办法恢复。你可以在任何错误情况下调用
panic!
,无论是否有可能恢复,但你是代表调用代码来决定一个情况是不可恢复的。当你选择返回一个
Result
值时,你给了调用代码一些选择。调用代码可以选择尝试以适合其情况的方式进行恢复,或者它可以决定这种情况下的
Err
值是不可恢复的,所以它可以调用
panic!
并将你的可恢复错误变成不可恢复的错误。因此,当你定义一个可能失败的函数时,返回
Result
是一个很好的默认选择。
在诸如例子、原型代码和测试等情况下,写出崩溃而不是返回
Result
的代码更为合适。让我们来探讨一下原因,然后讨论在哪些情况下编译器无法判断失败是不可能的,但作为人类的你可以。本章最后将介绍一些关于如何决定是否在库代码中进行恐慌的一般准则。
示例、原型代码和测试
当你写一个例子来说明一些概念时,也包括健壮的错误处理代码会使这个例子不那么清晰。在例子中,我们可以理解为,对像
unwrap
这样的方法的调用可能会引起恐慌,这是你希望你的应用程序处理错误的方式的一个占位符,它可以根据你的其他代码的做法而有所不同。
同样,在你准备决定如何处理错误之前,
unwrap
和
expect
方法在原型设计时是非常方便的。它们在你的代码中留下了清晰的标记,以便你准备使你的程序更加健壮。
如果在测试中一个方法调用失败,你会希望整个测试都失败,即使这个方法不是被测试的功能。因为
panic!
是一个测试被标记为失败的方式,调用
unwrap
或
expect
正是应该发生的。
你比编译器拥有更多信息的案例
当你有一些其他的逻辑来确保结果会有一个
Ok
值时,调用
unwrap
或
expect
也是合适的,但这个逻辑不是编译器所能理解的东西。你仍然有一个需要处理的
Result
值:无论你调用的是什么操作,在一般情况下仍然有失败的可能,尽管在你的特定情况下逻辑上是不可能的。如果你能通过手动检查代码来确保永远不会出现
Err
变量,那么调用
unwrap
是完全可以接受的,甚至最好在
expect
文本中记录你认为永远不会出现
Err
变量的原因。这里有一个例子。
use std::net::IpAddr;
let home: IpAddr = "127.0.0.1"
.parse()
.expect("Hardcoded IP address should be valid");
我们正在通过解析一个硬编码的字符串来创建一个
IpAddr
实例。我们可以看到
127.0.0.1
是一个有效的 IP 地址,所以在这里使用
expect
是可以接受的。然而,有一个硬编码的有效字符串并没有改变
parse
方法的返回类型:我们仍然得到一个
Result
值,而且编译器仍然会让我们把这个
Result
当作
Err
变量来处理,因为编译器还没有聪明到可以看出这个字符串总是一个有效的IP地址。如果这个IP地址字符串来自用户,而不是硬编码到程序中,因此确实有失败的可能,我们肯定会想用一种更稳健的方式来处理
Result
。提到这个IP地址是硬编码的假设将促使我们改变
expect
,以更好地处理错误的代码,如果将来我们需要从其他来源获得IP地址的话。
错误处理指南
当你的代码有可能结束在一个坏的状态时,建议让你的代码恐慌。在这里,坏状态是指一些假设、保证、契约或不变性被破坏,比如无效的值、矛盾的值或缺失的值被传递给你的代码,再加上以下一个或多个。
- 不良的状态是出乎意料的事情,而不是可能偶尔发生的事情,比如用户以错误的格式输入数据。
- 你的代码在这一点上需要依靠不处于这种坏状态,而不是在每一步都检查问题。
- 在你使用的类型中没有一个好的方法来编码这些信息。我们将在第后面章节的 “将状态和行为编码为类型 “一节中通过一个例子来说明我们的意思。
如果有人调用你的代码并传入一些不合理的值,如果可以的话,最好是返回一个错误,这样库的用户就可以决定在这种情况下他们要做什么。然而,在继续可能是不安全的或有害的情况下,最好的选择可能是调用
panic!
并提醒使用你的库的人注意他们代码中的错误,这样他们就可以在开发过程中修复它。同样,如果你正在调用不受你控制的外部代码,并且它返回了一个你无法修复的无效状态,那么
panic!
往往是合适的。
然而,当失败是预料之中的时候,返回一个
Result
比调用一个
panic!
更合适。这方面的例子包括解析器得到了畸形的数据,或者HTTP请求返回的状态表明你已经达到了速率限制。在这些情况下,返回一个
Result
表明失败是一种预期的可能性,调用代码必须决定如何处理。
当你的代码执行一个操作时,如果使用无效的值调用,可能会使用户处于危险之中,你的代码应该首先验证这些值是否有效,如果值无效,就应该崩溃。这主要是出于安全考虑:试图对无效的数据进行操作会使你的代码暴露在漏洞中。这也是标准库在你试图进行越界内存访问时会调用
panic!
的主要原因:试图访问不属于当前数据结构的内存是一个常见的安全问题。函数通常有约定:只有当输入满足特定要求时,它们的行为才会得到保证。当约定被违反时,程序崩溃是有道理的,因为违反约定总是表明有一个调用方的错误,这不是一种你希望调用代码必须明确处理的错误。事实上,调用代码没有合理的方法来恢复;调用程序员需要修复代码。一个函数的约定,特别是当违反约定会导致崩溃时,应该在该函数的API文档中加以解释。
然而,在你所有的函数中都有大量的错误检查将是冗长和令人讨厌的。幸运的是,你可以使用Rust的类型系统(以及由编译器完成的类型检查)来为你做许多检查。如果你的函数有一个特定的类型作为参数,你可以继续你的代码逻辑,因为你知道编译器已经确保了你有一个有效的值。例如,如果你有一个类型而不是一个
Option
,你的程序就会期望有一个东西而不是什么都没有。那么你的代码就不需要处理
Some
和
None
变体的两种情况:它只需要处理一个肯定有值的情况。试图向你的函数传递无的代码甚至不会被编译,所以你的函数在运行时不需要检查这种情况。另一个例子是使用无符号的整数类型,如
u32
,这样可以确保参数永远不会是负数。
创建用于验证的自定义类型
让我们把使用Rust的类型系统来确保我们有一个有效的值的想法更进一步,看看如何创建一个自定义类型来进行验证。回顾一下之前章节中的猜谜游戏,我们的代码要求用户在1到100之间猜一个数字。在与我们的秘密数字进行核对之前,我们从未验证过用户的猜测是否在这些数字之间;我们只验证了猜测是肯定的。在这种情况下,后果不是很严重:我们输出的 “太高 “或 “太低 “仍然是正确的。但如果能引导用户进行有效的猜测,并在用户猜测出一个超出范围的数字时与用户输入字母时有不同的行为,这将是一个有用的改进。
一种方法是将猜测解析为
i32
,而不仅仅是
u32
,以允许潜在的负数,然后添加一个数字是否在范围内的检查,像这样。
loop {
// --片段--
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) {
}
if
表达式检查我们的值是否超出了范围,告诉用户这个问题,并调用
continue
来开始下一次循环的迭代,并要求再次进行猜测。在
if
表达式之后,我们可以继续进行
guess
和秘密数字之间的比较,因为我们知道
guess
在1到100之间。
然而,这并不是一个理想的解决方案:如果程序只对1到100之间的数值进行操作是绝对关键的,而且它有许多函数都有这样的要求,在每个函数中都有这样的检查是很乏味的(而且可能影响性能)。
相反,我们可以建立一个新的类型,并将验证放在一个函数中,以创建该类型的实例,而不是到处重复验证。这样一来,函数就可以安全地在签名中使用新类型,并自信地使用它们收到的值。下例例13显示了一种定义
Guess
类型的方法,只有当
new
函数收到1到100之间的值时,才会创建一个
Guess
的实例。
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {}.", value);
}
Guess { value }
}
pub fn value(&self) -> i32 {
self.value
}
}
例13
首先,我们定义了一个名为
Guess
的结构,该结构有一个名为
value
的字段,存放着一个
i32
。这就是数字将被存储的地方。
然后,我们在
Guess
上实现一个名为
new
的关联函数,创建
Guess
值的实例。
new
函数被定义为有一个
i32
类型的参数名为
value
,并返回一个
Guess
。新函数正文中的代码测试
value
以确保它在1到100之间。如果
value
没有通过这个测试,我们就会发出一个
panic!
调用,这将提醒正在编写调用代码的程序员,他们有一个需要修复的错误,因为用这个范围以外的值创建一个
Guess
将违反
Guess::new
所依赖的约定。
Guess::new
可能发生崩溃的条件应该在其面向公众的API文档中讨论;我们将在后面章节中介绍表明崩溃可能性的文档惯例!在你创建的API文档中。如果
value
确实通过了测试,我们就创建一个新的
Guess
,其
value
字段设置为
value
参数并返回
Guess
。
接下来,我们实现一个名为
value
的方法,它借用了
self
,没有任何其他参数,并返回一个
i32
。这种方法有时被称为
getter
,因为它的目的是从其字段中获取一些数据并返回。这种公共方法是必要的,因为
Guess
结构的
value
域是私有的。重要的是,
value
域是私有的,所以使用
Guess
结构的代码不允许直接设置
value
:模块外的代码必须使用
Guess::new
函数来创建
Guess
的实例,从而确保
Guess
没有办法拥有一个未经
Guess::new
函数检查的值。
一个有参数或只返回1到100之间的数字的函数可以在其签名中声明它接收或返回一个
Guess
而不是一个
i32
,并且不需要在其主体中做任何额外的检查。
总结
Rust的错误处理功能是为了帮助你写出更健壮的代码。
panic!
宏表示你的程序处于它无法处理的状态,并让你告诉进程停止,而不是试图用无效或不正确的值继续进行。
Result
枚举使用Rust的类型系统来表示操作可能会失败,而你的代码可以从中恢复。你可以使用
Result
来告诉调用你的代码的代码,它也需要处理潜在的成功或失败。在适当的情况下使用
panic!
和
Result
将使你的代码在面对不可避免的问题时更加可靠。
现在你已经看到了标准库用
Option
和
Result
枚举使用泛型的有用方法,我们将讨论泛型是如何工作的以及你如何在你的代码中使用它们。
本章重点
- panic!还是不panic!的原则
- unwrap或expect的使用方法和注意细节
- 错误处理的指导步骤
- 如何创建用于验证的自定义类型