别再使用 unwrap 处理 Options:还有更好的方法

发布时间:2024-08-29

我注意到在 Rust 中,处理 OptionNone 变体而不依赖 unwrap() 是一种常见的痛点。更具体地说,当你想要从一个返回 Result 的函数中提前返回,如果遇到 None 时,问题就出现了。

这个问题已经被讨论过无数次了,但令人惊讶的是,即使是 Rust 官方书籍也没有提到我最喜欢的处理方法,而且许多论坛帖子已经过时。

经过一些练习,可靠地处理 None 可以变得像 unwrap() 一样简单,但更安全。

如果你时间紧迫,只需要一个快速的建议,可以直接跳到结尾。

问题所在

很常见的情况是,人们会编写如下代码:

// 假设这从某个地方获取用户
fn get_user() -> Option<String> {
  None
}
fn get_user_name() -> Result<String, String> {
  let user = get_user()?;
  // 对 `user` 做一些处理
  // ...
  Ok(user)
}

这里的目标是在 Option 中遇到 None 时提前返回,所以他们使用 ? 操作符来传播错误。

唉,这段代码无法编译。相反,你会得到一个可怕的错误信息:

error[E0277]: the `?` operator can only be used on `Result`s, not `Option`s, in a function that returns `Result`
 --> src/lib.rs:10:26
  |
