代码之家  ›  专栏  ›  技术社区  ›  Greg Rogers

从析构函数中抛出异常

  •  234
  • Greg Rogers  · 技术社区  · 17 年前

    大多数人说 从未 从析构函数中抛出异常-这样做会导致未定义的行为。斯特劳斯鲁普指出 “向量析构函数显式调用每个元素的析构函数。这意味着如果一个元素析构函数抛出,那么向量销毁将失败…实际上没有很好的方法来防止析构函数抛出异常,因此如果元素析构函数抛出,库就不作任何保证(见附录E3.2)。 .

    This article 似乎不是这样说的-投掷析构函数或多或少是可以的。

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

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

    很明显,这种错误是罕见的,但也是可能的。

    16 回复  |  直到 6 年前
        1
  •  177
  •   Andy    7 年前

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

    #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:FFSH

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

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

    Scott Myers在他的《有效C++》一书中有一篇关于这门学科的优秀文章。

    编辑:

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

        2
  •  49
  •   Gal Goldman    16 年前

    抛出析构函数可能导致崩溃,因为此析构函数可能作为“堆栈展开”的一部分调用。 堆栈展开是在引发异常时发生的过程。 在此过程中,将终止自“Try”以来一直推到堆栈中直到引发异常的所有对象--将调用它们的析构函数。 在这个过程中,不允许另一个异常抛出,因为一次不能处理两个异常,因此,这将引发调用abort(),程序将崩溃,控件将返回操作系统。

        3
  •  43
  •   Martin Ba    8 年前

    我们必须 区分 这里不是盲目跟随 一般的 建议 具体的 病例。

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

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

    • (R)释放语义(亦称释放内存)
    • (c) 犯罪 语义(AKA) 脸红 文件到磁盘

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

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

    如果我们遵循raii路径,并允许对象在其驱动程序中具有(c)语义,那么我们还必须考虑这样的驱动程序可以抛出的奇怪情况。因此,您不应该将这些对象放入容器中,而且程序仍然可以 terminate() 如果在另一个异常处于活动状态时提交DTOR引发。


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

    在细节部分,他解释了愚蠢的图书馆如何实现 UncaughtExceptionCounter 为了他们 ScopeGuard 工装。

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

    虽然谈话的重点不在于从老师那里扔东西,但它展示了一种可以使用的工具 今天 为了摆脱 problems with when to throw 从一个Dor。

    未来 那里 可以 作为标准功能, 看见 N3614 , 和A discussion about it .

    UPD 17:C++的17个STD特性是 std::uncaught_exceptions 更是如此。我将很快引用CPPREF的文章:

    笔记

    一个例子,其中 int -返回 uncaught_exceptions 用的是……第一 创建保护对象并记录未捕获的异常数 在其构造函数中。输出由保护对象的 析构函数,除非foo()引发( 在这种情况下,未捕获的数量 析构函数中的异常大于构造函数中的异常 观察 )

        4
  •  19
  •   Derek Park    17 年前

    关于从析构函数抛出的真正问题是,“调用程序能用这个做什么?”对于这个例外,您实际上可以做什么有用的事情来抵消从析构函数抛出所产生的危险吗?

    如果我毁灭了 Foo 对象和 析构函数抛出一个异常,我能合理地处理它吗?我可以记录它,或者忽略它。这就是全部。我不能“修复”它,因为 对象已不存在。最好的情况是,我记录异常并继续,就好像什么都没有发生(或终止程序)。这真的值得通过从析构函数中抛出来潜在地导致未定义的行为吗?

        5
  •  12
  •   Loki Astari    16 年前

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

    在这种情况下你要问的是

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

    也看到这个 FAQ

        6
  •  11
  •   lothar    16 年前

    从ISO草案C++(ISO/IEC JTC 1/SC 22 N 4411)

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

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

        7
  •  7
  •   Franci Penov    17 年前

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

        8
  •  5
  •   Tom    16 年前

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

    例如:

    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...
    };
    
        9
  •  5
  •   DJClayworth    16 年前

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

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

    问题在于,它列出的与备选方案相关的问题中,没有一个比异常行为更糟糕,让我们记住,异常行为是“程序的未定义行为”。作者的一些反对意见包括“在美学上丑陋”和“鼓励不良风格”。现在你想要哪一个?一个风格不好的程序,或者表现出未定义行为的程序?

        10
  •  3
  •   GaspardP    8 年前

    我所在的团队认为,在许多情况下,在析构函数中抛出“范围保护”模式非常有用,特别是在单元测试中。但是,请注意,在C++ 11中,抛出析构函数会导致调用 std::terminate 因为析构函数是用 noexcept .

    Andrzej Krzemie_ ski在“投掷的毁灭者”这一主题上有一篇很好的文章:

    他指出C++ 11有一个机制来重写默认值。 不除外 对于析构函数:

    在C++ 11中,析构函数隐式指定为 不除外 . 即使您没有添加任何规范并像这样定义析构函数:

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

    编译器仍将无形地添加规范 不除外 对你的毁灭者。这意味着当析构函数抛出异常时, STD::终止 将被调用,即使没有双重异常情况。如果您真的决定允许析构函数抛出,则必须显式地指定它;您有三个选项:

    • 将析构函数显式指定为 noexcept(false) ,
    • 从另一个已将其析构函数指定为的类继承类 除(假)以外 .
    • 在类中放置一个非静态数据成员,该成员已将其析构函数指定为 除(假)以外 .

    最后,如果您确实决定抛出析构函数,那么应该始终注意双异常的风险(在堆栈因异常而被释放时抛出)。这会导致 STD::终止 而且它很少是你想要的。为了避免这种行为,在使用 std::uncaught_exception() .

        11
  •  2
  •   MartinP    15 年前

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

    A:有几种选择:

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

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

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

    但是,扔面包片好吗?

    我同意上面大多数的观点,即抛掷最好避免在析构函数中,在可能的地方。但有时候你最好接受它的发生,并处理好它。我选择上面的3个。

    有一些奇怪的情况 好主意 从析构函数中抛出。 比如“必须检查”的错误代码。这是从函数返回的值类型。如果调用方读取/检查包含的错误代码,则返回的值将自动销毁。 但是 ,如果返回值超出范围时未读取返回的错误代码,则会引发一些异常, 从它的析构函数 .

        12
  •  1
  •   Matthew    15 年前

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

    …但是我相信容器类型类的析构函数,像向量一样,不应该掩盖从它们包含的类中抛出的异常。在本例中,我实际上使用了一个递归调用自身的“free/close”方法。是的,我递归地说。有一种方法可以解决这种疯狂。异常传播依赖于存在一个堆栈:如果发生单个异常,那么其余的析构函数都将继续运行,而挂起的异常将在例程返回后传播,这很好。如果出现多个异常,那么(取决于编译器)第一个异常将传播,或者程序将终止,这是正常的。如果出现了如此多的异常,以至于递归溢出了堆栈,那么就出现了严重的错误,有人会发现它,这也是可以的。就我个人而言,我错误的一面是错误的爆发,而不是被隐藏、秘密和阴险。

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

        13
  •  1
  •   user3726672    10 年前

    MartinBA(上面)是正确的——您为发布和提交逻辑设计了不同的架构。

    释放:

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

    提交:

    在这里,您需要与std::lock_guard等东西为互斥提供的相同类型的raii包装器对象。对于那些您根本不将提交逻辑放在DTOR中的对象。您有一个专门的API,然后包装对象,这些对象将在DTORS中提交它并在那里处理错误。记住,您可以在析构函数中捕获异常,这很好;它发出的异常是致命的。这还允许您通过构建一个不同的包装器来实现策略和不同的错误处理(例如,std::unique_lock与std::lock_guard),并确保您不会忘记调用提交逻辑——这是将其放在DTOR第一位的唯一半途而废的正当理由。

        14
  •  0
  •   MRN    12 年前

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

        15
  •  0
  •   Devesh Agrawal    11 年前

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

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

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

        16
  •  0
  •   Dragon Energy    7 年前

    所以我的问题是,如果从析构函数中抛出导致 未定义的行为,如何处理在 析构函数?

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

    因为析构函数是为正常和异常(失败)路径调用的,所以它们本身不能失败,否则我们将“失败”。

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

    务实的解决方案可能只是确保 失败是天方夜谭,因为制造东西 在某些情况下,不可能失败几乎是不可能的。

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

    诚然,说起来容易做起来难,但这是我看到的唯一正确的方式。有时我认为应该有能力为远离异常路径的正常执行路径编写独立的析构函数逻辑,因为有时析构函数感觉有点像它们通过尝试处理这两个路径而拥有双倍的责任(例如,需要显式解除的范围保护;如果它们能够ld将异常破坏路径与非异常破坏路径区分开来)。

    然而,最终的问题是我们不能失败,这是一个很难在所有情况下完美解决的概念设计问题。如果你不太习惯于复杂的控制结构,大量的微小物体相互作用,而是用稍微大一点的方式来模拟你的设计(例如:粒子系统用一个析构函数来破坏整个粒子系统,而不是每个粒子一个单独的非平凡的析构函数),那就容易多了。当您在这种更粗糙的层次上建模您的设计时,可以处理的非平凡析构函数更少,而且还可以承担确保析构函数不会失败所需的任何内存/处理开销。

    自然,这是最简单的解决方案之一,就是减少使用析构函数的次数。在上面的粒子例子中,也许在销毁/移除一个粒子时,应该做一些可能由于任何原因而失败的事情。在这种情况下,不需要通过粒子的DTOR调用这样的逻辑,而可以在异常路径中执行,相反,当粒子系统 移除 粒子。移除粒子可能总是在非异常路径中进行。如果系统被破坏,它可能只会清除所有粒子,而不会干扰可能失败的单个粒子移除逻辑,而可能失败的逻辑仅在粒子系统正常执行期间执行,当它移除一个或多个粒子时。

    如果避免使用非平凡的析构函数处理大量的微小对象,通常会出现类似这样的解决方案。当你陷入一团混乱之中,似乎不可能成为例外的时候,安全性就是当你被许多微小的物体缠住,这些物体都有不平凡的数据终端。

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