从析构函数中抛出异常

时间:2008-09-24 21:34:01

标签: c++ exception destructor raii

大多数人都说从不从析构函数中抛出异常 - 这样做会导致未定义的行为。 Stroustrup指出“向量析构函数显式地为每个元素调用析构函数。这意味着如果元素析构函数抛出,向量破坏失败......实际上没有好的方法来防止从析构函数抛出的异常,因此,如果元素析构函数抛出“(来自附录E3.2),则库不保证。

This article似乎另有说法 - 投掷析构函数或多或少都没问题。

所以我的问题是这样的 - 如果从析构函数中抛出会导致未定义的行为,那么如何处理析构函数期间发生的错误?

如果在清理操作期间发生错误,您是否只是忽略它?如果它是一个可能在堆栈中处理但在析构函数中不正确的错误,那么从析构函数中抛出异常是否有意义?

显然,这类错误很少见,但可能。

16 个答案:

答案 0 :(得分:182)

从析构函数中抛出异常是危险的 如果另一个异常已经传播,则应用程序将终止。

#include <iostream>

class Bad
{
    public:
        // Added the noexcept(false) so the code keeps its original meaning.
        // Post C++11 destructors are by default `noexcept(true)` and
        // this will (by default) call terminate if an exception is
        // escapes the destructor.
        //
        // But this example is designed to show that terminate is called
        // if two exceptions are propagating at the same time.
        ~Bad() noexcept(false)
        {
            throw 1;
        }
};
class Bad2
{
    public:
        ~Bad2()
        {
            throw 1;
        }
};


int main(int argc, char* argv[])
{
    try
    {
        Bad   bad;
    }
    catch(...)
    {
        std::cout << "Print This\n";
    }

    try
    {
        if (argc > 3)
        {
            Bad   bad; // This destructor will throw an exception that escapes (see above)
            throw 2;   // But having two exceptions propagating at the
                       // same time causes terminate to be called.
        }
        else
        {
            Bad2  bad; // The exception in this destructor will
                       // cause terminate to be called.
        }
    }
    catch(...)
    {
        std::cout << "Never print this\n";
    }

}

这基本归结为:

任何危险的东西(即可能抛出异常)都应该通过公共方法(不一定是直接的)来完成。然后,您的类的用户可以通过使用公共方法并捕获任何潜在的异常来处理这些情况。

然后析构函数将通过调用这些方法来完成对象(如果用户没有明确地这样做),但是捕获和删除任何异常throw(在尝试修复问题之后)。

因此,您实际上将责任传递给用户。如果用户能够纠正异常,他们将手动调用相应的函数并处理任何错误。如果对象的用户不担心(因为对象将被销毁),那么析构函数将留下来处理业务。

一个例子:

的std :: fstream的

close()方法可能会抛出异常。 如果文件已被打开,析构函数会调用close(),但要确保任何异常都不会从析构函数中传播出来。

因此,如果文件对象的用户想要对与关闭文件相关的问题进行特殊处理,他们将手动调用close()并处理任何异常。另一方面,如果他们不关心那么析构函数将留给处理这种情况。

Scott Myers在他的“Effective C ++”一书中有一篇关于这个主题的优秀文章

编辑:

显然也在“更有效的C ++”中 Item 11: Prevent exceptions from leaving destructors

答案 1 :(得分:51)

抛出析构函数可能会导致崩溃,因为这个析构函数可能会被称为“堆栈展开”的一部分。 堆栈展开是在抛出异常时发生的过程。 在此过程中,自“尝试”以及抛出异常之后被推入堆栈的所有对象将被终止 - &gt;他们的毁灭者将被召唤。 并且在此过程中,不允许另一个异常抛出,因为一次不能处理两个异常,因此,这将引发对abort()的调用,程序将崩溃并且控制将返回到OS。

答案 2 :(得分:46)

我们必须区分,而不是盲目地关注特定案例的一般建议。

请注意,以下忽略对象容器的问题以及面对容器内多个对象时要做什么。 (并且可以部分忽略它,因为有些物体不适合装入容器。)

当我们将类拆分为两种类型时,整个问题变得更容易思考。课程师可以有两种不同的职责:

  • (R)发布语义(又称释放内存)
  • (C) commit 语义(又名 flush 文件到磁盘)

如果我们以这种方式查看问题,那么我认为可以认为(R)语义不应该导致dtor的异常,因为a)我们无法对它做任何事情和b)许多自由资源操作甚至不提供错误检查,例如 void free(void* p);

具有(C)语义的对象,如需要成功刷新其数据的文件对象或在dtor中执行提交的(“范围保护”)数据库连接属于不同类型:我们可以< / em>对错误做一些事情(在应用程序级别上),我们真的不应该继续,好像什么也没发生。

