你(真的)编写异常安全代码吗?

Fru*_*nsi 308 c++ exception-handling

异常处理(EH)似乎是当前的标准,并且通过搜索网络,我找不到任何试图改进或替换它的新颖想法或方法(好吧,存在一些变化,但没有新颖的).

虽然大多数人似乎忽略它或只是接受它,但EH 一些巨大的缺点:代码看不到异常,它会创建许多可能的退出点.乔尔在软件上写了一篇关于它文章.比较goto完美,它让我再次思考EH.

我尽量避免使用EH,只使用返回值,回调或任何适合目的的东西.但是当你必须编写可靠的代码时,你现在就无法忽略EH:它从new可能抛出异常开始,而不是仅仅返回0(就像过去一样).这使得任何C++代码行都容易受到异常的影响.然后C++基础代码中的更多地方抛出异常...... std lib执行它,依此类推.

这感觉就像走在摇摇欲坠的地面上.所以,现在我们被迫关注异常!

但它很难,真的很难.你必须学会​​编写异常安全代码,即使你有一些经验,它仍然需要仔细检查任何一行代码是安全的!或者你开始在任何地方放置try/catch块,这会使代码混乱,直到它达到不可读状态.

EH取代了旧的干净确定性方法(返回值..),它只有一些但可以理解且易于解决的缺点,在您的代码中创建许多可能的退出点的方法,以及如果您开始编写捕获异常的代码(您是什么的)在某些时候被迫做某事),然后它甚至通过你的代码创建了许多路径(catch块中的代码,考虑一个服务器程序,你需要除了std :: cerr之外的日志工具..).EH有优势,但这不是重点.

我的实际问题:

  • 你真的写异常安全代码吗?
  • 您确定最后一个"生产就绪"代码是异常安全的吗?
  • 你能确定吗,它是吗?
  • 你知道和/或实际使用有效的替代品吗?

pae*_*bal 514

你的问题断言,"编写异常安全的代码非常困难".我先回答你的问题,然后回答他们背后隐藏的问题.

回答问题

你真的写异常安全代码吗?

我当然是了.

这就是Java作为C++程序员(缺乏RAII语义)对我失去了很多吸引力原因,但我很离题:这是一个C++问题.

事实上,当您需要使用STL或Boost代码时,这是必要的.例如,C++线程(boost::threadstd::thread)将抛出异常以正常退出.

您确定最后一个"生产就绪"代码是异常安全的吗?

你能确定吗,它是吗?

编写异常安全的代码就像编写无错误的代码一样.

您不能100%确定您的代码是异常安全的.但是,然后,你使用众所周知的模式,并避免众所周知的反模式来争取它.

你知道和/或实际使用有效的替代品吗?

在C++ 中没有可行的替代方案(即你需要恢复到C并避免使用C++库,以及像Windows SEH这样的外部惊喜).

编写异常安全代码

编写异常安全的代码,你必须知道首先每个你写的指令是什么级别的异常安全的.

例如,a new可以抛出异常,但是分配内置(例如int或指针)不会失败.交换永远不会失败(不要写掉投掷交换),std::list::push_back可以投掷...

例外保证

首先要理解的是,您必须能够评估所有功能提供的异常保证:

  1. none:您的代码永远不应该提供.此代码将泄漏所有内容,并在抛出的第一个异常时分解.
  2. basic:这是您必须至少提供的保证,也就是说,如果抛出异常,没有资源泄露,并且所有对象仍然是完整的
  3. strong:处理将成功,或抛出异常,但如果它抛出,那么数据将处于相同的状态,就好像处理根本没有开始一样(这为C++提供了事务处理能力)
  4. nothrow/nofail:处理将成功.

代码示例

下面的代码看起来像是正确的C++,但实际上,它提供了"无"保证,因此它不正确:

void doSomething(T & t)
{
   if(std::numeric_limits<int>::max() > t.integer)  // 1.   nothrow/nofail
      t.integer += 1 ;                              // 1'.  nothrow/nofail
   X * x = new X() ;                // 2. basic : can throw with new and X constructor
   t.list.push_back(x) ;            // 3. strong : can throw
   x->doSomethingThatCanThrow() ;   // 4. basic : can throw
}
Run Code Online (Sandbox Code Playgroud)

