RAII与异常处理

53
在C++中,我们越多地使用RAII,就越发现自己需要执行非平凡的清理操作。现在,如果清理(finalization,或者你想怎么称呼它)失败了,那么异常是让上层知道我们的清理问题的唯一方法。但是,抛出析构函数是个坏主意,因为在堆栈展开期间可能会抛出异常。std::uncaught_exception() 可以让你知道发生了什么,但除此之外,并没有太多可做的事情,除非你愿意让你的程序处于未定义状态,在这种状态下,一些东西被解除分配/完成,而其他东西则没有。

一种方法是使用不抛出异常的析构函数。但在许多情况下,这只是隐藏了一个真正的错误。例如,由于某个异常被抛出,我们的析构函数可能正在关闭一些由RAII管理的DB连接,并且这些DB连接可能无法关闭。这并不意味着我们在这一点上可以接受程序终止。另一方面,记录和跟踪这些错误并不是每种情况的解决方案;否则,我们一开始就不需要使用异常。

另一种方法就是让程序终止,因为这是最可预测的事情。

有些人建议链接异常,以便可以一次处理多个错误。但是我在C ++中从未见过这样的操作,也不知道如何实现。

所以要么使用RAII,要么使用异常。是吗?我倾向于无抛出析构函数;主要是因为它使事情保持简单。但我真的希望有更好的解决方案,因为正如我所说,我们越多地使用RAII,就越多地发现自己使用非平凡的dtors。

附录

我添加了一些我发现的有趣的关于该主题的文章和讨论的链接:


也许只需为您的类注册一个回调函数,在销毁期间发生任何错误时调用该函数?作为来自JavaScript的人,我很惊讶在C++中很少看到使用回调来解决这样的问题。 - Andy
8个回答

17

在析构函数中,不应该抛出异常。

注:已更新以反映标准的更改:

在C++03中
如果异常已经在传播,则应用程序将终止。

在C++11中
如果析构函数是 noexcept(默认情况下),则应用程序将终止。

以下基于C++11

如果一个异常从一个 noexcept 函数中逃脱,那么栈是否被卸载甚至取决于实现。

以下基于C++03

通过终止,我意味着立即停止。堆栈卸载停止。不再调用任何其他析构函数。所有糟糕的事情都发生了。请参阅此处的讨论。

关于在析构函数中抛出异常

对于我不理解(或者不同意)的关于析构函数变得更加复杂的逻辑
使用智能指针的正确用法使得析构函数更简单,因为一切都变得自动化了。每个类都会整理其自己的小拼图。这里没有脑外科手术或火箭科学。另一个RAII的巨大胜利。

至于 std::uncaught_exception() 的可能性,我指向 Herb Sutter 写的文章,说明为什么它无法工作


7
我从未说密集故障的可能性会导致析构函数变得更加复杂。是 RAII 导致了这一点。你在使用析构函数清理资源时,越多错误就越容易出现。请注意,我并未对原话进行解释或添加任何额外内容。 - Assaf Lavie
2
我同意Assaf的观点。你可以将更多(清理)内容放在析构函数中,这样当抛出异常时就会执行它们,但是如果只有构造函数和(隐式)析构函数的情况下,这种情况就会退化。因此,在析构函数中放置大量内容-> 就会有很多异常的机会。 - QBziZ
1
抱歉,QBziZ,我无法理解你的逻辑。为什么不发布一个回答,用适当的细节来解释你的立场,以便可以适当地投票支持或反对。 - Martin York
1
@QBziZ 我同意Martin的说法:你所说的与“程序中添加的代码越多,失败的风险就越大”的情况非常相似。像任何清理功能一样,析构函数必须尝试清理。但与其他功能不同的是,它不能抛出异常,因为析构函数不像其他功能。 - paercebal
1
这是 std::fstream 的设计,缺省情况下在析构函数中文件关闭失败时不会采取任何措施(析构函数中捕获并舍弃任何异常)。资源类忽略这些错误。但该资源提供功能,使得资源的使用者可以检查并在适当/可能的情况下采取行动。例如,在用户应用程序中,您可以调用 close() 并检查状态并显示错误对话框(允许纠正操作)。 - Martin York
显示剩余8条评论

