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



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

  • std::exception捕获每个异常
  • std::bad_optional_access公开实现详细信息。请考虑以下示例:
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());
}

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

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

因此,"遥远"的示例可以这样写:

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

最后

  • 为什么std::bad_optional_access设计成这样?
  • 我是否正确地感觉到了异常?我的意思是,它们是为这种用途而引入的吗?

注意:boost::bad_optional_access派生自std::logic_error。好!

注2:我知道catch (...)和投掷除家庭以外的物品std::exception。为了简洁(和理智),它们被省略了。

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

您说异常的主要吸引力在于,您可以在尽可能深的调用堆栈中忽略它们。据推测,鉴于您避免泄露实现细节的雄心壮志,您不能再让异常传播到其处理程序无法理解和修复该异常的点之外。这似乎与你的理想设计相矛盾:它为用户修复异常,但bad_optional_access::what完全没有关于刚刚发生的事情的上下文 - 向用户泄露实现细节。你如何期望用户对装备物品失败采取有意义的行动,当他们看到的充其量只是"无法装备物品:bad_optional_access"时?

你显然过于简单化了,但挑战仍然存在。即使使用"更好"的异常层次结构,std::bad_optional_access也根本没有足够的上下文,除了非常接近的调用者之外,任何内容都可能知道如何处理它。

在以下几种相当不同的情况下,您可能想要抛出:

  1. 您希望在没有太多语法开销的情况下中断控制流。例如,您有 25 个不同的可选选项要解包,并且您希望在其中一个失败时返回一个特殊值。您在 25 个访问周围放置了一个 try-catch 块,为自己节省了 25 个if块。
  2. 您已经编写了一个用于常规用途的库,该库执行了许多可能出错的操作,并且您希望向调用程序报告细粒度错误,以便以编程方式执行智能恢复操作的最佳机会。
  3. 已经编写了一个执行非常高级任务的大型框架,因此您期望操作失败的唯一合理结果通常是通知用户操作已失败。

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

显然,C++委员会认为bad_optional_access属于第一类,而你问为什么它不属于第三类。与其试图忽略异常,直到你"需要"对它们做点什么,我认为你应该把问题转过来,问问自己什么来捕捉异常。

如果答案真的是"用户",那么你应该抛出一些不是bad_optional_access的东西,而是具有高级功能,如本地化的错误消息和足够的数据,inform_user能够调出一个对话框,其中包含有意义的标题、正文、副文本、按钮、图标等。

如果答案是这是一个一般的游戏引擎错误,并且可能会在游戏的常规过程中发生,那么你应该抛出一些东西,说装备物品失败,而不是存在状态错误。与出现非描述性状态错误相比,您更有可能从装备失败中恢复过来,包括将来是否需要为用户生成一个漂亮的错误。

如果答案是您可能尝试连续装备 25 个项目,并且您希望在出现问题时立即停止,那么您不需要更改bad_optional_access

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

因此,要捕获异常,您需要充分了解 Item 实现中std::optional的用法

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

因此,它对Item的实现的依赖程度并不比直接返回std::optional的依赖性更大。你选择把它放在你的界面中,所以你应该承担后果。

换句话说,如果您觉得将std::optional作为参数类型或返回值是错误的,那么您应该对直接发出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.

好吧,所以...如果您的字符串到浮动转换器抛出会发生什么?这取决于;你想发生什么?这取决于谁抓住了它。如果你想要一个错误的默认值,那么最里面循环中的代码会捕获它并将默认值写入元素。

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

此时,错误的含义已更改。抛出的错误是"此字符串不包含有效的浮点数",但这不是您的代码处理它的方式。事实上,捕获代码已经完全失去了错误的上下文。它不知道是文本中的第一个、第二个还是第三个字符串导致了失败。充其量,它知道它沿着这条线的某个地方,也许异常恰好包含几个指向坏字符串范围的指针(尽管由于异常离其源越远,这就越危险,因为可能存在悬空指针)。

如果转换失败意味着整个过程不再可信,您正在构建的列表无效,应该丢弃怎么办?这比前一个案例的语境更少,意思更加混乱和遥远。此时,错误仅意味着终止列表构建过程。也许你把一个日志条目放在一起,但这就是你此时要做的全部工作。

您离引发异常的位置越远,丢失的有关错误的上下文就越多,并且含义最终偏离错误的初始含义就越多。这不仅仅是一个实现细节;这是关于信息的位置和对该信息的反应。

因此,基本上,接近错误源的代码正在捕获具有上下文意义的特定异常。捕获距离错误源越远,捕获代码就越有可能非常通用,处理模糊的"这不起作用,因为原因"之类的事情。这就是像std::logic_error这样的模糊类型的用武之地。

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

