从析构函数中抛出异常


257

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

本文似乎另有说法-抛出析构函数或多或少是可以的。

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

如果在清理操作期间发生错误,您是否会忽略它?如果是可以在堆栈中潜在地处理但在析构函数中无法正确处理的错误,那么将异常抛出到析构函数之外是否有意义?

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


36
“一次两个例外”是一个很好的答案,但这不是真正的原因。真正的原因是,当且仅当无法满足函数的后置条件时,才应引发异常。析构函数的后置条件是该对象不再存在。这不可能发生。因此,在对象超出范围之前,任何容易发生故障的寿命终止操作都必须作为一种单独的方法调用(明智的功能通常只有一条成功路径)。
spraff 2011年

29
@spraff:您是否知道您所说的意思是“扔掉RAII”?
科斯

16
@spraff:必须调用“对象超出范围之前的单独方法”(如您所写)实际上会丢弃RAII!使用此类对象的代码必须确保在调用析构函数之前将调用此类方法。最后,这种想法根本没有帮助。
弗伦西(Frunsi)2012年

8
@Frunsi不,因为此问题源于以下事实:析构函数正在尝试做一些事情,而不仅仅是释放资源。诱人地说“我总是想结束XYZ”,并认为这是将这样的逻辑放入析构函数的论点。不,不要偷懒,编写xyz()并保持析构函数远离非RAII逻辑。
spraff

6
@Frunsi例如,承诺的东西文件并不一定是OK的代表交易类的析构函数做。如果提交失败,则当事务中涉及的所有代码都超出范围时,处理该提交为时已晚。除非commit()调用方法,否则析构函数应丢弃事务。
Nicholas Wilson

Answers:


197

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

#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";
    }

}

基本上可以归结为:

任何危险的事情(即可能引发异常的事情)都应通过公共方法(不一定直接)进行。然后,您的类的用户可以通过使用公共方法并捕获任何潜在的异常来潜在地处理这些情况。

然后,析构函数将通过调用这些方法(如果用户未明确这样做)来结束对象,但是会捕获并丢弃所有引发的异常(尝试解决问题之后)。

因此,实际上您将责任转移给了用户。如果用户能够纠正异常,他们将手动调用适当的功能并处理任何错误。如果对象的用户不担心(因为对象将被销毁),则析构函数将留给企业处理。

一个例子:

std :: fstream

close()方法可能会引发异常。如果已打开文件,则析构函数调用close(),但请确保任何异常都不会传播到析构函数之外。

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

斯科特·迈尔斯(Scott Myers)在他的著作《有效的C ++》中有一篇关于该主题的出色文章。

编辑:

显然也在“更有效的C ++”
项目11中:防止异常离开析构函数


5
“除非您不介意潜在地终止应用程序,否则您应该吞下该错误。” -这应该是例外(对双关语),而不是规则-即快速失败。
Erik Forbes

15
我不同意。终止程序将停止堆栈展开。不再调用析构函数。任何打开的资源将保持打开状态。我认为吞下例外将是首选。
马丁·约克

20
操作系统可以清理资源,但所有者无法使用。内存,FileHandles等。复杂资源呢:数据库连接。您打开的ISS的上行链路(它会自动发送关闭的连接)吗?我确定NASA希望您彻底关闭连接!
马丁·约克

7
如果应用程序要通过中止来“快速失败”,则不应首先抛出异常。如果通过将控制传递回堆栈而失败,则不应以可能导致程序中止的方式这样做。一个或另一个,不要两者都选。
汤姆”

2
@LokiAstari您用于与航天器通信的传输协议无法处理断开的连接?好的...
doug65536

54

抛出析构函数会导致崩溃,因为该析构函数可能被称为“堆栈展开”的一部分。堆栈展开是在引发异常时发生的过程。在此过程中,自“ try”以来直到抛出异常之前所有被压入堆栈的对象都将被终止->将调用它们的析构函数。并且在此过程中,不允许再次引发异常,因为不可能一次处理两个异常,因此,这将引发对abort()的调用,程序将崩溃,并且控件将返回到OS。


1
您能否详细说明在上述情况下如何调用abort()。意味着执行控制仍由C ++编译器执行
Krishna Oza 2014年

1
@Krishna_Oza:非常简单:每当引发错误时,引发错误的代码都会检查一些位,指示运行时系统正在展开堆栈(即,处理其他一些throw但尚未找到其catch块)在这种情况下,将调用std::terminate(not abort)而不是引发(新)异常(或继续展开堆栈)。
Marc van Leeuwen

