为什么 C++ 不使用 std::nested_exception 来允许从析构函数中抛出?

Why doesn't C++ use std::nested_exception to allow throwing from destructor?

从析构函数中抛出异常的主要问题是,在调用析构函数的那一刻,另一个异常可能是 "in flight" (std::uncaught_exception() == true),因此在这种情况下该怎么做并不明显. "Overwriting" 旧异常与新异常是处理这种情况的可能方法之一。但决定在这种情况下必须调用 std::terminate(或另一个 std::terminate_handler)。

C++11 通过 std::nested_exception class 引入了嵌套异常功能。此功能可用于解决上述问题。旧的(未捕获的)异常可以嵌套到新的异常中(或者反之亦然?),然后可以抛出嵌套的异常。但是这个想法没有被使用。在 C++11 和 C++14 中,std::terminate 仍然在这种情况下被调用。

所以问题。是否考虑了嵌套异常的想法?它有什么问题吗?难道C++17不会改变这种情况吗?

当你的析构函数作为堆栈展开过程的一部分被执行时(当你的对象不是作为堆栈展开的一部分创建的)1 时,你引用的问题就会发生,而你的析构函数需要发出异常。

那么它是如何工作的呢?你有两个例外。异常 X 是导致堆栈展开的异常。 Exception Y 是析构函数要抛出的异常。 nested_exception只能装其中一个

所以也许你有例外 Y 包含 一个 nested_exception (或者可能只是一个 exception_ptr)。那么...您如何在 catch 站点处理该问题?

如果你抓住了Y,它恰好有一些嵌入的X,你怎么得到它?请记住:exception_ptr 类型擦除的 ;除了传递它之外,你唯一能做的就是重新扔掉它。所以人们应该这样做:

catch(Y &e)
{
  if(e.has_nested())
  {
    try
    {
      e.rethrow_nested();
    }
    catch(X &e2)
    {
    }
  }
}

我没看到很多人这样做。特别是因为会有非常多的可能 X-es.

1:请不要使用std::uncaught_exception() == true来检测这种情况。非常有缺陷。

嵌套异常只是添加关于发生的事情的最有可能被忽略的信息,即:

异常 X 已被抛出,堆栈正在展开,即正在调用局部对象的析构函数,但该异常“正在运行”,而其中一个对象的析构函数又抛出异常 Y。

通常这意味着清理失败。

然后这不是可以通过向上报告并让更高级别的代码决定例如使用一些替代方法来实现其目标,因为保存进行清理所需的信息的对象已被销毁,连同它的信息,但没有进行清理。所以这很像断言失败。进程状态可能非常糟糕,打破了代码的假设。

throw 的析构函数原则上是有用的,例如正如 Andrei 曾经提出的关于在退出块作用域时指示交易失败的想法。也就是说,在正常的代码执行中,未被告知事务成功的本地对象可以从其析构函数中抛出。只有当它与 C++ 在堆栈展开期间的异常规则发生冲突时,这才会成为一个问题,它需要 检测 是否可以抛出异常,这似乎是不可能的。无论如何,析构函数仅用于其自动调用,而不用于其清理角色。因此可以得出结论,当前的 C++ 规则承担了析构函数的清理角色。

std::nested exception 有一种用途,而且只有一种用途(据我所知)。

话虽如此,这太棒了,我在我的所有程序中都使用了嵌套异常,因此花在寻找隐蔽错误上的时间几乎为零。

这是因为嵌套异常允许您轻松构建一个在错误发生时生成的带有完整注释的调用堆栈,没有任何 运行时间开销,在重新调用期间不需要大量日志记录运行(无论如何都会改变时间),并且不会因错误处理而污染程序逻辑。

例如:

#include <iostream>
#include <exception>
#include <stdexcept>
#include <sstream>
#include <string>