如果我们遵循RAII路线并且允许在其中具有(C)语义的对象,我认为我们还必须允许这样的事件可以抛出的奇怪情况。因此,您不应该将这些对象放入容器中,并且如果在另一个异常处于活动状态时抛出commit-dtor,程序仍然可以terminate()


关于错误处理(提交/回滚语义)和异常,一个Andrei Alexandrescu有一个很好的对话: Error Handling in C++ / Declarative Control Flow (在NDC 2014举行)

在详细信息中,他解释了Folly库如何为UncaughtExceptionCounter工具实现ScopeGuard

(我应该注意others也有类似的想法。)

虽然谈话并不专注于从一个节目中进行投掷,但它显示了一个可以用于今天的工具来摆脱d {tor}中的problems with when to throw

future 中,可以为此的标准功能,参见N3614和{{3} }。

Upd '17:C ++ 17标准功能是discussion about it afaikt。我会快速引用cppref文章:

  

注释

     

使用int - 返回uncaught_exceptions的示例是......首先   创建一个保护对象并记录未捕获的异常的数量   在它的构造函数中。输出由保护对象执行   析构函数,除非foo()抛出(在这种情况下未被捕获的数量   析构函数中的异常大于构造函数   观察到的

答案 3 :(得分:19)

问自己关于从析构函数中抛出的真正问题是“调用者可以对此做些什么?”实际上是否有任何有用的异常,可以抵消从析构函数中抛出的危险?

如果我销毁一个Foo对象,并且Foo析构函数抛出一个异常,我可以合理地做些什么呢?我可以记录它,或者我可以忽略它。就这样。我无法“修复”它,因为Foo对象已经消失了。最好的情况,我记录异常并继续,好像什么也没发生(或终止程序)。这是否真的值得通过从析构函数中抛出来导致未定义的行为?

答案 4 :(得分:12)

它很危险,但从可读性/代码可理解性的角度来看也没有意义。

在这种情况下你要问的是什么

int foo()
{
   Object o;
   // As foo exits, o's destructor is called
}

什么应该抓住异常?应该是foo的来电者吗?或者应该foo处理它? foo的调用者为什么要关心foo内部的某个对象?可能有一种方法可以让语言将其定义为有意义,但它会变得难以理解且难以理解。

更重要的是,Object的内存在哪里?对象所拥有的内存在哪里?它仍然被分配(表面上是因为析构函数失败了)?还要考虑对象是在堆栈空间中,所以无论如何它都明显消失了。

然后考虑这个案例

class Object
{ 
   Object2 obj2;
   Object3* obj3;
   virtual ~Object()
   {
       // What should happen when this fails? How would I actually destroy this?
       delete obj3;

       // obj 2 fails to destruct when it goes out of scope, now what!?!?
       // should the exception propogate? 
   } 
};

当删除obj3失败时,如何以保证不失败的方式实际删除?我的记忆是该死的!

现在考虑在第一个代码片段中,Object会自动消失,因为它在堆栈上而Object3在堆上。由于指向Object3的指针消失了,你就是SOL了。你有内存泄漏。

现在一种安全的做事方式如下

class Socket
{
    virtual ~Socket()
    {
      try 
      {
           Close();
      }
      catch (...) 
      {
          // Why did close fail? make sure it *really* does close here
      }
    } 

};

另见FAQ

答案 5 :(得分:12)

来自C ++的ISO草案(ISO / IEC JTC 1 / SC 22 N 4411)

因此,析构函数通常应该捕获异常,而不是让它们从析构函数中传播出来。

  

3为从try块到throw的路径构造的自动对象调用析构函数的过程 -     表达式称为“堆栈展开”。[注意:如果在堆栈展开期间调用的析构函数退出时使用     异常,调用std :: terminate(15.5.1)。所以析构函数通常应该捕获异常而不是让它们     它们从析构函数中传播出来。 - 结束说明]

答案 6 :(得分:7)

您的析构函数可能正在其他析构函数链中执行。抛出未被直接调用者捕获的异常会使多个对象处于不一致状态,从而导致更多问题,然后忽略清理操作中的错误。

答案 7 :(得分:5)

其他人都解释了为什么抛出破坏者是可怕的......你能做些什么呢?如果您正在执行可能失败的操作,请创建一个单独的公共方法来执行清理并可以抛出任意异常。在大多数情况下,用户会忽略它。如果用户想要监视清理的成功/失败,他们可以简单地调用显式清理例程。

例如:

class TempFile {
public:
    TempFile(); // throws if the file couldn't be created
    ~TempFile() throw(); // does nothing if close() was already called; never throws
    void close(); // throws if the file couldn't be deleted (e.g. file is open by another process)
    // the rest of the class omitted...
};

答案 8 :(得分:5)

作为主要答案的补充,这些答案是好的,全面的和准确的,我想对你引用的文章发表评论 - 那个说“在析构函数中抛出异常并不是那么糟糕”的文章。

本文采用了“抛出异常的替代方法”这一行,并列出了每种替代方案的一些问题。这样做就得出结论,因为我们找不到无问题的替代方案,所以我们应该继续抛出异常。

问题在于它列出的备选方案中没有任何问题与异常行为一样糟糕,我们记得,这是“程序未定义的行为”。一些作者的反对意见包括“审美丑陋”和“鼓励坏风格”。那你想要哪个?一个风格不好的程序,或者表现出不确定行为的程序?

答案 9 :(得分:4)

我在小组中认为在析构函数中投掷的“范围保护”模式在许多情况下都很有用 - 特别是对于单元测试。但是,请注意,在C ++ 11中,抛出析构函数会导致调用std::terminate,因为析构函数是使用noexcept隐式注释的。

AndrzejKrzemieński发表了一篇关于析构主题的伟大帖子:

他指出C ++ 11有一种机制来覆盖析构函数的默认noexcept

  

在C ++ 11中,析构函数被隐式指定为noexcept。即使您没有添加规范并定义这样的析构函数:

  class MyType {
        public: ~MyType() { throw Exception(); }            // ...
  };
     

编译器仍会无形地将规范noexcept添加到析构函数中。这意味着在析构函数抛出异常的那一刻,即使没有双重异常情况,也会调用std::terminate。如果你真的决定允许你的析构函数抛出,你必须明确指定它;你有三个选择:

     
      
  • 明确指定您的析构函数为noexcept(false)
  •   
  • 从已将其析构函数指定为noexcept(false)的另一个类继承您的类。
  •   
  • 在您的类中放置一个非静态数据成员,该成员已将其析构函数指定为noexcept(false)
  •   

最后,如果你决定抛出析构函数,你应该始终意识到双重异常的风险(由于异常而在堆栈正在展开时抛出)。这会导致调用std::terminate,这很少是你想要的。要避免此行为,您可以在使用std::uncaught_exception()投放新的行为之前检查是否已有异常。

答案 10 :(得分:2)

  问:所以我的问题是 - 如果   从析构函数中抛出导致   未定义的行为,你如何处理   析构函数期间发生的错误?

答:有几种选择:

  1. 让异常从析构函数中流出,无论其他地方发生了什么。在这样做时,请注意(甚至可怕)std :: terminate可能会跟随。

  2. 永远不要让异常从析构函数中流出。如果可以,可以写入日志,一些大的红色坏文本。

  3. my fave :如果std::uncaught_exception返回false,则让异常流出。如果它返回true,则返回到日志记录方法。

  4. 但扔进去是不是很好?

    我同意上面的大部分内容,在析构函数中最好避免抛出它。但有时你最好接受它可以发生,并妥善处理它。我选择3以上。

    有一些奇怪的情况,它实际上是一个好主意从析构函数中抛出。 像“必须检查”错误代码。这是从函数返回的值类型。如果调用者读取/检查包含的错误代码,则返回的值将以静默方式销毁。 但是,如果在返回值超出范围时尚未读取返回的错误代码,则会从其析构函数中抛出一些异常

答案 11 :(得分:1)

我目前遵循这个政策(很多人都说),类不应该主动从析构函数中抛出异常,而应该提供一个公共的“关闭”方法来执行可能失败的操作......

...但我确实认为容器类类的析构函数(如向量)不应该掩盖从它们包含的类中抛出的异常。在这种情况下,我实际上使用一个“自由/关闭”方法,递归调用自己。是的,我递归地说。这种疯狂有一种方法。异常传播依赖于存在堆栈:如果发生单个异常,则剩余的析构函数仍将运行,并且一旦例程返回,挂起的异常将传播,这很好。如果发生多个异常,那么(取决于编译器)要么传播第一个异常,要么终止程序,这没关系。如果有这么多异常发生,递归溢出堆栈然后出现严重错误,有人会发现它,这也没关系。就个人而言,我犯错误的一面,而不是隐藏,秘密和阴险。

重点是容器保持中立,并且由所包含的类来决定它们是否在从析构函数中抛出异常时表现或行为异常。

答案 12 :(得分:1)

Martin Ba(上图)走在正确的轨道上 - 您对RELEASE和COMMIT逻辑的架构不同。

For Release:

你应该吃任何错误。你释放内存,关闭连接等等。系统中的其他任何人都不应该再次看到这些东西,而是将资源交回操作系统。如果您在此处需要真正的错误处理,则可能是对象模型中设计缺陷的结果。

For Commit:

这就是你想要的那种RAII包装器对象,像std :: lock_guard这样的东西正在为互斥体提供。有了那些你没有把提交逻辑放在dtor AT ALL中。你有一个专用的API,然后包装对象,RAII会在他们的dtors中提交它并处理那里的错误。记住,你可以在析构函数中捕获异常就好了;它发布它们是致命的。这也允许您通过构建不同的包装器(例如std :: unique_lock与std :: lock_guard)来实现策略和不同的错误处理,并确保您不会忘记调用提交逻辑 - 这是唯一的中途将它放在第一位的dtor中的合理理由。

答案 13 :(得分:0)

设置闹钟事件。通常,警报事件是在清理对象时通知故障的更好形式

答案 14 :(得分:0)

与构造函数不同,抛出异常可以是指示对象创建成功的有用方法,不应在析构函数中抛出异常。

在堆栈展开过程中从析构函数抛出异常时会发生此问题。如果发生这种情况,编译器将处于不知道是继续堆栈展开过程还是处理新异常的情况。最终结果是您的程序将立即终止。

因此,最好的做法是完全避免在析构函数中使用异常。请将消息写入日志文件。

答案 15 :(得分:0)

  

所以我的问题是 - 如果从析构函数中抛出结果   未定义的行为,你如何处理在a。期间发生的错误   析构?

主要问题是:你不能失败。毕竟,未能失败意味着什么?如果将事务提交到数据库失败,并且它无法失败(无法回滚),那么数据完整性会发生什么变化?

由于正常和异常(失败)路径都会调用析构函数,因此它们本身不会失败,否则我们就会“失败”。

这是一个概念上难以解决的问题但通常解决方案是找到一种方法来确保失败不会失败。例如,数据库可能会在提交到外部数据结构或文件之前写入更改。如果事务失败,则可以丢弃文件/数据结构。所有它必须确保从外部结构/文件提交一个不会失败的原子事务的更改。

  

务实的解决方案或许只是确保机会   失败的失败在天文学上是不可能的,因为制造东西   在某些情况下,不可能失败是不可能的。

对我来说最合适的解决方案是以一种清理逻辑不会失败的方式编写非清理逻辑。例如,如果您想要创建新的数据结构以清理现有的数据结构,那么您可能会提前创建该辅助结构,以便我们不再需要在析构函数中创建它。 / p>

不可否认,这说起来容易做起来难得多,但这是我认为唯一正确的方法。有时候我认为应该有能力为正常的执行路径编写单独的析构函数逻辑而不是特殊的执行路径,因为有时析构函数感觉有点像他们通过尝试处理两者而承担双重责任(例如需要明确解雇的范围保护程序) ;如果他们能够将特殊的破坏路径与非特殊破坏路径区分开来,他们就不会要求这样做。)

