C ++中异常的惯用法


16

isocpp.org例外常见问题解答指出

不要使用throw来指示函数使用中的编码错误。使用断言或其他机制将流程发送到调试器中或使流程崩溃并收集崩溃转储供开发人员调试。

另一方面,标准库定义了std :: logic_error及其所有派生类,在我看来,它们似乎还应该处理编程错误。是否将空字符串传递给std :: stof(将抛出invalid_argument)不是编程错误?是否将包含不同于'1'/'0'字符的字符串传递给std :: bitset(将抛出invalid_argument)不是编程错误?使用无效索引调用std :: bitset :: set(会抛出out_of_range)不是编程错误吗?如果不是,那么将要测试的编程错误是什么?基于字符串的std :: bitset构造函数仅自C ++ 11起存在,因此在设计时应考虑惯用异常。另一方面,有人告诉我,从根本上不应该使用逻辑错误。

经常出现异常的另一个规则是“仅在特殊情况下使用异常”。但是,库函数应该如何知道哪些情况例外?对于某些程序,无法打开文件可能是一种例外。对于其他人,无法分配内存可能不是例外。中间有100多个案例。无法创建套接字?无法连接或将数据写入套接字或文件?无法解析输入?可能是例外,可能不是。该函数本身肯定通常不知道,它不知道在哪种上下文中调用它。

因此,我应该如何决定是否应为特定功能使用异常?在我看来,实际上唯一一致的方法是将其用于所有错误处理,或一无所有。而且,如果我使用的是标准库,那么该选择就是我自己做的。


6
您必须非常仔细地阅读该FAQ条目 它仅适用于编码错误,不适用于无效数据,取消引用空对象或与一般运行时错误有关的任何事情。一般而言,断言是关于确定不应该发生的事情。 对于其他所有内容,都有异常,错误代码等。
罗伯特·哈维

1
@RobertHarvey,定义仍然存在相同的问题-是否可以在没有程序干预的情况下解决问题,只有程序的上层才知道。
cooky451 '16

1
您正在迷恋法学。评估优缺点,并下定决心。另外,您问题的最后一段...我一点也不认为这是不言而喻的。当真相可能接近几层灰色阴影时,您的想法非常黑白。
罗伯特·哈维

4
在问这个问题之前,您是否尝试过进行任何研究?几乎可以肯定,在Web上令人作呕的细节中讨论了C ++错误处理习惯用法。一项常见问题解答条目的引用并不能很好地进行研究。完成研究后,您仍然必须下定决心。不要让我开始了解我们的编程学校显然是如何创建不懂得如何思考的盲目的软件模式编码机器人的。
罗伯特·哈维

2
这使我的理论相信这种规则可能实际上不存在。我邀请了C ++ Lounge的一些人来看看他们是否可以回答您的问题,尽管每次我进去时,他们的建议都是“停止使用C ++会让您大吃一惊”。因此,请听取他们的建议,风险自负。
罗伯特·哈维

Answers:


15

首先,我不得不指出这一点,std::exception并且它的子项是很久以前设计的。如果今天设计的零件很多(几乎可以肯定)可能会有所不同。

不要误会我的意思:设计的某些部分效果很好,并且是如何为C ++设计异常层次结构的很好的示例(例如,与大多数其他类不同,它们都共享一个共同根)。

专门针对logic_error,我们有一个难题。一方面,如果您在此问题上有任何合理的选择,那么您所引用的建议是正确的:通常最好尽可能快且嘈杂地进行故障排除,以便对其进行调试和纠正。

不管是好是坏,很难围绕您应该定义的内容定义标准库 通常该做什么。如果abort()在给定错误输入的情况下将其定义为退出程序(例如,调用),则在这种情况下总是会发生这种情况-实际上,在很多情况下,这可能并不是正确的选择,至少在已部署的代码中。

这将适用于具有(至少是软)实时要求的代码,并且对错误输出的惩罚最小。例如,考虑一个聊天程序。如果它正在解码某些语音数据并得到一些不正确的输入,则与完全关闭的程序相比,用户可能会更高兴地在输出中保留一毫秒的静态值。同样,在进行视频播放时,对于一两个帧的某些像素产生错误的值可能比接受程序总会退出(因为输入流已损坏)更容易接受。

至于是否使用异常报告某些类型的错误:是的,根据操作的方式,同一操作是否可以视为异常。

