51

The more we use RAII in C++, the more we find ourselves with destructors that do non-trivial deallocation. Now, deallocation (finalization, however you want to call it) can fail, in which case exceptions are really the only way to let anybody upstairs know of our deallocation problem. But then again, throwing-destructors are a bad idea because of the possibility of exceptions being thrown during stack unwinding. std::uncaught_exception() lets you know when that happens, but not much more, so aside from letting you log a message before termination there's not much you can do, unless you're willing to leave your program in an undefined state, where some stuff is deallocated/finalized and some not.

One approach is to have no-throw destructors. But in many cases that just hides a real error. Our destructor might, for example, be closing some RAII-managed DB connections as a result of some exception being thrown, and those DB connections might fail to close. This doesn't necessarily mean we're ok with the program terminating at this point. On the other hand, logging and tracing these errors isn't really a solution for every case; otherwise we would have had no need for exceptions to begin with. With no-throw destructors we also find ourselves having to create "reset()" functions that are supposed to be called before destruction - but that just defeats the whole purpose of RAII.

Another approach is just to let the program terminate, as it's the most predictable thing you can do.

Some people suggest chaining exceptions, so that more than one error can be handled at a time. But I honestly never actually seen that done in C++ and I've no idea how to implement such a thing.

So it's either RAII or exceptions. Isn't it? I'm leaning toward no-throw destructors; mainly because it keeps things simple(r). But I really hope there's a better solution, because, as I said, the more we use RAII, the more we find ourselves using dtors that do non-trivial things.

Appendix

I'm adding links to interesting on-topic articles and discussions I've found:

4

8 回答 8

18

You SHOULD NOT throw an exception out of a destructor.

Note: Updated to refeclt changes in the standard:

In C++03
If an exception is already propagating then the application will terminate.

In C++11
If the destructor is noexcept (the default) then the application will terminate.

The Following is based on C++11

If an exception escapes a noexcept function it is implementation defined if the stack is even unwound.

The Following is based on C++03

By terminate I mean stop immediately. Stack unwinding stops. No more destructors are called. All bad stuff. See the discussion here.

throwing exceptions out of a destructor

I don't follow (as in disagree with) your logic that this causes the destructor to get more complicated.
With the correct usage of smart pointers this actually makes the destructor simpler as everything now becomes automatic. Each class tides up its own little piece of the puzzle. No brain surgery or rocket science here. Another Big win for RAII.

As for the possibility of std::uncaught_exception() I point you at Herb Sutters article about why it does not work

于 2008-10-01T19:19:26.033 回答
9

From the original question:

Now, deallocation (finalization, however you want to call it) can fail, in which case exceptions are really the only way to let anybody upstairs know of our deallocation problem

Failure to cleanup a resource either indicates:

  1. Programmer error, in which case, you should log the failure, followed by notifying the user or terminating the application, depending on application scenario. For example, freeing an allocation that has already been freed.

  2. Allocator bug or design flaw. Consult the documentation. Chances are the error is probably there to help diagnose programmer errors. See item 1 above.

  3. Otherwise unrecoverable adverse condition that can be continued.

For example, the C++ free store has a no-fail operator delete. Other APIs (such as Win32) provide error codes, but will only fail due to programmer error or hardware fault, with errors indicating conditions like heap corruption, or double free, etc.

As for unrecoverable adverse conditions, take the DB connection. If closing the connection failed because the connection was dropped -- cool, you're done. Don't throw! A dropped connection (should) result in a closed connection, so there's no need to do anything else. If anything, log a trace message to help diagnose usage issues. Example:

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

None of these conditions justify attempting a second kind of unwind. Either the program can continue normally (including exception unwind, if unwind is in progress), or it dies here and now.

Edit-Add

If you really want to be able to keep some sort of link to those DB connections that can't close -- perhaps they failed to close due to intermittent conditions, and you'd like to retry later -- then you can always defer cleanup:

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

Very not pretty, but it might get the job done for you.

于 2008-10-01T22:09:08.667 回答
6

你在看两件事:

  1. RAII,它保证在退出范围时清理资源。
  2. 完成一个操作并确定它是否成功。

RAII 承诺它将完成该操作(释放内存、关闭试图刷新它的文件、结束试图提交它的事务)。但是因为它是自动发生的,程序员不需要做任何事情,它不会告诉程序员它“尝试”的那些操作是否成功。

异常是报告某事失败的一种方式,但正如您所说,C++ 语言有一个限制,这意味着它们不适合从析构函数 [*] 中执行此操作。返回值是另一种方式,但更明显的是析构函数也不能使用它们。

因此,如果您想知道您的数据是否已写入磁盘,则不能使用 RAII。它并没有“破坏 RAII 的全部目的”,因为 RAII 仍然会尝试编写它,并且它仍然会释放与文件句柄(DB 事务等)相关的资源。它确实限制了 RAII 的功能——它不会告诉您数据是否已写入,因此您需要一个close()可以返回值和/或抛出异常的函数。