我用这种分析编写了所有代码.

提供的最低保证是基本的,但是,每条指令的顺序使整个函数"无",因为如果3.抛出,x将泄漏.

要做的第一件事就是使函数"基本",即将x放入智能指针,直到它被列表安全拥有:

void doSomething(T & t)
{
   if(std::numeric_limits<int>::max() > t.integer)  // 1.   nothrow/nofail
      t.integer += 1 ;                              // 1'.  nothrow/nofail
   std::auto_ptr<X> x(new X()) ;    // 2.  basic : can throw with new and X constructor
   X * px = x.get() ;               // 2'. nothrow/nofail
   t.list.push_back(px) ;           // 3.  strong : can throw
   x.release() ;                    // 3'. nothrow/nofail
   px->doSomethingThatCanThrow() ;  // 4.  basic : can throw
}
Run Code Online (Sandbox Code Playgroud)

现在,我们的代码提供了"基本"保证.没有任何东西会泄漏,所有物体都将处于正确的状态.但我们可以提供更多,即强有力的保证.这是它可能变得昂贵的地方,这就是为什么并非所有 C++代码都很强大的原因.我们来试试吧:

void doSomething(T & t)
{
   // we create "x"
   std::auto_ptr<X> x(new X()) ;    // 1. basic : can throw with new and X constructor
   X * px = x.get() ;               // 2. nothrow/nofail
   px->doSomethingThatCanThrow() ;  // 3. basic : can throw

   // we copy the original container to avoid changing it
   T t2(t) ;                        // 4. strong : can throw with T copy-constructor

   // we put "x" in the copied container
   t2.list.push_back(px) ;          // 5. strong : can throw
   x.release() ;                    // 6. nothrow/nofail
   if(std::numeric_limits<int>::max() > t2.integer)  // 7.   nothrow/nofail
      t2.integer += 1 ;                              // 7'.  nothrow/nofail

   // we swap both containers
   t.swap(t2) ;                     // 8. nothrow/nofail
}
Run Code Online (Sandbox Code Playgroud)

我们重新订购了这些操作,首先创建并设置X其正确的价值.如果任何操作失败,则t不会被修改,因此,操作1到3可以被认为是"强":如果抛出某些东西,t则不会被修改,并且X不会泄漏,因为它由智能指针拥有.

然后,我们创建一个副本t2t,如果事情上抛出这个副本从操作工作4至7,t2被修改,但之后,t仍然是原来的.我们仍然提供强有力的保证.

然后,我们交换tt2.交换操作应该不是在C++中进行操作,所以让我们希望你所写的交换T是不可靠的(如果不是,则重写它以便它不是).

因此,如果我们到达函数的末尾,一切都成功(不需要返回类型)并t具有其例外值.如果失败,那么t仍然是其原始值.

现在,提供强有力的保证可能会非常昂贵,所以不要努力为您的所有代码提供强有力的保证,但如果您可以不花费成本(并且C++内联和其他优化可以使所有代码无法成本) ,然后去做.功能用户会感谢你.

结论

编写异常安全的代码需要一些习惯.您需要评估您将使用的每条指令所提供的保证,然后,您需要评估指令列表提供的保证.

当然,C++编译器不会备份保证(在我的代码中,我提供了作为@warning doxygen标签的保证),这有点令人遗憾,但它不应该阻止您尝试编写异常安全的代码.

正常失败与错误

程序员如何保证无故障功能永远成功?毕竟,该功能可能有一个错误.

这是真的.异常保证应该由无错误的代码提供.但是,在任何语言中,调用函数都假定函数没有错误.没有合理的代码可以保护自己不受错误的影响.尽可能地编写代码,然后提供保证,假设它没有错误.如果有错误,请更正错误.

例外情况是异常处理失败,而不是代码错误.

最后的话

现在,问题是"这值得吗?".

当然如此.知道函数不会失败的"nothrow/no-fail"函数是一个很大的好处.对于"强"函数也是如此,它使您能够使用事务语义编写代码,如数据库,具有提交/回滚功能,提交是代码的正常执行,抛出异常是回滚.