// this function will re-throw the current exception, nested inside a
// new one. If the std::current_exception is derived from logic_error, 
// this function will throw a logic_error. Otherwise it will throw a
// runtime_error
// The message of the exception will be composed of the arguments
// context and the variadic arguments args... which may be empty.
// The current exception will be nested inside the new one
// @pre context and args... must support ostream operator <<
template<class Context, class...Args>
void rethrow(Context&& context, Args&&... args)
{
    // build an error message
    std::ostringstream ss;
    ss << context;
    auto sep = " : ";
    using expand = int[];
    void (expand{ 0, ((ss << sep << args), sep = ", ", 0)... });
    // figure out what kind of exception is active
    try {
        std::rethrow_exception(std::current_exception());
    }
    catch(const std::invalid_argument& e) {
        std::throw_with_nested(std::invalid_argument(ss.str()));
    }
    catch(const std::logic_error& e) {
        std::throw_with_nested(std::logic_error(ss.str()));
    }
    // etc - default to a runtime_error 
    catch(...) {
        std::throw_with_nested(std::runtime_error(ss.str()));
    }
}

// unwrap nested exceptions, printing each nested exception to 
// std::cerr
void print_exception (const std::exception& e, std::size_t depth = 0) {
    std::cerr << "exception: " << std::string(depth, ' ') << e.what() << '\n';
    try {
        std::rethrow_if_nested(e);
    } catch (const std::exception& nested) {
        print_exception(nested, depth + 1);
    }
}

void really_inner(std::size_t s)
try      // function try block
{
    if (s > 6) {
        throw std::invalid_argument("too long");
    }
}
catch(...) {
    rethrow(__func__);    // rethrow the current exception nested inside a diagnostic
}

void inner(const std::string& s)
try
{
    really_inner(s.size());

}
catch(...) {
    rethrow(__func__, s); // rethrow the current exception nested inside a diagnostic
}

void outer(const std::string& s)
try
{
    auto cpy = s;
    cpy.append(s.begin(), s.end());
    inner(cpy);
}
catch(...)
{
    rethrow(__func__, s); // rethrow the current exception nested inside a diagnostic
}


int main()
{
    try {
        // program...
        outer("xyz");
        outer("abcd");
    }
    catch(std::exception& e)
    {
        // ... why did my program fail really?
        print_exception(e);
    }

    return 0;
}

预期输出:

exception: outer : abcd
exception:  inner : abcdabcd
exception:   really_inner
exception:    too long

@Xenial 扩展行的解释:

void (expand{ 0, ((ss << sep << args), sep = ", ", 0)... });

args 是一个参数包。它代表 0 个或多个参数(零很重要)。

我们要做的是让编译器为我们扩展参数包,同时围绕它编写有用的代码。

让我们从外到内:

void(...) - 表示评估某些东西并丢弃结果 - 但要评估它。

expand{ ... };

记住 expand 是 int[] 的类型定义,这意味着让我们评估一个整数数组。

0, (...)...;

表示第一个整数为零 - 请记住,在 C++ 中定义零长度数组是非法的。如果 args... 代表 0 个参数怎么办?这个 0 确保数组中至少有一个整数。

(ss << sep << args), sep = ", ", 0);

使用逗号运算符按顺序计算一系列表达式,取最后一个的结果。表达式为:

s << sep << args - 将分隔符后跟当前参数打印到流

sep = ", " - 然后让分隔符指向一个逗号 + space

0 - 结果值为 0。这是数组中的值。

(xxx params yyy)... - 表示对参数包中的每个参数执行一次 params

因此:

void (expand{ 0, ((ss << sep << args), sep = ", ", 0)... });

表示“对于 params 中的每个参数,在打印分隔符后将其打印到 ss。然后更新分隔符(以便我们为第一个分隔符设置不同的分隔符)。将所有这些作为初始化虚构数组的一部分进行然后我们会把它扔掉。

真正的问题是从析构函数中抛出是一个逻辑谬误。这就像定义 operator+() 来执行乘法。 析构函数不应用作 运行 任意代码的挂钩。它们的目的是确定性地释放资源。根据定义,那一定不能失败。其他任何东西都打破了编写通用代码所需的假设。

在使用析构函数的链接异常进行堆栈展开期间可能发生的问题是嵌套的异常链可能太长。例如,您有 std::vector1 000 000 个元素,每个元素都会在其析构函数中引发异常。假设 std::vector 的析构函数将其元素的析构函数中的所有异常收集到单个嵌套异常链中。那么由此产生的异常可能比原来的 std::vector 容器还要大。这可能会导致性能问题,甚至在堆栈展开期间抛出 std::bad_alloc(甚至无法嵌套,因为没有足够的内存来执行此操作)或在程序中其他不相关的地方抛出 std::bad_alloc