53

我们必须在这里区别对待,而不是一味遵循特定案例的一般建议。

请注意,以下内容忽略了对象容器的问题以及面对容器内部多个对象的操作。(并且可以部分忽略它,因为某些对象不适合放入容器中。)

当我们将类分为两种类型时,整个问题变得更容易思考。班主任有两个不同的职责:

  • (R)释放语义(aka释放该内存)
  • (C)提交语义(也称为刷新文件到磁盘)

如果我们以这种方式看待这个问题,那么我认为可以认为(R)语义绝不应该引起dtor的异常,因为a)我们对此无能为力,并且b)许多自由资源操作都没有甚至提供错误检查,例如 void free(void* p);

具有(C)语义的对象,例如需要成功刷新其数据的文件对象或在dtor中进行提交的(“范围保护”)数据库连接,属于另一种类型:我们可以对错误进行处理(在应用程序级别),我们真的不应该继续进行下去,就好像什么都没有发生一样。

如果我们遵循RAII路线,并允许其对象具有(C)语义的对象,那么我认为我们还必须考虑到此类对象可能抛出的奇怪情况。随之而来的是,您不应该将此类对象放入容器中,并且还可以保证,terminate()如果在另一个异常处于活动状态时抛出了commit-dtor ,则程序仍然可以。


关于错误处理(Commit / Rollback语义)和异常,一位Andrei Alexandrescu进行了精彩演讲:C ++ /声明式控制流中的错误处理(于NDC 2014举行)

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

(我应该注意其他人也有类似的想法。)

尽管讨论的重点不是投掷“ d'tor”,但它显示了一种当今可以用来消除何时投掷问题的工具。从d'tor。

未来,有可能是性病的特征这一点,看到N3614以及关于它的讨论

Upd '17:对此的C ++ 17 std功能是std::uncaught_exceptionsafaikt。我将快速引用cppref文章:

笔记

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


6
高度同意。并添加了另一种语义(Ro)回滚语义。常用于示波器。就像我的项目中定义ON_SCOPE_EXIT宏的情况一样。关于回滚语义的情况是,任何有意义的事情都可能在这里发生。因此,我们真的不应该忽略失败。
Weipeng L

我觉得我们在析构函数中拥有提交语义的唯一原因是C ++不支持finally
user541686

@Mehrdad:finally 一个dtor。无论如何,它总是被称为。有关final的语法近似,请参见各种scope_guard实现。如今,有了用于检测是否允许dtor抛出的机械(即使在标准中,它也是C ++ 14?),甚至可以使其完全安全。
马丁·巴

1
@MartinBa:我想您错过了我的评论,这令人惊讶,因为我同意您的观点,即(R)和(C)不同。我试图说dtor本质上是(R)finally的工具,本质上是(C)的工具。如果您不明白为什么:请考虑为什么在finally块中相互抛出异常是合法的,以及为什么析构函数不能使用异常。(从某种意义上说,这是数据与控制的关系。析构finally函数用于释放数据,用于释放控制。它们是不同的;不幸的是C ++将它们

1
@Mehrdad:这里时间太长了。如果需要,可以在这里建立您的论点:programmers.stackexchange.com/questions/304067/… 。谢谢。
马丁·巴

21

要问自己有关从析构函数中抛出的问题,真正的问题是“调用者可以做什么?” 实际上,您有什么有用的例外处理方法,可以抵消因析构函数引发的危险吗?

如果我销毁了一个Foo对象,而Foo析构函数抛出了一个异常,我该如何合理地处理呢?我可以记录它,也可以忽略它。就这样。我无法“修复”它,因为该Foo对象已经消失了。最好的情况是,我记录了异常,然后继续进行,好像什么都没发生(或终止程序)一样。通过从析构函数中抛出,那真的值得潜在地引起不确定的行为吗?


11
只是注意到...从dtor抛出从来都不是未定义的行为。当然,它可能会调用Terminate(),但这是非常明确的行为。
马丁·巴

4
std::ofstream的析构函数将刷新,然后关闭文件。刷新时可能会发生磁盘已满错误,您绝对可以使用以下方法做一些有用的事情:向用户显示一个错误对话框,指出磁盘空间不足。
安迪

13

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

你要问的就是这种情况

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

什么应该捕捉到异常?应该调用foo吗?还是应该由foo处理?为什么foo的调用者应该关心foo内部的某些对象?语言可能会定义一种有意义的方式,但是它将变得不可读且难以理解。

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

然后考虑这种情况

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删除失败时,我如何以保证不会失败的方式实际删除?这是我的记忆!

现在考虑在第一个代码片段中,对象自动消失,因为对象在堆栈上而对象3在堆上。由于指向Object3的指针不存在,因此您有点像SOL。您有内存泄漏。

现在,一种安全的处理方法如下

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

};