单个异常类型可以跳过整个代码库和设计意图并仍保留其初始含义的想法是一种幻想。当异常必须通过中性代码时,例如从回调或其他间接函数执行情况中引发异常时,异常效果很好。在这种情况下,引发执行的代码仍具有引发异常的进程的本地上下文。但是,谁知道你得到的当地环境越远,特定的例外就越没有意义。

由于这些原因,从logic_error继承是错误的。抓bad_optional_access终究是一件非常本土化的事情。超过某个点,该错误的含义就会改变。

"逻辑错误"表示程序没有意义。但是不包含值的可选并不一定表示这样的问题。在一段代码中,有一个空的可选可能是完全有效的事情,而抛出的异常只是如何向调用方报告。另一段代码可能会将某个可选代码视为在某个点为空的用户,因为用户在 API 使用中犯了一些先前的错误。其中一个是逻辑错误,另一个不是。

最终,正确的做法是确保您的类 API 都发出对调用方有意义的异常。目前尚不清楚bad_optional_accessget_placement的呼叫者意味着什么.

公开实现详细信息

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

  1. 返回状态码
  2. 引发自定义异常类型
  3. 以这样一种方式处理空虚,使客户不知道曾经发生过内部错误

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

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

逻辑错误?

根据预标准化论文中可选的概念模型,std::optional是具有有效空状态的值包装器。因此,意图是让空在正常使用中是故意的。正如我在评论中所说,有两种处理空性的一般方法:

使用
  1. operator boolhas_value,然后内联处理空性或通过operator*operator->使用包装的值。
  2. 如果可选为空,则使用value并保释出范围

无论哪种情况,您都应该期望optional可能为空,并将其设计为程序中的有效状态。

换句话说,当您使用operator boolhas_value来检查空性时,并不是为了防止引发异常。相反,您选择根本不使用optional的异常接口(通常)。当你使用value时,你选择接受optional潜在的抛出std::bad_optional_access。因此,异常绝不应该是optional的预期用法中的逻辑错误。

更新

C++设计中的逻辑错误

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

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

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

当一个精心设计的(在这种理念下)标准库函数抛出时,它永远不应该是因为代码或调用者编写了有缺陷的代码。对于有缺陷的代码,标准让你因为编写错误而陷入困境。例如,如果你<algorithn>传递糟糕的beginend迭代器,中的许多函数都会运行无限循环,甚至从未尝试诊断你这样做的事实。他们当然不会扔std::invalid_argument.不过,"好"的实现确实会尝试在调试版本中诊断此问题,因为这些逻辑错误是错误。当一个精心设计的(在这种理念下)标准库函数抛出时,应该是因为发生了一个真正异常且不可避免的事件。 具有许多抛出函数,因为您无法确定某些随机文件系统上的内容。这就是应该使用例外的情况。

在下面链接的论文中,赫伯·萨特反对std::logic_error作为例外类型的存在,正是因为这个原因。清楚地陈述哲学,捕获std::logic_error或其任何子项相当于引入运行时开销来修复程序员逻辑错误。实际上,任何要检测的真实逻辑错误条件都应该断言,以便可以将错误报告给编写错误的人。