8

从原问题中:

现在,释放(finalization 或者你想怎么叫它)可能会失败,在这种情况下,异常确实是让上层知道我们的释放问题的唯一方法

未能清理资源要么表明:

  1. 程序员错误。在这种情况下,应记录故障并通知用户或终止应用程序,具体取决于应用场景。例如,释放已经被释放的分配。

  2. 分配器错误或设计缺陷。请查阅文档。很有可能错误就在那里以帮助诊断程序员错误。参见第1条。

  3. 其他无法恢复的不利条件,可以继续执行。

例如,C++自由存储空间具有非失败的delete操作符。其他API(例如Win32)提供错误代码,但仅由于程序员错误或硬件故障而发生故障,错误指示如堆损坏或双重释放等条件。

至于不可恢复的不利条件,请考虑数据库连接。如果关闭连接失败是因为连接中断了——好的,你完成了。不要抛出异常!连接已经中断了(应该),所以没有必要再做其他事情。如果有的话,记录一条跟踪消息以帮助诊断使用问题。示例:

class DBCon{
public:
  DBCon() { 
    handle = fooOpenDBConnection();
  }
  ~DBCon() {
    int err = fooCloseDBConnection();
    if(err){
      if(err == E_fooConnectionDropped){
        // do nothing.  must have timed out
      } else if(fooIsCriticalError(err)){
        // critical errors aren't recoverable.  log, save 
        //  restart information, and die
        std::clog << "critical DB error: " << err << "\n";
        save_recovery_information();
        std::terminate();
      } else {
        // log, in case we need to gather this info in the future,
        //  but continue normally.
        std::clog << "non-critical DB error: " << err << "\n";
      }
    }
    // done!
  }
};

这些情况都不足以证明需要尝试进行第二次取消。程序要么可以正常继续(包括异常取消,如果取消正在进行中),要么就在此处立即停止。

编辑-添加

如果您真的想保留某些无法关闭的DB连接的某种链接 - 也许它们由于间歇性条件而无法关闭,并且您希望稍后重试 - 那么您可以随时推迟清理:

vector<DBHandle> to_be_closed_later;  // startup reserves space

DBCon::~DBCon(){
  int err = fooCloseDBConnection();
  if(err){
    ..
    else if( fooIsRetryableError(err) ){
      try{
        to_be_closed.push_back(handle);
      } catch (const bad_alloc&){
        std::clog << "could not close connection, err " << err << "\n"
      }
    }
  }
}

这可能不是很好看,但它可能会为您完成工作。


5
你的代码风格假设你的对象通过使用terminate()知道应用程序最好的处理方式。但实际上这几乎从来不是这种情况,决定如何处理错误的是周围的代码(即控制代码)具有上下文。 - Martin York
1
你似乎在暗示,除非是代码或设计中的错误,否则就可以恢复,就像你的DB示例一样;但是,在RAII解绑期间可能会遇到不可恢复的错误的情况。 - Assaf Lavie
3
清理失败后正确的行为方式并不是“继续好像没出问题一样”或“立即杀掉整个系统”,有许多情况需要考虑。例如,“保存文档”方法应该在一切正常(包括关闭)时才不会抛出异常。如果保存文档需要同时写入两个文件,并且在SaveDocument方法期间USB驱动器被拔出,一个文件的写入操作抛出异常(很可能发生),那么另一个文件的析构函数也会失败。让应用程序立即终止会显得非常粗鲁,但是…… - supercat
1
忽略文件关闭失败不应被视为可接受的。正确的行为是让异常解除到用户可以得知工作或未工作的情况并相应地采取行动的点。 - supercat
@supercat - 很遗憾,你不能这样做。一个现有的异常可能已经在解除,而一个离开析构函数的异常与现有的异常冲突会导致强制调用std::terminate()。最好的方法是,你可以添加一个检查错误的函数,并指示用户在自动销毁之前“如果他们想要”的调用它。 - Aaron
显示剩余5条评论