那么,"基本"是您应该提供的最低保证.C++是一种非常强大的语言,具有其范围,使您能够避免任何资源泄漏(垃圾收集器会发现很难为数据库,连接或文件句柄提供).

所以,据我所知,这值得的.

编辑2010-01-29:关于非投掷交换

nobar做了一个我相信的评论是非常相关的,因为它是"你如何编写异常安全代码"的一部分:

  • [我]交换永远不会失败(甚至不写掉投掷交换)
  • [nobar]这是自定义编写swap()函数的一个很好的建议.但应注意,std::swap()根据内部使用的操作,可能会失败

默认情况下std::swap将进行复制和分配,对于某些对象,可以进行复制和分配.因此,默认交换可以抛出,既可用于您的类,也可用于STL类.就C++标准而言,交换操作是vector,dequelist不会抛出,而它可以用于map比较仿函数可以抛出复制结构(参见C++编程语言,特别版,附录E,E.4.3) .Swap).

查看向量交换的Visual C++ 2008实现,如果两个向量具有相同的分配器(即正常情况),则向量的交换将不会抛出,但如果它们具有不同的分配器,则将进行复制.因此,我认为它可以抛弃最后一种情况.

所以,原始文本仍然存在:不要写一个抛出交换,但必须记住nobar的注释:确保你交换的对象有一个非投掷交换.

编辑2011-11-06:有趣的文章

Dave Abrahams给了我们基本/强/非保证,在一篇文章中描述了他使STL异常安全的经验:

http://www.boost.org/community/exception_safety.html

请看第7点(异常安全的自动化测试),他依靠自动化单元测试来确保每个案例都经过测试.我想这部分是对作者的问题的一个很好的答案:" 你能确定,它是什么? ".

编辑2013-05-31:来自dionadar的评论

t.integer += 1;是不能保证不会发生溢出而不是异常安全,事实上可能在技术上调用UB!(有符号溢出是UB:C++ 11 5/4"如果在求值表达式期间,结果未在数学上定义或未在其类型的可表示值范围内,则行为未定义.")注意无符号整数不会溢出,而是在模数为2 ^#位的等价类中进行计算.

Dionadar指的是以下行,它确实具有未定义的行为.

   t.integer += 1 ;                 // 1. nothrow/nofail
Run Code Online (Sandbox Code Playgroud)

这里的解决方案是std::numeric_limits<T>::max()在执行添加之前验证整数是否已经处于其最大值(使用).

我的错误将出现在"正常失败与错误"部分,即一个错误.它不会使推理无效,并不意味着异常安全的代码是无用的,因为无法实现.您无法保护自己免受计算机关闭,编译器错误,甚至您的错误或其他错误.你无法达到完美,但你可以尝试尽可能接近.

