从析构函数中抛出异常

时间:2020-03-06 14:41:27  来源:igfitidea点击:

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

这篇文章似乎在其他方面说,抛出析构函数或者多或者少是可以的。

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

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

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

解决方案

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

#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:防止异常离开析构函数

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

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

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

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

你要问的就是在这种情况下

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

};

另请参阅此常见问题解答

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

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

例如:

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

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

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

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

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

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

3 The process of calling destructors for automatic objects constructed on the path from a try block to a throw-
    expression is called “stack unwinding.” [ Note: If a destructor called during stack unwinding exits with an
    exception, std::terminate is called (15.5.1). So destructors should generally catch exceptions and not let
    them propagate out of the destructor. — end note ]

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

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

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

Q: So my question is this - if
  throwing from a destructor results in
  undefined behavior, how do you handle
  errors that occur during a destructor?

答:有几种选择:

  • 不管其他地方发生了什么,让异常从析构函数中流出。在这样做时,要意识到(甚至恐惧)可能会出现std :: terminate。
  • 永远不要让异常从析构函数中流出。可以写到日志中,如果可以的话,可以写一些大红色的坏文字。
  • 我的最爱:如果std :: uncaught_exception返回false,那么异常就会流出。如果返回true,则退回到日志记录方法。

但是丢掉托儿好吗?

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

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

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

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

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

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

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

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

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

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

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

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

尽管讨论不仅仅针对从d'tor投掷,但它展示了一种当今可以用来消除何时从d'tor投掷的问题的工具。

将来可能会为此提供std功能,请参阅N3614,并对此进行讨论。

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

Notes
  
  An example where int-returning uncaught_exceptions is used is ... ... first
  creates a guard object and records the number of uncaught exceptions
  in its constructor. The output is performed by the guard object's
  destructor unless foo() throws (in which case the number of uncaught
  exceptions in the destructor is greater than what the constructor
  observed)