[*] 这也是一个很自然的限制,存在于其他语言中。如果您认为 RAII 析构函数应该抛出异常来表示“出现问题!”,那么当已经发生异常时,必须发生一些事情,即“甚至在此之前还有其他事情出错了!”。我知道使用异常的语言不允许同时出现两个异常-语言和语法根本不允许。如果 RAII 是做你想做的事,那么异常本身需要重新定义,这样一个线程一次有不止一件事情出错,两个异常向外传播和调用两个处理程序是有意义的,一个来处理每个。

其他语言允许第二个异常掩盖第一个异常,例如,如果一个finally块在 Java 中抛出。C++ 几乎说第二个必须被抑制,否则terminate称为(在某种意义上抑制两者)。在这两种情况下,较高的堆栈级别都不会通知这两个故障。有点不幸的是,在 C++ 中,您无法可靠地判断另一个异常是否是一个太多(uncaught_exception不会告诉您,它会告诉您一些不同的东西),因此您甚至无法抛出存在的情况在飞行中已经不是一个例外。但即使你能在那种情况下做到这一点,你仍然会被塞满在一个多一个是太多的情况下。

于 2011-07-09T09:43:35.467 回答
5

当我向一位同事解释异常/RAII 概念时,这让我想起了一个问题:“嘿,如果计算机关闭,我可以抛出什么异常?”

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

异常和析构函数是怎么回事?

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

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

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

如果被授权会发生什么?

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

如果你的析构函数无法释放你的资源,你会怎么做?您的对象可能已损坏一半,您会从“外部”捕获该信息做什么?再试一次?(如果是,那么为什么不从析构函数中再次尝试?...)

也就是说,如果您仍然可以访问半破坏的对象:如果您的对象在堆栈上(这是 RAII 工作的基本方式)怎么办?如何访问其范围之外的对象?

在异常中发送资源?

您唯一的希望是在异常中发送资源的“句柄”并希望代码中的代码,嗯......再次尝试释放它(见上文)?

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

 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(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”,它应该是“试图释放资源与绝对不想被释放的资源,即使受到破坏的威胁

:-)

于 2008-10-16T21:51:22.670 回答
2

我要问的一件事是,忽略终止等问题,如果您的程序由于正常破坏或异常破坏而无法关闭其数据库连接,您认为适当的响应是什么。

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

我认为,如果我们对这个问题有答案,那么我们就会更好地了解如何进行。

对我来说,没有什么策略特别明显。除此之外,我真的不知道关闭数据库连接抛出意味着什么。如果 close() 抛出,连接的状态是什么?它是关闭的、仍然开放的还是不确定的?如果它是不确定的,有没有办法让程序恢复到已知状态?

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

于 2008-10-05T13:47:20.217 回答
1

你的破坏可能失败的原因是什么?为什么不在实际破坏之前处理这些?

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

  • 交易进行中。(检查 std::uncaught_exception() - 如果为 true,则回滚,否则提交 - 这些是最可能需要的操作,除非您在实际关闭连接之前有另外说明的策略。)
  • 连接断开。(检测并忽略。服务器会自动回滚。)
  • 其他数据库错误。(记录下来,以便我们可以调查并可能在将来适当地处理。这可能是检测和忽略。同时,尝试回滚并再次断开连接并忽略所有错误。)

如果我正确理解 RAII(我可能不会),那么重点就是它的范围。因此,无论如何,您并不希望交易持续时间比对象长。因此,在我看来,您希望尽可能确保关闭是合理的。RAII 并没有使它变得独一无二——即使根本没有对象(比如在 C 中),您仍然会尝试捕获所有错误条件并尽可能地处理它们(有时会忽略它们)。RAII 所做的只是强制您将所有代码放在一个地方,无论有多少函数使用该资源类型。

于 2008-10-05T14:06:50.957 回答
0

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

bool std::uncaught_exception()

如果它返回 true,此时抛出将终止程序,如果不是,则抛出是安全的(或至少与以往一样安全)。这在 ISO 14882(C++ 标准)的第 15.2 和 15.5.3 节中进行了讨论。

这并没有回答在清理异常时遇到错误时该怎么做的问题,但确实没有任何好的答案。但是,如果您在后一种情况下等待执行不同的操作(例如 log&ignore 它),它确实可以让您区分正常退出和异常退出,而不是简单地恐慌。

于 2008-10-01T19:33:01.833 回答
0

如果在最终确定过程中确实需要处理一些错误,则不应该在析构函数中完成。相反,应使用返回错误代码或可能抛出的单独函数。要重用代码,您可以在析构函数中调用此函数,但不能让异常泄漏。

正如一些人提到的,这并不是真正的资源释放,而是退出期间的资源提交。正如其他人提到的,如果在强制关机期间保存失败,您该怎么办?可能没有完全令人满意的答案,但我建议采用以下方法之一:

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

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

于 2021-09-29T11:32:09.300 回答