我用Dionadar的评论更正了代码.

  • "交换永远不会失败".对于自定义编写的swap()函数,这是一个很好的建议.但应该注意,std :: swap()可能会因内部使用的操作而失败. (8认同)
  • @Mehrdad:"终于"确实完成了你想要实现的目标:当然可以.而且因为使用`finally`很容易产生脆弱的代码,所以在Java 7中引入了"try-with-resource"概念*(即,在C#的`using`和C++析构函数之后的3年之后10年).这是我批评的这种脆弱.至于"只是因为[终于]不符合你的口味(RAII与我的不相符,[...])并不意味着它"失败"`:在这一点上,业界不同意你的口味,就像垃圾一样收集的语言倾向于添加RAII启发的语句(C#的`using`和Java的'try`). (7认同)
  • 非常感谢!我仍然希望有不同的东西.但我接受你坚持使用C++和你的详细解释的答案.你甚至反驳了我的"这使得任何C++代码行都容易受到异常的影响"-offense.这种逐行分析毕竟是有意义的......我必须考虑一下. (6认同)
  • @Mehrdad:`RAII与我的不匹配,因为它每个单独的时间都需要一个新的结构,这有时是乏味的:不,它没有.您可以使用智能指针或实用程序类来"保护"资源. (5认同)
  • @Mehrdad:"它迫使你为你可能不需要包装器的东西做一个包装器" - 当以RAII方式编码时,你不需要任何类型的包装器:ressource是对象,对象的生命周期是资源生命周期.但是,我来自C++世界,我目前正在努力学习Java项目.与C++中的RAII相比,Java中的"手动资源管理"是一个很好的例子!我的意见,但Java很久以前就"自动资源管理"交换了"自动内存管理". (5认同)
  • @frunsi:异常安全性有趣.例如,当你有一个只调用nofail/nothrow代码的函数时,当你意识到整个函数时,函数也是nofail/nothrow,那么就会有一点点"闪亮",以某种方式使你的日子更加明亮.对于强大的功能也可以这样说.如果你有一个似乎做了很多的功能,并且你可以提供强大/提交/回滚保证,你可以从新的角度看到你的代码.我开始将SQLite3 C代码包装成C++代码,并为事务概念提供强大/无保证,既有趣又有益. (4认同)
  • @Mehrdad:至于"请参阅我的问题http://stackoverflow.com/questions/194261以获取Java失败的示例",请再次参考链接的问题,以及如何在生成健壮的代码时几乎不可能多个资源在Java中的相同函数中使用,而不使用Java 7"try-with-resource"模式. (4认同)
  • @Malcolm:"真的,这个答案让我更加欣赏Java:相信你是错的.Java使得异常安全的代码比C++更难.例如,C++中没有`finally`,这不是错误...请参阅我的问题http://stackoverflow.com/questions/194261/,了解Java如何与C#和(与C#相比)失败的示例当然)C++. (3认同)
  • @ESRogs:`"t.integer + = 1; // 1. nothrow/nofail"如果t为null,这不会抛出异常吗?`:`t`作为参考传递,所以它是一个值,而不是一个指针.在C++中,解除引用的NULL指针不会抛出异常,而是具有未定义的行为(通常它会在第一次真正使用时崩溃) (3认同)
  • @Jeroen Dirks:通常,new运算符会抛出内存分配失败,所以不需要自己从构造函数中手动抛出它们.现在,在某些情况下(例如,您使用malloc进行分配),如果您想要正确运行,则需要手动抛出异常.通常,存在更大的问题,但是如果你正在编写一个库,那么你就不能用"如果它去那里,那么抱歉,我的代码仅适用于正常情况"这样的假设编写代码.因此,即使操作系统正在颠簸,抛出异常仍然是恕我直言的正确的事情. (3认同)
  • `t.integer + = 1;`不能保证不会发生溢出而不是异常安全,实际上可能在技术上调用UB!(有符号溢出是UB:C++ 11 5/4"如果在求值表达式期间,结果未在数学上定义或未在其类型的可表示值范围内,则行为未定义.")注意无符号整数不会溢出,而是在模数为2 ^#位的等价类中进行计算. (3认同)
  • @frunsi:听起来像是一个很好的学习练习,但总的来说不实用.一旦你学会了异常安全(以上是一个非常好的答案),你就会开始为自己看到安全和不安全的结构. (2认同)
  • @Mehrdad:好的,不需要讨论你的口味了. (2认同)
  • @AliNaciErdem:Google C ++样式指南有点废话,所以我不建议您使用它。现在,异常安全代码样式还可用于不会引发异常的代码,因为它还保护您免受早期返回,中断,继续等的侵害。如果您的代码可以处理异常,那么它几乎可以处理任何同步事件。 (2认同)

Joh*_*Joh 31

在C++中编写异常安全的代码并不是要使用大量的try {} catch {}块.它是关于记录代码提供什么样的保证.

我建议阅读Herb Sutter的本周大师系列,特别是分期59,60和61.

总而言之,您可以提供三种级别的异常安全性:

  • 基本:当您的代码抛出异常时,您的代码不会泄漏资源,并且对象仍然是可破坏的.
  • 强:当您的代码抛出异常时,它会使应用程序的状态保持不变.
  • 没有抛出:你的代码永远不会抛出异常.

就个人而言,我发现这些文章的时间很晚,因此我的C++代码绝对不是例外.

  • +1 OP将处理异常(捕获它们)与异常安全混淆(通常更多关于RAII) (8认同)

bma*_*ies 18

我们中的一些人已经使用例外超过20年.例如,PL/I有它们.它们是一种新的危险技术的前提似乎对我来说是个问题.


D.S*_*ley 17

首先(正如Neil所说),SEH是微软的结构化异常处理.它与C++中的异常处理类似但不完全相同.实际上,如果在Visual Studio中需要它,则必须启用C++异常处理 - 默认行为不保证在所有情况下都会销毁本地对象!在任何一种情况下,异常处理并不是真的更难,它只是不同.

现在为您提出实际问题.

你真的写异常安全代码吗?

是.在所有情况下,我都努力寻求异常安全代码.我使用RAII技术宣传资源的范围访问(例如,boost::shared_ptr用于内存,boost::lock_guard用于锁定).通常,RAII范围保护技术的一致使用将使异常安全代码更容易编写.诀窍是了解存在的内容以及如何应用它.

您确定最后一个"生产就绪"代码是异常安全的吗?

不,这是安全的.我可以说,由于几年的24/7活动中的异常,我没有看到过程故障.我不希望完美的代码,只是编写良好的代码.除了提供异常安全性之外,上述技术还以几乎不可能用try/ catch块实现的方式保证了正确性.如果您正在捕获最高控制范围(线程,进程等)中的所有内容,那么您可以确保在异常(大多数情况下)中继续运行.同样的技术也将帮助您继续运行正确的异常的脸而不try/ catch块随处可见.

你能确定它是吗?

是.您可以通过彻底的代码审核来确定,但没有人真正做到这一点吗?定期的代码审查和细心的开发人员在很长的路要走.

你知道和/或实际使用有效的替代品吗?

多年来我尝试了一些变化,例如在高位(ala HRESULTs)中编码状态或那种可怕的setjmp() ... longjmp()黑客.这两种方式在实践中都以完全不同的方式分解.


最后,如果您习惯于应用一些技巧并仔细考虑在异常情况下实际可以执行某些操作的位置,那么最终会得到非常易读且异常安全的代码.您可以按照以下规则进行总结:

  • 您只想查看try/ catch何时可以对特定异常执行某些操作
  • 你几乎不想看到原始代码newdelete代码
  • 避开std::sprintf,snprintf和一般的阵列-使用std::ostringstream的格式,代之以阵列std::vectorstd::string
  • 如有疑问,请在推出自己的功能之前在Boost或STL中查找功能

我只能建议您学习如何正确使用异常,如果计划用C++编写,请忘记结果代码.如果您想避免异常,您可能需要考虑用另一种语言编写,或者使用它们,或者使它们安全.如果你想真正学习如何充分利用C++,请阅读Herb Sutter,Nicolai JosuttisScott Meyers的一些书.


AnT*_*AnT 9

在"任何行可以抛出"的假设下,不可能编写异常安全的代码.异常安全代码的设计主要依赖于您应该在代码中期望,观察,遵循和实现的某些合同/保证.保证永不抛出的代码是绝对必要的.还有其他种类的例外保证.

换句话说,创建异常安全代码在很大程度上是程序设计的问题,而不仅仅是普通编码问题.


小智 8

  • 你真的写异常安全代码吗?

好吧,我当然打算.

  • 您确定最后一个"生产就绪"代码是异常安全的吗?

我确信使用异常构建的24/7服务器可以全天候运行并且不会泄漏内存.

  • 你能确定吗,它是吗?

很难确定任何代码是否正确.通常,人们只能按结果去做

  • 你知道和/或实际使用有效的替代品吗?

没有.使用例外比我在编程过去30年中使用的任何替代方案更清晰,更容易.

  • 这个答案毫无价值. (29认同)
  • @MattJoiner然后问题没有价值. (6认同)
  • 没有勺子 (4认同)

Mar*_*sey 5

抛开SEH和C++异常之间的混淆,您需要意识到可以随时抛出异常,并在编写代码时考虑到这一点.对异常安全的需求在很大程度上推动了RAII,智能指针和其他现代C++技术的使用.

如果你遵循完善的模式,编写异常安全的代码并不是特别困难,事实上它比编写在所有情况下都能正确处理错误返回的代码更容易.