280

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

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

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

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

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

4

17 回答 17

212

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

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

}

这基本上归结为:

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

然后,析构函数将通过调用这些方法来结束对象(如果用户没有明确地这样做),但是任何抛出的异常都会被捕获并丢弃(在尝试修复问题之后)。

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

一个例子:

标准::fstream

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

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

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

编辑:

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

于 2008-09-24T21:35:59.893 回答
63

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

于 2008-12-17T15:22:03.820 回答
52

我们必须在这里区分,而不是盲目地遵循针对特定情况的一般建议。

请注意,以下内容忽略了对象容器的问题以及面对容器内对象的多个 d'tors 该怎么办。(并且可以部分忽略,因为有些对象不适合放入容器中。)

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

  • (R) 释放语义(也就是释放内存)
  • (C)提交语义(也就是将文件刷新到磁盘)

如果我们以这种方式看待这个问题,那么我认为可以说(R)语义永远不应该导致来自 dtor 的异常,因为 a)我们对此无能为力,b)许多免费资源操作不会甚至提供错误检查,例如.void free(void* p);

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

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


关于错误处理(提交/回滚语义)和异常,Andrei Alexandrescu有一个很好的演讲:C++ 中的错误处理/声明式控制流(在NDC 2014举行)

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

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

虽然这次演讲的重点不是从 d'tor 投掷,但它展示了一种今天可以用来解决何时从 d'tor投掷的问题的工具。

将来可能会有一个标准特性,请参阅N3614关于它的讨论。

更新 '17:C++17 标准特性是std::uncaught_exceptionsafaikt。我将快速引用 cppref 文章:

笔记

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

于 2010-11-04T16:07:37.493 回答
20

关于从析构函数中抛出的真正问题是“调用者可以用这个做什么?” 实际上,您可以对异常做些什么有用的事情,以抵消从析构函数中抛出所产生的危险?

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

于 2008-09-24T21:42:05.417 回答
13

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

所以析构函数通常应该捕获异常,而不是让它们传播出析构函数。

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

于 2009-04-11T03:00:42.170 回答
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
      }
    } 

};

另请参阅此常见问题解答

于 2008-09-24T21:48:07.740 回答
7

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

Andrzej Krzemień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().

于 2017-01-02T16:02:04.373 回答
6

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

于 2008-09-24T21:41:01.173 回答
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...
};
于 2009-01-19T05:39:54.597 回答
3

作为良好、全面和准确的主要答案的补充,我想对您引用的文章发表评论——那篇文章说“在析构函数中抛出异常还不错”。

文章采用“抛出异常的替代方案是什么”这一行,并列出了每个替代方案的一些问题。这样做之后,它得出的结论是,因为我们找不到没有问题的替代方案,我们应该继续抛出异常。

问题在于,它列出的备选问题中没有一个问题与异常行为一样糟糕,让我们记住,这是“程序的未定义行为”。作者的一些反对意见包括“审美丑陋”和“鼓励不良作风”。现在你更愿意拥有哪个?一个风格不好的程序,还是一个表现出未定义行为的程序?

于 2009-01-20T19:57:08.563 回答
2

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

答:有几种选择:

  1. 让异常从你的析构函数中流出,不管其他地方发生了什么。这样做时要注意(甚至害怕) std::terminate 可能会随之而来。

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

  3. 我的最爱:如果std::uncaught_exception返回 false,让你的异常流出。如果它返回 true,则回退到日志记录方法。

但是投入d'tors好吗?

我同意上述大部分内容,最好在析构函数中避免投掷,如果可以的话。但有时你最好接受它可能发生,并妥善处理它。我会选择上面的3个。

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

于 2010-03-18T14:57:57.800 回答
2

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

主要问题是:你不能不失败。毕竟,失败意味着什么?如果向数据库提交事务失败,并且失败(回滚失败),我们的数据完整性会发生什么?

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

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

务实的解决方案也许只是确保失败失败的可能性在天文数字上是不可能的,因为在某些情况下,让事情不可能失败几乎是不可能的。

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