另一方面,您也错了-使用标准库并不会(有必要)将这一决定强加给您。在打开文件的情况下,通常会使用iostream。Iostream也不完全是最新,最出色的设计,但是在这种情况下,它们做对了:它们允许您设置错误模式,因此您可以控制是否无法打开文件而导致抛出异常。因此,如果您的文件确实应用程序所必需的,而无法打开它意味着您必须采取一些严肃的补救措施,那么,如果它无法打开该文件,则可能引发异常。对于大多数文件,您将尝试打开它们,如果它们不存在或不可访问,它们将失败(这是默认设置)。

至于您的决定方式:我认为没有简单的答案。不管好坏,“例外情况”并不总是容易衡量的。当然,在某些容易确定的情况下必须是[un]例外的情况,但在某些情况下(可能总是会)有很多问题值得商requires,或者需要了解超出手头功能范围的上下文。对于这种情况,至少值得考虑一下与iostream的这一部分大致相似的设计,在该设计中,用户可以决定失败是否导致抛出异常。另外,也可以完全有两组独立的函数(或类等),其中一组将引发异常以指示失败,另一组将使用其他方式。如果你走那条路,


9

基于字符串的std :: bitset构造函数仅自C ++ 11起存在,因此在设计时应考虑惯用异常。另一方面,有人告诉我,从根本上不应该使用逻辑错误。

您可能不相信这一点,但是,不同的C ++编码人员不同意。这就是为什么FAQ讲一件事但标准库不同意的原因。

FAQ提倡崩溃,因为它更容易调试。如果崩溃并获得核心转储,您将拥有应用程序的确切状态。如果抛出异常,您将失去很多状态。

标准库采用的理论是,赋予编码器捕获能力并可能处理错误的能力比调试能力更为重要。

可能是例外,可能不是。该函数本身肯定通常不知道,它不知道在哪种上下文中调用它。

这里的想法是,如果您的函数不知道这种情况是否异常,则不应抛出异常。它应该通过其他机制返回错误状态。一旦到达程序中知道状态异常的位置,则应抛出异常。

但这有其自身的问题。如果从函数返回错误状态,则您可能不记得要检查它,错误将静默地通过。这导致某些人放弃异常例外规则,转而对任何类型的错误状态抛出异常。

总的来说,关键是不同的人对何时抛出异常有不同的想法。您不会找到一个统一的想法。即使有人会教条地断言这是处理异常的正确方法,但也没有单一的共识理论。

您可以抛出异常:

  1. 决不
  2. 到处
  3. 仅针对程序员错误
  4. 永远不会出现程序员错误
  5. 仅在非常规(异常)故障期间

并在互联网上找到同意您的人。您必须采用适合自己的样式。


可能值得一提的是,人们在讲授关于例外表现不佳的语言时,已经广泛提倡只在情况确实异常的情况下才使用例外的建议。C ++不是这些语言之一。
Jules

1
@Jules-现在,(性能)肯定值得您自己回答,您可以在其中支持自己的主张。C ++异常表现肯定的问题,可能更多,也许更少比其他地方,但写明“C ++是不是这些语言[例外情形有表现不佳]的一个”肯定是值得商榷的。
马丁·巴

1
@MartinBa-与Java相比,C ++异常性能要快几个数量级。基准表明,将异常抛出1级的性能比处理C ++中的返回值慢50倍左右,而在Java中则慢1000倍以上。在这种情况下,为Java编写的建议不应在没有额外思考的情况下应用于C ++,因为两者之间的性能差异要大得多。也许我应该写“极端糟糕的表现”而不是“糟糕的表现”。
Jules