另请参阅此常见问题解答


重现此答案,例如:第一个示例about int foo(),可以使用function-try-block将整个函数foo包装在try-catch块中,包括捕获析构函数(如果您愿意的话)。仍然不是首选的方法,但这是一回事。
tyree731 '19

13

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

因此,析构函数通常应捕获异常,而不应让它们传播到析构函数之外。

3为从try块到throw表达式的路径上构造的自动对象调用析构函数的过程称为“堆栈展开”。[注意:如果在堆栈展开期间调用的析构函数异常退出,则调用std :: terminate(15.5.1)。因此,析构函数通常应捕获异常,而不应让它们传播到析构函数之外。—尾注]


1
没有回答问题-OP已经意识到这一点。
Arafangion

2
@Arafangion我怀疑他是否意识到这一点(std :: terminate被调用),因为所接受的答案完全相同。
lothar,2009年

@Arafangion就像在这里的一些答案中,有人提到过abort()被调用;还是std :: terminate依次调用abort()函数。
克里希纳(Krishna Oza)2014年

7

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


7

我所在的小组认为,在许多情况下,尤其是对于单元测试,在析构函数中抛出“作用域保护”模式很有用。但是请注意,在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()


6

其他人都解释了为什么抛出析构函数如此可怕……您能怎么做?如果您执行的操作可能会失败,请创建一个单独的公共方法来执行清理并可能引发任意异常。在大多数情况下,用户将忽略它。如果用户想要监视清理的成功/失败,则可以简单地调用显式清理例程。

例如:

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...
};

我正在寻找解决方案,但他们试图解释发生了什么以及为什么。只是想弄清楚在析构函数中是否调用了close函数?
贾森·刘

5

除了主要的回答(好的,全面的和准确的)之外,我想评论一下您所引用的文章-该文章说“在析构函数中抛出异常还不错”。

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

问题在于,它所列出的替代方案中没有一个问题比异常行为更糟糕,让我们记住,异常行为是“程序的未定义行为”。作者的一些反对意见包括“美学上丑陋”和“鼓励不良作风”。现在,您宁愿拥有哪一个?一个程序风格不好,还是表现出不确定的行为?


1
不是不确定的行为,而是立即终止。
Marc van Leeuwen

该标准说“未定义的行为”。这种行为经常是终止,但并非总是如此。
DJClayworth

不,请阅读“异常处理”->“特殊功能”中的[except.terminate](在我的标准副本中为15.5.1,但其编号可能已过时)。
Marc van Leeuwen

2

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

答:有几种选择:

  1. 不管其他地方发生了什么,让异常从析构函数中流出。在这样做时,请注意(甚至是担心)可能会出现std :: terminate。

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

  3. 我的最爱:如果std::uncaught_exception返回false,那么异常就会流出。如果返回true,则返回日志记录方法。

但是,丢个孩子好吗?

我同意以上大部分观点,最好在可能的析构函数中避免抛出。但是有时候最好不要接受它的发生并妥善处理。我会在上面选择3。

在一些奇怪的情况下,从析构函数中抛出它实际上是一个好主意。类似于“必须检查”错误代码。这是从函数返回的值类型。如果调用方读取/检查了所包含的错误代码,则返回的值将无提示地破坏。 但是,如果在返回值超出范围时尚未读取返回的错误代码,它将从其析构函数中抛出一些异常。


4
您最喜欢我最近尝试过的东西,事实证明您不应该这样做。gotw.ca/gotw/047.htm
GManNickG 2010年

1

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