诚然,这说起来容易做起来难,但这是我看到的唯一真正正确的方法。有时我认为应该有能力为正常的执行路径和异常的执行路径编写单独的析构函数逻辑,因为有时析构函数感觉有点像他们通过尝试处理两者来承担双重责任(一个例子是需要明确解除的范围保护; 如果他们可以区分异常破坏路径和非异常破坏路径,他们就不需要这样做)。

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

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

如果您避免使用非平凡的析构函数处理大量小对象,通常会出现类似的解决方案。你可能会陷入一团乱麻,似乎几乎不可能实现异常安全的地方是,当你确实陷入了许多都具有非平凡 dtors 的小对象中时。

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

于 2018-01-05T13:50:02.300 回答
0

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

于 2013-02-25T07:42:25.230 回答
0

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

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

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

于 2013-10-27T03:31:54.980 回答
0

从析构函数中抛出异常永远不会导致未定义的行为。

将异常抛出析构函数的问题是,成功创建的对象的析构函数在处理未捕获的异常时(在创建异常对象之后直到异常激活的处理程序完成)会被异常处理调用机制; 并且,如果在处理未捕获的异常时调用的析构函数中的此类附加异常中断了处理未捕获的异常,它将导致调用std::terminate(调用的另一种情况std::exception是异常没有被任何处理程序处理,但这与任何其他函数一样,不管它是否是析构函数)。


如果正在处理未捕获的异常,您的代码永远不知道是否会捕获额外的异常或将归档未捕获的异常处理机制,因此永远无法确定抛出是否安全。

虽然,有可能知道正在处理未捕获的异常(https://en.cppreference.com/w/cpp/error/uncaught_exception),因此您可以通过检查条件来过度杀伤并仅在它出现时抛出并非如此(在某些情况下它不会抛出它是安全的)。

但在实践中,这种分离为两种可能的行为是没有用的——它只是无助于你制作一个设计良好的程序。


如果你抛出析构函数而忽略未捕获的异常处理是否正在进行,为了避免可能的调用std::terminate,你必须保证在对象的生命周期中抛出的所有异常都可能从它们的析构函数中抛出异常在开始之前被捕获对象的破坏。它的使用非常有限;您几乎不能使用所有可以合理地以这种方式从析构函数中抛出的类;并且仅对某些类的此类使用受限的类允许此类异常的组合也阻碍了设计良好的程序。

于 2021-08-27T01:04:40.703 回答
-1

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

...但我确实相信容器类型类的析构函数(如向量)不应该掩盖从它们包含的类中抛出的异常。在这种情况下,我实际上使用了递归调用自身的“释放/关闭”方法。是的,我递归地说。这种疯狂是有办法的。异常传播依赖于堆栈:如果发生单个异常,则其余的析构函数仍将运行,并且一旦例程返回,挂起的异常将传播,这很好。如果发生多个异常,则(取决于编译器)第一个异常将传播或程序将终止,这没关系。如果发生这么多异常以致递归溢出堆栈,那么就出现了严重错误,有人会发现它,这也是可以的。亲自,

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

于 2010-02-02T06:21:18.730 回答
-1

Martin Ba(上图)走在正确的轨道上——你为 RELEASE 和 COMMIT 逻辑构建不同的架构。

对于发布:

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

对于提交:

这是您需要与 std::lock_guard 等为互斥锁提供的相同类型的 RAII 包装器对象的地方。有了这些,您根本不会将提交逻辑放在 dtor 中。你有一个专用的 API,然后包装对象将 RAII 提交到他们的 dtors 并在那里处理错误。请记住,您可以很好地在析构函数中捕获异常;发行它们是致命的。这还允许您通过构建不同的包装器(例如 std::unique_lock 与 std::lock_guard)来实现策略和不同的错误处理,并确保您不会忘记调用提交逻辑——这是唯一的中途将它放在第一名的正当理由。

于 2015-04-15T20:42:07.097 回答