std::bad_optional_access是针对例外的小罪吗?

Is std::bad_optional_access a small crime against exceptions?

如果在 optional 没有初始化实际值时调用 std::optionalvalue() 成员函数,则会抛出 std::bad_optional_access。由于它直接派生自 std::exception,因此您需要 catch (std::bad_optional_access const&)catch (std::exception const&) 来处理异常。然而,这两种选择对我来说似乎都很悲伤:

Placement Item::get_placement() const {
  // throws if the item cannot be equipped
  return this->placement_optional.value();
}
void Unit::equip_item(Item acquisition) {
  // lets the exception go further if it occurs
  this->body[acquisition.get_placement()] = acquisition;
}
// somewhere far away:
try {
  unit.equip_item(item);
} catch (std::bad_optional_access const& exception) { // what is this??
  inform_client(exception.what());
}

因此,要捕获异常,您需要充分了解 ItemItem 的实现中的用法,从而获得已知问题列表。我也不想捕获并重新包装 std::bad_optional_access 因为(对我而言)异常的关键部分是在需要时忽略它们的可能性。这就是我认为正确的方法:

std::exception
  <- std::logic_error
    <- std::wrong_state (doesn't really exist)
      <- std::bad_optional_access (isn't really here)

所以,"far away"例子可以这样写:

try {
  unit.equip_item(item);
} catch (std::wrong_state const& exception) { // no implementation details
  inform_client(exception.what());
}

最后,

注意:boost::bad_optional_access 源自 std::logic_error。不错!

注意 2:我知道 catch (...) 并抛出 std::exception 系列以外类型的物体。为了简洁(和理智),它们被省略了。

更新:不幸的是,我不能接受两个答案,所以:如果你对这个话题感兴趣,你可以阅读 Zuodian Hu 的答案和他们的评论。

首先,如果您不想公开实现,那么异常甚至不应该跨越实现和客户端代码之间的边界。这是一个常见的习语,任何例外都不应跨越库、API 等的界限。

其次,你在optional中存储一些东西是你应该自己控制的实现。这意味着你应该检查可选的不为空(至少如果你不想让客户知道实现的细节)。

最后回答问题:客户端代码操作空对象是否报错?如果那是 允许做的事情 ,则不应抛出任何异常(例如,可能会返回错误代码)。如果那是不应该发生的真正问题,则抛出异常是合适的。您可能会在代码中捕获 std::bad_optional_access 并从 catch 块中抛出其他内容。

So, to catch the exception you need to be well-informed about the usage of std::optional in the Item's implementation

不,要捕获异常,您必须阅读 get_placement 的文档,它会告诉您它抛出 std::bad_optional_access。通过选择发出该异常,该函数使该异常的发出成为该函数 接口 的一部分。

因此,与直接 return 编辑 std::optional 相比,它不再依赖于 Item 的实现。您选择将它放在您的界面中,因此您应该承担后果。

换句话说,如果你觉得将std::optional作为参数类型或return值是错误的,那么你应该对直接发射bad_optional_exception有同样的感觉.


最终,这一切都回到了错误处理的最基本问题之一:在错误的具体性质变得毫无意义甚至完全不同之前,你能离错误发生多远?

假设您正在进行文本处理。您有一个文件,每行包含 3 个浮点数。您正在逐行处理它,并将每组三个值插入到列表中。你有一个将字符串转换为浮点数的函数,如果转换失败,它会抛出异常。

因此代码大致如下所示:

for each line
  split the line into a 3-element list of number strings.
  for each number string
    convert the string into a number.
    add the number to the current element.
  push the element into the list.

好的,那么...如果您的字符串到浮点转换器抛出异常会怎样?那要看;你希望发生什么?这取决于谁抓住它。如果您想要错误的默认值,那么最内层循环中的代码会捕获它并将默认值写入元素。

但也许您想记录特定行有错误,然后跳过该行(不要将其添加到列表中),但继续正常处理文本的其余部分。在这种情况下,您会在第一个循环中捕获异常。

到那时,错误的含义已经改变。抛出的错误是 "this string doesn't contain a valid float",但这不是您的代码处理它的方式。事实上,捕获代码已经完全失去了错误的上下文。它不知道是文本中的第一个、第二个还是第三个字符串导致了失败。充其量,它知道它在这条线上的某个地方,也许异常恰好包含几个指向错误字符串范围的指针(尽管由于悬空指针的可能性,异常离其源越远越危险).

如果转换失败意味着整个过程不再可信,您正在构建的列表无效并且应该被丢弃怎么办?这比前一例更没有来龙去脉,意义也更扑朔迷离。此时,错误只是意味着终止列表构建过程。也许您将一个日志条目放在一起,但这就是您此时要做的所有事情。

离抛出异常的地方越远,有关错误的上下文就越丢失,最终含义就越偏离错误的初始含义。这不仅仅是一个实现细节;它是关于信息的位置和对该信息的响应。

所以基本上,接近错误源的代码正在捕获具有上下文含义的特定异常。捕获距离错误源越远,捕获代码越有可能变得非常通用,处理模糊的 "this didn't work because reasons" 类事物。这就是 std::logic_error 等模糊类型的用武之地。

的确,可以想象在流程的每一步,异常都会被重新解释("reinterpreted",我的意思是通过 catch/throw 将其转换为不同的类型)。字符串到浮点数转换器抛出一个有意义的异常:无法将字符串转换为浮点数。试图从 3 个字符串构建元素的层将异常转换为对其调用者有价值的东西:字符串索引 X 格式错误。在最后阶段,异常被概括为:由于第 Y 行无法解析列表。

单个异常类型可以跳过整个代码库和设计意图并仍然保留其初始含义的想法是一种幻想。异常在必须通过中性代码时非常有效,例如从回调中抛出异常或间接函数执行的其他情况。在这种情况下,引发执行的代码仍然具有引发异常的进程的本地上下文。但是,离知道发生了什么的本地上下文越远,特定异常的意义就越小。

由于这些原因,从 logic_error 继承是错误的。抓住 bad_optional_access 最终是一件非常本地化的事情。过了某个点,那个错误的意思就改变了。

A "logic error" 表示您的程序没有意义。但是一个不包含值的可选值并不必然代表这样的问题。在一段代码中,有一个空的可选值可能是一件完全有效的事情,抛出的异常只是将其报告给调用者的方式。另一段代码可能会在某个时候将可选的空值视为用户在 API 的使用中犯了一些先前的错误。其中一个是逻辑错误,另一个不是。

最终,正确的做法是确保您的 类 API 都发出对调用者 有意义 的异常。并且不清楚 bad_optional_accessget_placement.

的调用者意味着什么

公开实施细节

如果您希望您的用户在您的实施中完全不知道 std::optional,您的界面将检查 operator boolhas_value 并执行以下操作之一:

  1. return一个状态码
  2. 抛出自定义异常类型
  3. 以客户不知道发生过内部错误的方式处理空值

...或者您的界面会捕获 std::bad_optional_access 并执行上述操作之一。无论哪种情况,您的客户都不知道您使用了 std::optional

请注意,无论您是通过显式检查还是通过异常发现可选项是否为空,都是一种设计选择(但就我个人而言,在大多数情况下我都不会捕获并重新抛出)。

逻辑错误?

基于 conceptual model for optional in the pre-standardization paperstd::optional 是具有 valid 空状态的值包装器。因此,意图是在正常使用中有意空虚。正如我在评论中所述,有两种处理空虚的一般方法:

  1. 使用 operator boolhas_value,然后内联处理空值或通过 operator*operator->.
  2. 使用包装值
  3. 使用 value 并在可选为空时退出作用域

在任何一种情况下,您都应该期望 optional 可能为空,并设计为在您的程序中成为有效状态。

换句话说,当你使用operator boolhas_value来检查是否为空时,并不是为了防止抛出异常。相反,您选择完全(通常)不使用 optional 的异常接口。当您使用 value 时,您选择接受 optional 可能会抛出 std::bad_optional_access。因此,异常绝不应该是 optional.

的预期用法中的逻辑错误

更新

C++ 设计中的逻辑错误

您似乎误解了标准对逻辑错误的预期定义。

在近几年的C++设计中(历史上不一样),逻辑错误是应用程序不应该尝试恢复的程序员错误,因为它不能合理地恢复。这包括取消引用悬挂指针和引用,在空可选上使用 operator*operator->,将无效参数传递给函数,或以其他方式破坏 API 契约。请注意,悬挂指针的存在不是逻辑错误,但取消引用悬挂指针是逻辑错误。

在这些真正的逻辑错误的情况下,标准故意选择 而不是 来抛出,因为它们是真正的 逻辑错误程序员和调用者不能合理地期望处理他们调用的代码中的所有错误。

当一个设计良好(在这种理念下)的标准库函数抛出异常时,绝不应该是因为代码或调用者编写了错误代码。对于有缺陷的代码,标准让您因编写错误而一败涂地。例如,<algorithn> 运行 中的许多函数如果你传递错误的 beginend 迭代器就会无限循环,并且永远不会尝试诊断你这样做的事实。他们当然不会抛出 std::invalid_argument。 "Good" 实现确实尝试在调试版本中对此进行诊断,因为这些逻辑错误是 错误 。当一个设计良好(在这种理念下)的标准库函数抛出异常时,应该是因为发生了真正异常且不可避免的事件。有很多抛出函数,因为你永远无法确定某个随机文件系统上有什么。这就是应该使用异常情况的情况。

在下面链接的论文中,Herb Sutter 反对 std::logic_error 由于这个原因作为异常类型的存在。清楚地说明哲学,捕获 std::logic_error 或其任何子项相当于引入 运行 时间开销来修复程序员逻辑错误。您想要检测的任何真正的逻辑错误条件都应该断言,真的,这样就可以将错误报告给编写错误的人。

optional 界面中,考虑到上述设计,value throws 以便您可以以合理的方式以编程方式处理它,并期望无论谁抓住它都不会关心 bad_optional_access 是什么意思 (catch( ... ) // catch everything) 或者可以专门处理 bad_optional_access。该异常实际上根本不打算传播很远。当你故意调用 value 时,你这样做是因为你承认 optional 可能是空的,如果它确实是空的,你选择退出当前范围。

有关哲学原理,请参阅 this paper(下载)的第一部分。

您说异常的主要吸引力在于您可以在尽可能深的调用堆栈中忽略它们。据推测,鉴于您避免泄露实现细节的雄心壮志,您不能再让异常传播到异常无法被其处理程序理解和修复的程度。这似乎与您的理想设计相矛盾:它向用户修复了异常,但是 bad_optional_access::what 完全没有关于刚刚发生的事情的上下文——向用户泄露了实现细节。当用户最多只看到 "could not equip item: bad_optional_access"?

时,您如何期望用户针对装备失败采取有意义的行动?

您显然已经取得了 over-simplification,但挑战仍然存在。即使有 "better" 异常层次结构,std::bad_optional_access 也没有足够的上下文,以至于除了非常接近的调用者之外的任何人都可能知道如何处理它。

有几种截然不同的情况,您可能想抛出:

  1. 您希望在没有太多语法开销的情况下中断控制流。例如,您有 25 个不同的可选项要解包,如果其中任何一个失败,您想要 return 一个特殊值。您在 25 次访问周围放置了一个 try-catch 块,为自己节省了 25 if 个块。
  2. 您已经编写了一个通用的库,它做了很多可能出错的事情,并且您想向调用程序报告 fine-grained 错误,以使其有最好的机会以编程方式做一些聪明的事情恢复。
  3. 您已经编写了一个执行非常 high-level 任务的大型框架,因此您通常期望操作失败的唯一合理结果是通知用户操作失败。

当您 运行 遇到异常问题时感觉不对劲,这通常是因为您正在尝试处理与您希望的级别不同的级别的错误。希望更改异常层次结构只是试图使该异常符合您的特定用途,这会导致其他人如何使用它的紧张关系。

显然,C++委员会认为bad_optional_access属于第一类,而你问为什么它不属于第三类。与其在 "need" 对异常进行处理之前试图忽略异常,我认为您应该翻转这个问题并问问自己捕获异常的目的是什么。

如果答案确实是 "the user",那么您应该抛出一些不是 bad_optional_access 的东西,而是具有 high-level 功能,例如本地化错误消息和足够的数据 inform_user 能够调出一个带有有意义的标题、正文、副文本、按钮、图标等的对话框。

如果答案是这是一个一般的游戏引擎错误并且它可能会在游戏的常规过程中发生,那么你应该抛出一些东西说装备失败,而不是状态错误.与 non-descript 状态错误相比,您更有可能从未能装备物品中恢复过来,包括如果在路上,您需要为用户产生一个漂亮的错误。

如果答案是您可能会尝试连续装备 25 件物品,并且您想在出现问题时立即停止,那么您无需更改 bad_optional_access

另请注意,不同的实现或多或少使不同的用途变得方便。在大多数现代 C++ 实现中,不抛出的代码路径上没有性能开销,而抛出的路径上有 huge 开销。这通常会反对对第一类错误使用异常。

您的问题的另一种解决方案可能是嵌套异常。这意味着您捕获了一个较低级别的异常(在您的情况下 std::bad_optional_access),然后使用 std::throw_with_nested 函数抛出另一个异常(任何类型,在您的情况下您可以实现 wrong_state : public std::logic_error) .使用这种方法你:

  1. 保留下层异常的信息(它是 仍存储为嵌套异常)

  2. 对用户隐藏有关嵌套异常的信息

  3. 允许用户将异常捕获为 wrong_statestd::logic_error

看例子:https://coliru.stacked-crooked.com/view?id=b9bc940f2cc6d8a3

考虑这个关于何时不使用 std:bad_optional_access 的真实世界示例,涉及不雅的 900 行代码,包裹在一个巨大的 class 中,只是为了渲染一个 vulkan 三角形,在这个例子中在 https://vulkan-tutorial.com/code/06_swap_chain_creation.cpp

我正在将一个巨大的 HelloTriangleApplication class 重新实现为多个较小的 classes。而且,QueueFamilyIndi​​ces 结构开始时是几个空的 std::optional 列表,因此,正是 std::optional 被发明来处理的那种尚未出现的事物。

所以,很明显,我想测试每个 class,然后再将其子 class 转换为另一个 class。但是,这涉及到保留一些尚未初始化的东西,直到后来实现父级的子class。

至少对我来说,不使用 std:bad-optional-access 作为未来值的占位符似乎是正确的,而只是在父 class 中编码 0,因为尚未实现 std:optional not-yet-things 的占位符。这足以避免我的 IDE 报告那些烦人的“错误的可选访问”警告。