optional界面中,设计时考虑到了上述内容,value抛出,以便您可以以合理的方式以编程方式处理它,期望捕获它的人要么不在乎bad_optional_access的含义(catch( ... ) // catch everything),要么可以专门处理bad_optional_access。这个异常实际上根本不是为了传播得很远。当您故意调用value时,您这样做是因为您承认optional可能为空,并且如果它确实为空,则选择退出当前范围。

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

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

接下来,您在optional中存储某些内容这一事实是您应该自己控制的实现。这意味着您应该检查可选是否不为空(至少如果您不希望客户端知道实现的详细信息)。

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

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

  1. 保留有关较低级别异常的信息(它是 仍存储为嵌套异常)

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

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

请参阅示例:https://coliru.stacked-crooked.com/view?id=b9bc940f2cc6d8a3

考虑这个现实世界的例子,什么时候不使用 std:bad_optional_access,涉及不优雅的 900 行代码,包装成一个巨大的类,只是为了渲染一个 vulkan 三角形,在这个例子中 https://vulkan-tutorial.com/code/06_swap_chain_creation.cpp

我正在将一个巨大的HelloTriangleApplication类重新实现为多个较小的类。而且,QueueFamilyIndices 结构以几个空的 std::optional列表开始,因此,正是 std::optional 发明来处理的那种尚未发生的事情。

所以,很明显,我想先测试每个类,然后再将其子类化到另一个类中。但是,这涉及到在后来实现父类的子类之前,将一些尚未初始化的东西保留下来。

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

这是一个有好答案的好问题。我想更直接地强调一些要点,并补充一些我不同意其他答案的观点。我更多地从抽象信息流的POV中得出这一点,其想法是,当适当的信息得到有效传递时,特定情况的所有无限变体都变得更容易处理。

TL;这里的DR是:

  • 以语义不正确的方式使用bad_optional_access很常见,但这是许多其他东西(如logic_error)似乎没有意义的根本原因,并且
  • value()应该只在你知道它有值时才使用;它不是作为value_or()的某种"异常"变体。当没有值时,这是没有意义的:没有价值的事物没有价值,因此检索其值不是您可以做的事情。如果你在没有值的时候调用'value()',那么你在某处犯了一个错误。

关于value()本身的使用:

  • 如果不能保证可选项具有值,请使用has_value()value_or()。使用value()假定可选具有值,并且通过使用它,您将该假设声明为不变(即assert(x.has_value())预期会通过),如果它没有值,则违反了不变量,并且异常是合适的。 当可选项没有值时,value()没有意义。这与在b可能为 0 的情况下不计算a / b的原因相同 - 您要么知道它不是 0,要么先检查。同样取消引用无效的迭代器,访问无效的指针,在空容器上调用front(),呃......计算单个样本的无偏方差...诸如此类的事情。

  • 在这一点之后,如果您看到一个bad_optional_access,那么这意味着您的代码中存在一个错误:您的一个假设(它有一个值)是错误的。换句话说,这是一个开发错误,在理想情况下,用户永远不应该遇到此异常,就像用户永远不会遇到断言失败、被零除或空指针访问一样:它不表示用户可操作的错误,它表示需要修复的代码。理想情况下,只有您作为开发人员才能遇到此特定异常。

  • 这就是为什么它是一个logic_error:你使用了value()但没有遵守它的先决条件,你对它有一个值所做的隐含假设是不正确的。您在无法保证value()具有值的情况下使用 时犯了编程错误。

也就是说,世界并不理想。此外,一般来说,如果某个代码层下面的某个异常旨在表示该代码层之上更适合用户的错误,那么您需要转换该异常。例如:

  • 异常可能会公开要抽象掉的实现详细信息。
  • 异常可能包含对用户没有意义的信息,但可能表示对用户很重要的更大更通用的问题。

所以你需要翻译它。例如:

Placement Item::get_placement() const {
// throws if the item cannot be equipped
return this->placement_optional.value();
}

该评论的字面意思是"如果物品无法装备,则投掷",但bad_optional_access并不意味着"物品无法装备"。因此,如果您允许将其从该函数中抛出,那么您通过抛出语义不正确的异常而误解了概念问题。相反:

// elsewhere
class item_equip_exception : ... {
...
};
// then:
Placement Item::get_placement() const {
// throws if the item cannot be equipped
try {
return this->placement_optional.value();
} catch (const std::bad_optional_access &x) {
throw item_equip_exception(...);
}
}

因为这就是你真正想要传达的。

但是,更正确的版本是:

Placement Item::get_placement() const {
// throws if the item cannot be equipped
if (!this->placement_optional.has_value())
throw item_equip_exception(...);
return this->placement_optional.value();
}

这更正确的原因是,现在您是在应该保证假设它具有值的情况下调用value()的。在这种情况下,如果你最终得到一个bad_optional_access,那么这确实是一个严重的逻辑错误。现在这意味着,只要你与这种方法保持一致,在应用程序的最高层,你现在可以实际捕获std::logic_error这实际上意味着一些程序逻辑出了严重的错误,你可以通知用户。

原帖中的所有问题基本上都可以归结为语义:

  • 如果在可能没有值的情况下使用value(),那就是编程错误,并且...
  • 如果您随后使用该"编程错误"来表示面向用户的一般错误,那么......
  • 。你现在已经把所有的语义都搞砸了,没有什么意义了,包括'std::logic_error'。

另一方面:

  • 如果合理地可能没有价值,并且表示一些更高层次的东西,例如"该物品无法装备",并且......
  • 您检查并抛出更合适的异常,并且在没有值时永远不要调用value(),然后......
  • 。您现在可以向用户传达这一点,logic_error->bad_optional_access继承含义仍然有意义,并且您仍然可以在更高级别单独捕获程序员错误。

所以是的; "说出你的意思"适用于编程中的信息流,就像它适用于现实生活中的说话一样!

最新更新