最终的问题仍然是我们不能失败,在所有情况下完全解决都是一个难以理解的概念设计问题。如果你没有太多包裹在复杂的控制结构中,并且大量的小物体彼此相互作用,而是以稍微笨重的方式模拟你的设计(例如:带有析构函数的粒子系统来破坏整个粒子),它会变得更容易系统,而不是每个粒子单独的非平凡析构函数)。当您在这种较粗糙的级别上对您的设计进行建模时,您可以处理更少的非平凡的析构函数,并且通常还可以承担所需的内存/处理开销,以确保您的析构函数不会失败。

这是最简单的解决方案之一,自然就是不经常使用析构函数。在上面的粒子示例中,也许在破坏/移除粒子时,应该做一些可能由于某种原因而失败的事情。在这种情况下,不是通过可以在异常路径中执行的粒子的dtor调用这样的逻辑,而是可以在粒子系统移除粒子时完成所有操作。在非特殊路径中,可能始终会移除粒子。如果系统被破坏,它可能只是清除所有粒子,而不是打扰可能失败的单个粒子移除逻辑,而可以失败的逻辑只在粒子系统正常执行时执行,当它移除一个或多个粒子时。 / p>

如果你避免使用非平凡的析构函数来处理很多很少的对象,那么通常会出现这样的解决方案。你可能会陷入混乱的地方,似乎几乎不可能成为例外 - 安全就是当你被许多青少年物品纠缠在一起时,这些物品都是非平凡的蠢货。

如果nothrow / noexcept实际上转换为编译器错误,如果指定它的任何东西(包括应该继承其基类的noexcept规范的虚函数)试图调用可能抛出的任何东西,那将会有很大帮助。这样我们就可以在编译时捕获所有这些东西,如果我们实际上无意中写了一个可能抛出的析构函数。