如果你不应该在析构函数中抛出异常,你如何处理其中的错误?

大多数人说从来没有从析构函数抛出异常——这样做会导致未定义的行为。Stroustrup指出vector析构函数显式地为每个元素调用析构函数。这意味着如果元素析构函数抛出,则vector销毁失败…实际上没有很好的方法来防止析构函数抛出异常,所以标准库不保证元素析构函数是否抛出异常”(摘自附录E3.2). c。

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

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

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

显然,这种错误很少见,但也有可能发生。

140867 次浏览
从析构函数抛出异常是危险的。
如果另一个异常已经在传播,则应用程序将终止
#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在他的书《Effective c++ "Effective c++ "》中有一篇关于这个主题的优秀文章;

编辑:

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

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

关于从析构函数抛出,真正要问自己的问题是“调用者可以用它做什么?”你是否真的可以对异常做一些有用的事情,来抵消从析构函数抛出的危险?

如果我销毁了一个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的删除失败时,我如何以一种保证不会失败的方式删除?该死的是我的记忆!

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

下面是一种安全的做法

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


};

也可以看到这个常见问题解答

抛出析构函数可能导致崩溃,因为这个析构函数可能被调用为“堆栈展开”的一部分。 堆栈展开是一个在抛出异常时发生的过程。 在这个过程中,从“try”开始,直到抛出异常之前,所有被推入堆栈的对象都将被终止——>它们的析构函数将被调用。 在此过程中,不允许抛出另一个异常,因为不可能同时处理两个异常,因此,这将引发abort()调用,程序将崩溃,控制权将返回操作系统。< / p >

其他人都解释了为什么抛出析构函数很糟糕……你能做些什么呢?如果您正在执行一个可能失败的操作,请创建一个单独的公共方法来执行清理,并可以抛出任意异常。在大多数情况下,用户会忽略这一点。如果用户希望监视清理的成功/失败,他们可以简单地调用显式清理例程。

例如:

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调用在try块到throw-的路径上构造的自动对象的析构函数的过程 表达式称为“堆栈unwind”。[注意:如果在堆栈展开期间调用析构函数退出 异常,std::terminate被调用(15.5.1)。因此,析构函数通常应该捕获异常,而不是let 它们从析构函数中传播出去。- end note]

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

...但我确实相信容器类型类的析构函数,比如vector,不应该掩盖它们所包含的类抛出的异常。在本例中,我实际上使用了递归调用自身的“free/close”方法。是的,我说的是递归。这种疯狂是有原因的。异常传播依赖于存在一个堆栈:如果发生单个异常,那么一旦例程返回,剩余的析构函数仍将运行,挂起的异常将传播,这很好。如果出现多个异常,那么(取决于编译器)要么第一个异常传播,要么程序终止,这是可以的。如果出现如此多的异常,以至于递归溢出堆栈,那么就有严重的问题,有人会发现它,这也是可以的。就我个人而言,我宁可错误爆发,也不愿隐藏、秘密和阴险。

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

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

A:有几种选择:

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

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

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

但加进d'tors好吗?

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

在一些奇怪的情况下,它实际上是从析构函数抛出的伟大的想法。 比如“必须检查”错误代码。这是一个从函数返回的值类型。如果调用者读取/检查包含的错误代码,返回值将静默销毁。 但是,如果返回的错误代码在返回值超出作用域之前还没有被读取,它将抛出一些异常从析构函数

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

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

当我们把类分成两种类型时,整个问题就更容易思考了。类医生可以有两个不同的职责:

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

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

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

如果我们遵循RAII路线,并允许在d'tors中具有(C)语义的对象,我认为我们还必须允许这种d'tors可以抛出的奇数情况。因此,你不应该将这样的对象放入容器中,并且如果提交器在另一个异常活动时抛出,程序仍然可以terminate()


关于错误处理(提交/回滚语义)和异常,安德烈Alexandrescu有一个很好的讨论: c++中的错误处理/声明性控制流 .(保存在NDC 2014)

在细节中,他解释了Folly库如何为他们的ScopeGuard工具实现UncaughtExceptionCounter

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

虽然讨论的重点不是从d'tor中抛出,但它展示了一个可以使用今天从d'tor中摆脱何时投球的问题的工具。

未来中,有五月是对此的std特征,see N3614关于它的讨论