9 | fn get_user_name() -> Result<String, String> {
  | ------------------------------------ this function returns a `Result`
10 |   let user = get_user()?;
  |             ^ use `.ok_or(...)?` to provide an error compatible with `std::result::Result<String, Box<dyn std::error::Error>>`
  |
  = help: the trait `FromResidual<Option<Infallible>>` is not implemented for `std::result::Result<String, Box<dyn std::error::Error>>`
  = help: the following other types implement trait `FromResidual<R>`:
       <std::result::Result<T, F> as FromResidual<Yeet<E>>>
       <std::result::Result<T, F> as FromResidual<std::result::Result<Infallible, E>>>

(Rust Playground)

哇。这看起来很吓人!

这个错误信息中有很多视觉噪音。FromResidualYeet 是实现细节,可能会让新用户感到困惑,而且相关的细节有些模糊。

而我们所做的只是尝试将 ? 操作符用于我们的 Option

我对这个错误信息的主要不满是,它没有解释 为什么 ? 操作符在这种情况下不适用于 Option… 只是说它不行。

人们最终会怎么做

我看到最常见的做法是:

这是他们最终得到的:

fn get_user_name() -> Result<String, String> {
  let user = get_user().unwrap();
  // 对 `user` 做一些处理
  Ok(user)
}

在培训中,我注意到人们常常不好意思寻求帮助。他们认为人们应该 “理解这一点”,而他们是唯一不理解的人。

这只是推迟了问题。函数的用户可能会在运行时遇到 panic。该用户可能是他们未来的自己。

unwrap 在许多情况下都很好,但不应该是处理意外情况的第一个直觉。特别是当你编写一个库或一个函数,它是较大代码库的一部分时,你应该努力优雅地处理这些情况。在生产代码中,它树立了一个坏榜样:一个 unwrap 会吸引另一个,并且代码库会随着你继续沿着这条道路而变得更加脆弱。1

好吧,我已经让你等了很久了。让我们揭开这个错误信息的神秘面纱。

真正的问题

编译器试图告诉我们的是你不能在返回 Result 的函数中传播 optionals

如果你返回一个 Option,一切都会正常工作:

fn get_user_name() -> Option<String> {
  // 可以运行 :)
  let user = get_user()?;
  // 对 `user` 做一些处理
  // ...
  Some(user)
}

因此,如果你可以更改外部函数以返回 Option,你就不会遇到上面的错误信息。 Rust 文档中有更多信息 这里

但是,如果你的函数的最终返回类型必须是 Result,或者你想向调用者传递关于缺失值的更多信息呢?毕竟,传达不同 None 值之间的区别对你的函数的用户来说可能很有用。

那么,如果你 真的 希望你的代码看起来像这样呢?

fn get_user_name() -> Result<String, String> {
  // 不起作用,因为我们返回一个 `Result`
  let user = get_user()?;
  // 更多可能失败的操作,在这里
  // ...
  // 返回一个 `Result`
  Ok(user)
}

好吧,这只是一个类型错误:get_user() 返回一个 Option,但外部函数期望一个 Result

我们的问题陈述变得更容易:在这种情况下,当 OptionNone 时,如何返回有用的错误消息?

事实证明,有多种解决方案!

解决方案 1:更改返回类型

如果“没有值”确实是你程序逻辑中的一种错误情况,你应该使用 Result 而不是 Option。当缺少值是一种正常的、预期的可能性,而不是错误状态时,最好使用 Option

在我们的例子中,如果你可以更改 get_user() 以返回 Result 而不是 Option,你可以像你预期的那样使用 ? 操作符:

fn get_user() -> Result<String, String> {
  // ...
  Ok("Alice".to_string())
}
fn get_user_name() -> Result<String, String> {
  let user = get_user()?;
  // 对 `user` 做一些处理
  // ...
  Ok(user)
}

但是,你可能由于各种原因而无法更改 get_user() 以返回 Result,例如,如果它是库的一部分,或者它在许多地方使用,或者其他调用者不将缺少用户视为错误。

在这种情况下,请继续阅读!

解决方案 2:ok_or

最初的错误消息虽然隐晦,但给了我们一个提示:

use `.ok_or(...)?` to provide an error compatible with `Result<(), Box<dyn std::error::Error>>`.

显然,我们可以使用 ok_or 方法,它将 Option 转换为 Result

let user = get_user().ok_or("No user")?;

这很方便!当我们使用迭代器模式时,它也可以很好地链接:

let user = get_user()
  .ok_or("No user")?
  .do_something()
  .ok_or("Something went wrong")?;

(Playground

如果你可以从 None 中恢复并优雅地处理这种情况,ok_or 也很棒:

let user = get_user().ok_or(get_logged_in_username())?;

这些是 ok_or 的良好用例。

另一方面,对于每个人来说,ok_or 的作用可能不是很明显。我发现 ok_or 这个名字很不直观,需要查找很多次。这是因为 Ok 通常与 Result 类型相关联,而不是 Option。有 Option::Some,所以它可以被称为 some_or实际上在 2014 年就有人建议过,但是 ok_or 这个名字胜出了,因为 ok_or(MyError) 读起来很顺畅,我能理解为什么。我想我们现在必须忍受这种轻微的不一致性了。

我认为 ok_or 是解决问题的快速方法,但还有一些可能更具可读性的替代方案。

解决方案 3:match

过去,我曾经建议人们不要耍花招,而只是使用 match 语句。

let user = match get_user() {
  Some(user) => user,
  None => return Err("No user".into()),
};

matchOption 结合使用,因为它只是一个枚举,我们可以对它进行模式匹配。只要我们涵盖所有情况,编译器就会很高兴。在这种情况下,我们只有两种情况:SomeNone。在 None 情况下,我们提前返回一个错误。在 Some 情况下,我们继续使用该值。(match 是一个表达式,并且块中最后一个表达式的值被返回。在我们的例子中,它是 user,它被分配给外部范围中的 user 变量。)

这已经更明确,并且对于初学者来说更容易理解。我过去在教授这个时遇到的一个问题是,对于简单的情况,它看起来有点冗长。

解决方案 4:let-else

使用 Rust 1.65,let-else 表达式被稳定了,所以现在你可以这样写:

let Some(user) = get_user() else {
  return Err("No user".into());
};
// 对 user 做一些处理

在我看来,这是两全其美:它既紧凑又易于理解。它受到初学者和经验丰富的 Rustacean 的一致喜爱。

一些解释:如果 get_user() 返回 Somelet 语句将解构 Some 变体并将该值分配给 user 变量。如果 get_user() 返回 None,则将执行 else 块,并且我们提前返回一个错误。

我最喜欢 let-else 的地方是它清楚地突出了代码的“happy path”。

与需要阅读两个分支才能理解预期流程的 match 语句不同,let-else 可以立即清楚地知道预期的情况是什么,而 else 块处理异常情况。

这是一个明显的赢家。对于初学者来说,它更直观;一旦他们理解了这个模式,他们就会一直使用它!

额外内容:anyhow

我想在这里添加一个荣誉奖。

如果你正在编写一个应用程序(不是库),并且你已经在使用 anyhow crate 了,你也可以使用他们的 context 方法来处理 None

use anyhow::{Context, Result};
fn get_user_name() -> Result<String, anyhow::Error> {
  let user = get_user().context("No user")?;
  // 对 `user` 做一些处理
  Ok(user)
}

它比 let-else 稍微简洁一些,这使得它很有吸引力。只需记住 anyhow 是一个外部依赖项。对于应用程序来说,它可能没问题,但你可能不想在库中使用它,因为你的库的用户将无法再匹配具体的错误变体。

这就是为什么我相信在大多数情况下,let-else 是处理 None 的最佳解决方案。

结论

在大多数情况下,我更喜欢这种语法:

let Some(value) = some_function() else {
  return Err("描述性的错误消息".into());
};

对我来说,let-else 是处理 None 的最佳解决方案,因为:

我希望这有助于更多的人以更健壮的方式处理 Option。如果它可以帮助一个人避免一个 unwrap,那就已经值了。

  1. 有时,unwrap() 可以通过减少噪音来提高代码的可读性,尤其是在成功的情况极有可能发生时。当你能够证明失败是不可能的,或者当 panic 实际上是失败的所需行为时,可以使用 unwrap()。 Andrew Gallant 写了一篇关于这个的文章,他在其中进行了更详细的介绍。