...但是我确实相信容器类型的类的析构函数(例如向量)不应掩盖从它们所包含的类引发的异常。在这种情况下,我实际上使用了“自由/关闭”方法,该方法以递归方式调用自身。是的,我递归地说。有一种解决这种疯狂的方法。异常传播依赖于堆栈:如果发生单个异常,则一旦例程返回,剩下的析构函数都将继续运行,并且待处理的异常将传播,这是非常好的。如果发生多个异常,则(取决于编译器)第一个异常将传播或程序将终止,这没关系。如果发生了太多的异常而导致递归溢出堆栈,则说明存在严重错误,并且有人会发现它,这也是可以的。亲自,

关键是容器保持中立,由所包含的类决定在从析构函数抛出异常方面是行为还是行为不当。


1

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

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

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


1
将消息写入日志文件可能会导致异常。
Konard

1

Martin Ba(上文)走在正确的道路上-您为RELEASE和COMMIT逻辑设计的架构不同。

发布:

你应该吃任何错误。您正在释放内存,关闭连接等。系统中的其他任何人都不能再次看到这些东西,并且会将资源交还给OS。如果看起来您需要在此处进行真正的错误处理,则可能是对象模型中的设计缺陷导致的。

提交:

在这里,您需要与std :: lock_guard之类的互斥对象一样的RAII包装对象。对于那些,您根本不会将提交逻辑放在dtor中。您有一个专用的API,然后包装对象将RAII提交到THEIR dtor中并在那里处理错误。记住,您可以在析构函数中捕获异常。它发出致命的消息。这也使您仅通过构建不同的包装即可实现策略和不同的错误处理(例如std :: unique_lock与std :: lock_guard),并确保您不会忘记调用commit逻辑-这是唯一的方法将其放在dtor的第一位的正当理由。


1

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

主要问题是:您不能失败。失败失败到底意味着什么?如果将事务提交到数据库失败,并且失败(无法回滚),那么我们的数据完整性会怎样?

由于析构函数同时针对正常和异常(失败)路径进行调用,因此它们本身不会失败,否则我们将“失败”。

这是一个概念上很困难的问题,但通常的解决方案是找到一种确保失败不会失败的方法。例如,数据库可能会在提交到外部数据结构或文件之前写入更改。如果事务失败,则可以丢弃文件/数据结构。然后,只需确保从该外部结构/文件提交更改就不会失败。

务实的解决方案也许只是确保从天文学角度讲不可能发生失败的可能性,因为在某些情况下使不可能失败的事情几乎是不可能的。

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

坦率地说,这说起来容易做起来难,但这是我看到的唯一正确的方法。有时,我认为应该为正常的执行路径写一些独立的析构函数逻辑,而不必使用特殊的析构函数,因为有时候析构函数会感觉有点像它们通过尝试同时处理这两者而承担着双重责任(例如,范围保护需要显式解雇;如果他们可以区分特殊的销毁路径和非例外的销毁路径,则不需要这样做。

最终的问题仍然是我们不能失败,这是一个很难在所有情况下完美解决的概念设计难题。如果您不必太复杂地控制复杂的控制结构,而使大量的小对象彼此交互,而以稍微大一些的方式对设计进行建模(例如:带有析构函数的粒子系统来破坏整个粒子),它会变得更加容易。系统,而不是每个粒子单独的非平凡的析构函数)。当您在这种较粗糙的级别上对设计进行建模时,您需要处理的琐碎析构函数将更少,并且通常还可以提供所需的任何内存/处理开销来确保析构函数不会失败。

这自然是最简单的解决方案之一,就是减少析构函数的使用频率。在上面的粒子示例中,也许在销毁/移除粒子后,应该执行某些可能由于任何原因而失败的操作。在那种情况下,您可以通过粒子系统在删除粒子时通过粒子系统来完成所有这些工作,而不必通过可以在特殊路径中执行的逻辑来调用此类逻辑。移除粒子可能总是在非异常路径中完成。如果系统被破坏,也许它只能清除所有粒子,而不会打扰可能失败的单个粒子清除逻辑,而可能失败的逻辑仅在粒子系统清除一个或多个粒子时的正常执行期间执行。

如果您避免使用非平凡的析构函数处理许多小对象,通常会有类似的解决方案出现。当您纠结在很多都带有非平凡的dtory对象的混乱对象中时,似乎几乎不可能出现异常安全的情况就可能使您陷入混乱。

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


1
破坏现在失败了吗?
curiousguy19年

我认为他的意思是在故障期间调用析构函数来清理该故障。因此,如果在活动异常期间调用了析构函数,则它无法从先前的失败中清除。
user2445507 '19

0

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

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.