Upd '17: c++ 17的std特性是std::uncaught_exceptions afaikt。我将快速引用cppref的文章:

笔记

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

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

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

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

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

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

发布:

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

提交:

在这里,您需要类似std::lock_guard为互斥对象提供的那种RAII包装器对象。在这些情况下,你根本不需要在dtor中放入提交逻辑。你有一个专门的API,然后包装对象,将RAII提交在他们的博士和处理错误。记住,你可以在析构函数中捕获异常;发出它们才是致命的。这也让你可以通过构建不同的包装器(例如std::unique_lock vs. std::lock_guard)来实现策略和不同的错误处理,并确保你不会忘记调用提交逻辑——这是将它放在dtor中的唯一合理的理由。

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

Andrzej krzemiezynski有一篇关于抛出析构函数的文章:

他指出c++ 11有一种机制可以覆盖析构函数的默认noexcept:

在c++ 11中,析构函数隐式指定为noexcept。即使你没有添加任何规范并像这样定义你的析构函数:

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

编译器仍然会无形地将规范noexcept添加到析构函数中。这意味着当你的析构函数抛出异常时,std::terminate将被调用,即使没有双异常情况。如果你真的决定允许你的析构函数抛出,你必须显式地指定这个;你有三个选择:

  • 显式地将析构函数指定为noexcept(false)
  • 从另一个已经将析构函数指定为noexcept(false)的类继承您的类。
  • 在类中放入一个非静态数据成员,该成员已经将析构函数指定为noexcept(false)

最后,如果您决定抛出析构函数,则应该始终注意双异常的风险(在堆栈因异常而被unwind时抛出)。这将导致调用std::terminate,这很少是你想要的。为了避免这种行为,你可以在使用std::uncaught_exception()抛出一个新异常之前简单地检查是否已经有一个异常。

我的问题是-如果从析构函数抛出会导致 未定义的行为,如何处理过程中发生的错误 析构函数?< / p >

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

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

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

务实的解决办法也许只是确保…的机会 一次又一次的失败在天文学上是不可能的,因为做东西 在某些情况下,失败几乎是不可能的

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

诚然,这说起来容易做起来难,但这是我看到的唯一正确的方法。有时我认为应该能够为正常的执行路径编写独立的析构函数逻辑,而不是异常的执行路径,因为有时析构函数通过尝试处理这两种路径而感觉有点像他们有双倍的责任(一个例子是需要显式撤销的范围守卫;如果他们能够区分异常破坏路径和非异常破坏路径,他们就不需要这样做)。

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

最简单的解决方法之一就是少用析构函数。在上面的粒子例子中,也许在摧毁/移除一个粒子时,有些事情可能会因为某种原因而失败。在这种情况下,你可以在粒子系统删除一个粒子时,而不是通过粒子的dtor调用这样的逻辑,因为它可以在异常路径中执行。移除一个粒子可能总是在一个非例外的路径上进行。如果系统被破坏了,也许它可以清除所有的粒子,而不用担心单个粒子移除逻辑会失败,而失败的逻辑只会在粒子系统正常执行时执行,即移除一个或多个粒子。

如果避免使用非平凡析构函数处理大量小对象,通常会出现这样的解决方案。当你被大量的小对象纠缠在一起时,你可能会陷入混乱,似乎不可能是异常安全的,这些小对象都有非平凡的dtor。

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

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

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


如果正在处理未捕获的异常,您的代码永远不知道附加的异常是否会被捕获,或者是否会归档未捕获的异常处理机制,因此永远不知道抛出异常是否安全。

不过,有可能知道正在处理未捕获的异常(https://en.cppreference.com/w/cpp/error/uncaught_exception),所以你可以通过检查条件来过度处理,只在情况不是这样的情况下抛出(在某些情况下它不会抛出,当它是安全的)。

但在实践中,将程序分成两种可能的行为并没有什么用——它只是无助于你设计出一个设计良好的程序。


如果抛出析构函数忽略未捕获的异常处理是否正在进行,为了避免可能调用std::terminate,必须保证在对象的生命周期内抛出的所有可能从其析构函数抛出异常的异常都在对象销毁开始之前被捕获。 它的用途相当有限;你几乎不能使用所有被合理地允许以这种方式抛出析构函数的类;如果只允许某些类例外,而这些类的使用却受到限制,这也会阻碍程序的良好设计