1
@Jules-感谢这些数字。(任何来源?)我可以相信,因为Java(和C#)需要捕获堆栈跟踪,这肯定看起来确实很昂贵。我仍然认为您的最初反应有点误导,因为我认为即使是50倍的减速也相当沉重。以面向性能的语言(如C ++)编写。
马丁·巴

2

还写了许多其他好的答案,我只想补充一点。

传统答案(尤其是在编写ISO C ++ FAQ时)主要比较“ C ++异常”与“ C样式返回码”。第三种选择,“返回某种类型的复合值,例如a structunion,或现今,boost::variant或(建议的)std::expected

在C ++ 11之前,“返回复合类型”选项通常非常弱。因为没有移动语义,所以将内容复制进出结构可能非常昂贵。在那时,用RVO设置代码风格对语言来说非常重要。以获得最佳性能。异常就像有效地返回复合类型的简单方法,否则将非常困难。

IMO,在C ++ 11之后,此选项“返回有区别的联盟”,类似于惯用法 Result<T, E>如今的Rust使用应在C ++代码中得到更多的青睐。有时,它确实是一种更简单,更方便的错误指示样式。除例外情况外,总有这种可能性,即在重构之前未抛出的函数可能会在重构后突然开始抛出,并且程序员并不总是很好地记录这些东西。当将错误表示为有区别的并集中的返回值的一部分时,它将大大减少程序员仅忽略错误代码的机会,这通常是对C样式错误处理的批评。

通常的Result<T, E>工作方式类似于boost可选。您可以使用进行测试,operator bool如果它是一个值或错误。然后使用say operator *来访问值或其他“获取”功能。通常不检查访问速度。但是您可以做到这一点,以便在调试版本中检查访问权限,并通过断言确保实际上存在值而不是错误。这样,任何不正确检查错误的人都会得到一个硬断言,而不是一些更隐蔽的问题。

另一个优点是,与不被异常捕获的异常不一样,它只是在堆栈中向上飞了一段任意距离,采用这种样式,当函数开始发信号通知以前没有发生过的错误时,除非代码已更改为可以处理。这使问题更加响亮-传统的“未捕获的异常”问题更像是编译时错误,而不是运行时错误。

我已经成为这种风格的忠实粉丝。通常,我现在使用this或exception。但是我试图将例外限制在主要问题上。对于诸如解析错误之类的东西,我尝试返回expected<T>例如。如今,在某些相对较小的问题“字符串无法转换为数字”的情况下,诸如std::stoi和之类boost::lexical_cast的事件会引发C ++异常,这在我看来似乎很糟糕。


1
std::expected还是不被接受的提议,对吧?
马丁·巴

您是对的,我想它尚未被接受。但是有几种开放源代码实现在浮动,我猜自己已经做了几次。它比执行变体类型复杂,因为只有两种可能的状态。主要的设计考虑因素是,您想要什么确切的接口,您是否希望它像Andrescu的Expected <T>一样,其中错误对象实际上应该是an exception_ptr,还是只想使用某种结构类型或某种东西?像那样。
克里斯·贝克

安德烈·亚历山德列斯库( Andrei Alexandrescu)的演讲在这里:channel9.msdn.com/Shows/Going+Deep / ...他详细显示了如何构造这样的类以及您可能有哪些注意事项。
克里斯·贝克

该建议[[nodiscard]] attribute将对这种错误处理方法有所帮助,因为它可以确保您不会简单地无意中忽略错误结果。
CodesInChaos

-是的,我知道机管局的讲话。我发现设计很奇怪,因为要解压缩它(except_ptr),您必须在内部引发异常。我个人认为这样的工具应该完全独立于执行。只是一句话。
马丁·巴

1

这是设计的一部分,是一个非常主观的问题。而且由于设计本质上是艺术,所以我更喜欢讨论这些事情而不是辩论(我并不是说您在辩论)。

对我来说,例外情况有两种-处理资源和处理关键操作的情况。关键问题取决于当前的问题,并且在许多情况下取决于程序员的观点。

无法获取资源是引发异常的最佳候选者。资源可以是内存,文件,网络连接或其他任何基于您的问题和平台的资源。现在,释放资源失败是否值得例外?好吧,这再次取决于。在释放内存失败的情况下,我没有做任何事情,因此不确定这种情况。但是,作为资源释放的一部分删除文件可能会失败,并且对我来说也是失败的,并且该失败通常与在多进程应用程序中保持打开状态的其他进程有关。我猜想其他资源在发布期间可能会像文件一样失败,通常是设计缺陷会导致此问题,因此解决该问题要比引发异常更好。

然后是更新资源。至少对我而言,这一点与应用程序的关键操作方面密切相关。想象一下一个Employee类,该类具有一个函数UpdateDetails(std::string&),该函数会根据给定的逗号分隔的字符串修改详细信息。与释放内存失败类似,我发现很难想象成员变量值的分配失败,原因是我缺乏在可能发生此类错误的领域的经验。但是,像UpdateDetailsAndUpdateFile(std::string&)其名称所示功能确实会失败。这就是我所说的关键操作。

现在,您必须查看所谓的关键操作是否可以保证引发异常。我的意思是,文件的更新是否像析构函数那样在最后进行,还是仅仅是每次更新后都进行了偏执?是否存在一个定期写入未写对象的后备机制?我的意思是,您必须评估操作的关键性。

显然,有许多关键操作与资源无关。如果UpdateDetails()为给出了错误的数据,它将不会更新详细信息,并且必须告知失败,因此您将在此处抛出异常。但是想象一个像这样的函数GiveRaise()。现在,如果所说的雇员很幸运有一个尖顶的上司,并且不会得到加薪(在编程方面,某些变量的值阻止了这种情况的发生),则该函数实质上已失败。您会在这里抛出异常吗?我的意思是,您必须评估例外的必要性。

对我而言,一致性是我的设计方法而非类的可用性。我的意思是,我不认为“所有Get函数都必须执行此操作,而所有Update函数都必须对此执行”,但我想看看某个特定函数是否符合我的方法中的某个想法。从表面上看,这些类看起来有点“偶然”,但是只要用户(主要是来自其他团队的同事)大声疾呼或询问,我就会解释,他们似乎很满意。

我看到许多人基本上都用异常替换了返回值,因为他们使用的是C ++而不是C,并且它提供了“错误处理的很好的分离”等,并敦促我停止“混合”语言等。我通常远离这样的人。


1

首先,正如其他人所说,在C ++,恕我直言,事情并没有那么清晰,主要是因为C ++的要求和限制比其他语言(尤其是C ++)多一些。具有“相似”异常问题的C#和Java。

我将在std :: stof示例中公开:

将空字符串传递给std :: stof(将抛出invalid_argument)不是编程错误

如我所见,此函数的基本协定是它试图将其参数转换为浮点数,并且任何这样做的失败均由异常报告。两种可能的异常都源自logic_error但不是程序员错误的意思,而是“输入永远无法转换为浮点数”的意思。

在这里,可能有人说a logic_error表示(运行时)输入,尝试对其进行转换始终是错误的-但是函数的工作是确定并告诉您(通过异常)。

旁注:在这种情况下,runtime_error 可以将a视为在给函数提供相同输入的情况下,理论上可以针对不同的运行成功的事物。(例如,文件操作,数据库访问等)

补充说明:C ++ regex库选择从中导出错误,runtime_error尽管在某些情况下可以将其分类为此处(无效的regex模式)。

恕我直言,这只是表明在C ++分组logic_runtime_错误是相当模糊的,并且在一般情况下并没有太大帮助(*)-如果您需要处理特定的错误,则可能需要抓住两者之间的差点。

(*):这是不是说,单件的代码不应该是一致的,但不管你扔runtime_logic_custom_出头真的没有那么重要,我想。


要评论stofbitset

这两个函数都将字符串作为参数,在两种情况下均为:

  • 检查调用方给定的字符串是否有效非常简单(例如,最坏的情况下,您必须复制函数逻辑;对于位集,尚不能立即清除空字符串是否有效,因此请ctor决定)
  • 函数已经负责“解析”字符串,因此它已经必须验证字符串,因此有意义的是它报告了错误以统一“使用”字符串(在两种情况下,这都是一个例外) 。

经常出现异常的规则是“仅在特殊情况下使用异常”。但是,库函数应该如何知道哪些情况例外?

恕我直言,此语句有两个根源:

性能:如果在关键路径中调用了函数,并且“例外”情况并不特殊,即,大量的传递将涉及引发异常,那么每次为异常清盘机器付费都没有道理,并且可能太慢。

错误处理局部性:如果一个函数被调用,异常立即引起和处理,那么在抛出异常的小点,因为错误处理会更加冗长catch比使用if

例:

float readOrDefault;
try {
  readOrDefault = stof(...);
} catch(std::exception&) {
  // discard execption, just use default value
  readOrDefault = 3.14f; // 3.14 is the default value if cannot be read
}

这里是TryParsevs 之类的功能Parse发挥作用的地方:一种版本是当本地代码期望已解析的字符串有效时,一种版本是当本地代码假定实际期望(即非例外)解析失败时。

确实,stof只是(定义为)的包装strtof,因此,如果您不希望出现异常,请使用该包装。


因此,我应该如何决定是否应为特定功能使用异常?

恕我直言,您有两种情况:

  • “库”之类的函数(经常在不同的上下文中重复使用):您基本上无法决定。可能同时提供两个版本,可能是一个报告错误的版本,另一个是将返回的错误转换为异常的包装器。

  • “应用程序”功能(特定于一堆应用程序代码,可能会重用一些,但受应用程序错误处理风格的限制,等等):在这里,它通常应该很清楚。如果调用函数的代码路径以合理且有用的方式处理异常,请使用异常报告任何(但请参见下文)错误。如果应用程序代码对于错误返回样式更容易读写,则一定要使用它。

当然,介于两者之间-只需使用需要的内容并记住YAGNI。


最后,我想我应该回到FAQ声明,

不要使用throw来指示函数使用中的编码错误。使用断言或其他机制将进程发送到调试器或使进程崩溃...

对于所有明确表示某件事已严重混乱或调用代码显然不知道自己在做什么的错误,我表示赞同。

但是这是适当的往往是高度专用的,因此见上图书馆领域与应用领域。

这是关于是否以及如何验证调用先决条件的问题,但我不愿赘述,回答已经太久了:-)

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.