7
你正在查看两个内容:
1. RAII,它保证在作用域退出时清理资源。 2. 完成操作并找出是否成功。
RAII承诺会完成操作(释放内存、尝试刷新文件并关闭它、尝试提交事务并结束它)。但是因为它是自动发生的,程序员不需要做任何事情,所以它不告诉程序员它“尝试”的操作是否成功。
异常是报告失败的一种方式,但正如你所说,C++语言有一个限制,意味着从析构函数中使用异常不适合报告失败。返回值是另一种方式,但显然析构函数也不能使用它们。
因此,如果您想知道数据是否写入磁盘,就不能使用RAII来实现。这并没有“完全摧毁RAII的目的”,因为RAII仍然会尝试写入数据,并释放与文件句柄(DB事务等)关联的资源。它确实限制了RAII的功能——它无法告诉您数据是否已写入,因此您需要一个可以返回值和/或抛出异常的close()函数。
[*] 这是一种非常自然的限制,在其他语言中也存在。如果您认为RAII析构函数应该抛出异常以表示“出现问题了!”,那么当已经有异常在运行时,“还有其他问题!”就必须发生。我所知道使用异常的语言不允许同时存在两个正在运行的异常——语言和语法根本不允许这样做。如果RAII要做您想做的事情,那么异常本身需要被重新定义,以便一个线程可以同时有多个问题,并且两个异常可以向外传播并调用两个处理程序,一个处理每个异常。
其他语言允许第二个异常掩盖第一个异常,例如在Java中,如果finally块抛出异常。C++基本上表示第二个异常必须被抑制,否则将调用terminate(在某种意义上抑制两者)。在任何情况下,高级堆栈级别都不会被告知两个故障。有点不幸的是,在C++中,您无法可靠地确定一个以上的异常是否太多(uncaught_exception无法告诉您这一点,它告诉您不同的信息),因此,即使在没有异常的情况下也不能抛出异常。但是,即使在这种情况下,当再次出现一个异常时,您仍然会遇到麻烦。

6

当我向同事解释异常/RAII概念时,他问了我一个问题:“嘿,如果电脑关闭了,我可以抛出什么异常?”

无论如何,我同意Martin York的回答RAII vs. exceptions

异常和析构函数有什么关系?

很多C++特性依赖于非抛出的析构函数。

实际上,整个RAII概念及其与代码分支(返回、抛出等)的协作都基于释放不会失败这一事实。同样,当您希望为对象提供高级别的异常保证时,某些函数不应该失败(例如std::swap)。

并不是说您不能通过析构函数抛出异常。只是语言甚至不会尝试支持这种行为。

如果允许这样做会发生什么?

只是为了好玩,我试着想象一下...

如果您的析构函数无法释放资源,您将怎么办?您的对象可能已经被部分析构,从“外部”catch中获取到这些信息后,您将怎么处理?重试吗?(如果是的话,那为什么不在析构函数内部重试呢?...)

也就是说,如果您无论如何都可以访问您的半析构对象:如果您的对象在堆栈上(这是RAII的基本工作方式),那么您如何访问其作用域之外的对象?

将资源发送到异常中?

您唯一的希望就是将资源的“句柄”发送到异常中,并希望catch中的代码能够再次尝试释放它(请参见上文)?

现在,想象一些有趣的事情:

 void doSomething()
 {
    try
    {
       MyResource A, B, C, D, E ;

       // do something with A, B, C, D and E

       // Now we quit the scope...
       // destruction of E, then D, then C, then B and then A
    }
    catch(const MyResourceException & e)
    {
       // Do something with the exception...
    }
 }

现在,让我们想象一下由于某种原因D的析构函数未能释放资源。你编码发送了一个异常,这将被catch捕获。一切顺利:您可以以您想要的方式处理失败(如何以建设性的方式处理仍然使我困惑,但现在不是问题)。
但是......
如何在MULTIPLE异常中发送MULTIPLE资源?
现在,如果~D失败了,那么~C也会失败。 ~B和~A也是如此。
通过这个简单的例子,您有4个析构函数在“同一时刻”失败(退出作用域)。你所需要的不是含有一个异常的catch,而是一个带有异常数组的catch(希望为此生成的代码不会...抛出异常)。
    catch(const std::vector<MyResourceException> & e)
    {
       // Do something with the vector of exceptions...
       // Let's hope if was not caused by an out-of-memory problem
    }

让我们开始吧(我喜欢这首歌):每个抛出的异常都是不同的(因为原因不同:请记住,在C++中,异常不需要派生自std::exception)。现在,您需要同时处理四个异常。您如何编写catch子句来处理四个异常类型,并按它们被抛出的顺序进行处理?
如果有多个相同类型的异常由多个失败的释放引发怎么办?如果在分配异常数组的数组内存时,您的程序耗尽了内存并且...抛出了内存不足异常呢?
您确定要花时间解决这种问题而不是花时间弄清楚为什么释放失败或以其他方式对其做出反应吗?
显然,C++设计师没有看到可行的解决方案,只是在那里减少损失。
问题不在于RAII与异常之间的关系...
不,问题在于有时候事情会失败得无法挽回。
RAII与异常结合使用效果很好,只要满足一些条件。其中之一是:析构函数不会抛出异常。你所看到的对立只是一个单一模式的极端情况,结合了两个“名字”:异常和RAII
在析构函数中遇到问题时,我们必须接受失败,并挽救可以挽救的东西:“DB连接无法被释放?对不起。让我们至少避免这个内存泄漏并关闭此文件。”
虽然异常模式(应该)是C++中的主要错误处理,但它不是唯一的错误处理方式。当C++异常不是解决方案时,您应该使用其他错误/日志机制来处理异常情况。
因为您在语言中遇到了障碍,没有其他我所知道或听说过的语言能够正确地通过这个问题而不会把房子拆掉(C#尝试是值得的,而Java的尝试仍然是伤害我的笑话...我甚至不想谈论脚本语言,他们将以同样的沉默方式失败)。
但最终,无论您写多少代码,用户关闭电脑时都无法保护您。
您可以做的最好的事情,已经写出来了。我的个人偏好是使用抛出finalize方法、非抛出析构函数清理未手动完成的资源以及日志/消息框(如果可能)来警报有关析构函数失败的信息。
也许您没有准备好正确的决斗。与其是“RAII vs. Exception”,不如是“尝试释放资源 vs. 这些资源绝对不想被释放,即使受到破坏的威胁”。

2
C++ 无法支持此行为的根本原因是没有通用的异常基类型。如果有一个包括获取 childsibling 指针属性以及处理多个异常所需的 should_catch<T>is_resolved 方法的通用基类型,那么处理多个异常应该是可行的。如果任何待处理异常满足 Foo,则应运行 catch Foo;在 catch 后,如果仍有未解决的异常,则系统应继续展开。 - supercat
但析构函数不能是接受失败的地方。这需要由用户代码来处理。析构函数通常无法进行应用程序级别的考虑,甚至无法决定是否记录任何内容。 - einpoklum
在这些情况下,您可以拥有一个close()方法,该方法应该是幂等的,并且如果有人真的想要处理它,可以手动调用。然后,析构函数可以在仍然需要时调用close()并捕获任何错误。但是,这需要认真思考您的类如何工作(例如,一旦调用了close()方法,它是否仍然可以使用,如果不能使用但仍在使用会发生什么?)。但是根据我的经验,大多数类不需要此模式,因为当您清理自己的东西时,您不希望重新启动东西,只需记录问题即可。 - paercebal

2

如果您的程序由于正常销毁或异常销毁而无法关闭其数据库连接,忽略终止等问题,我想问的是,您认为适当的响应是什么?

您似乎排除了“仅记录”并且不愿意终止,那么您认为最好的做法是什么?

我认为,如果我们有了这个问题的答案,那么我们就会更好地知道如何继续进行。

对我来说,没有一种策略特别明显;除此之外,我真的不知道关闭数据库连接抛出的含义。如果close()抛出异常,则连接的状态是什么?它是关闭的、仍然打开还是不确定的?如果是不确定的,程序是否有任何方法可以恢复到已知状态?

析构函数失败意味着无法撤消对象的创建;将程序返回到已知(安全)状态的唯一方法是拆除整个进程并重新启动。


2
如果有办法的话,正确的做法是向调用者报告情况。抛出异常的代码通常没有理由相信即使存在即时应用程序也是最不有害的行动,但是如果未通知清除异常的调用者,则没有理由指望调用者会做正确的事情。C++没有提供必要的机制来通知调用者发生了什么,并不意味着不需要这样的机制来执行语义上正确的操作——这仅仅使得执行语义上正确的操作变得不可能。 - supercat

1

你的销毁失败的原因有哪些?为什么不在销毁之前先处理这些问题呢?

例如,关闭数据库连接可能是因为:

  • 事务正在执行(检查std::uncaught_exception() - 如果为true,则回滚,否则提交 - 这些是最常见的期望动作,除非有规定要做其他操作,在实际关闭连接之前)
  • 连接被断开(检测并忽略。服务器将自动回滚。)
  • 其他DB错误(记录下来,以便我们可以在未来适当地进行调查和处理。这可能是检测并忽略。同时,尝试回滚、重新断开连接并忽略所有错误。)

如果我正确理解RAII(我可能不正确),那么它的重点在于其范围。所以你不希望事务比对象生存周期更长。因此,你想尽力确保关闭。RAII并没有使这个过程变得独特 - 即使完全没有对象(比如在C中),你仍然会尝试捕捉所有错误条件,并尽可能地处理它们(有时是忽略它们)。RAII强制你将所有这些代码放在一个单一的位置,无论有多少个函数使用该资源类型。


0

如果在终止过程中需要处理一些错误,那么不应该在析构函数内部完成。相反,应该使用一个单独的函数来返回错误代码或可能抛出异常。为了重用代码,可以在析构函数内调用此函数,但必须不允许异常泄漏。

正如一些人所提到的,这并不是真正的资源释放,而是类似于退出时的资源提交。正如其他人所提到的,如果在强制断电期间保存失败,你能做什么呢?可能没有完全令人满意的答案,但我建议采用以下方法之一:

  • 允许失败和损失发生
  • 将未保存的部分保存到其他地方,并允许稍后进行恢复(如果这也不起作用,请参见另一种方法)

如果您不喜欢这两种方法中的任何一种,请让用户明确保存。告诉他们不要依赖于断电期间的自动保存。


0

您可以通过检查来确定当前是否存在异常(例如,我们正在执行堆栈展开,复制异常对象或类似操作的 throw 和 catch 块之间),

bool std::uncaught_exception()

如果返回true,则在此处抛出异常将终止程序,否则抛出异常是安全的(或者至少尽可能安全)。这在ISO 14882(C ++标准)的第15.2和15.5.3节中进行了讨论。

这并没有回答当您在清理异常时遇到错误时该怎么做的问题,但实际上并没有什么好的答案。但它确实可以让您区分正常退出和异常退出,如果您在后一种情况下等待执行某些不同的操作(例如记录并忽略它),而不是简单地惊慌失措。


注意:请参考马丁上面的帖子...即使在try{..}中,如果异常展开处于活动状态,这也可能返回true。 - Aaron

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接