数据成员析构函数的调用时机

9

我有一个类,它持有一个析构函数可能会抛出异常的对象(实际上是一个tbb::task_group,但为了简单起见,我在这里将其命名为MyObject)。

代码如下:

#include <stdexcept>

class MyObject {
public:
    MyObject() {}
    ~MyObject() noexcept(false) {}
};

class A {
public:
    A() {}
    virtual ~A() {}
};

class B : public A {
public:
    B() : A() {}
    ~B() {}

private:
    MyObject _object;
};

编译器会给出以下错误提示:

覆盖函数的异常规范比基类版本更宽松

我不喜欢在整个代码中散布着 noexcept(false) ,所以我考虑使用 MyObject 的原始指针,并在析构函数之外删除它们(例如,在 Close 函数中)。

如何处理这种情况最好呢?


我发现在类B中使用std::unique_ptr<MyObject>可以消除错误。不过,根据这个问题的答案(https://dev59.com/H1oU5IYBdhLWcg3wIkeI),我不确定这是否是一个好主意。 - Uraza
潜在抛出异常的析构函数通常不是一个好主意:C++ FAQ: 如何处理失败的析构函数?Andrzej's C++ blog: 抛出异常的析构函数。您仍然可以选择将所有内容放入 try catch 块中,或者至少将析构函数设置为 noexcept(如果调用异常,则可能会调用 std::terminate())。关于成员和基类的析构函数...它们也应该是 noexcept 的。;-) - Scheff's Cat
1
抛出析构函数实际上来自外部库(特别是tbb::task_group,正如我在开头提到的)。 - Uraza
1
@Uraza unique_ptr 的析构函数要求删除器的应用不会抛出异常:https://en.cppreference.com/w/cpp/memory/unique_ptr/~unique_ptr。您需要使用一个自定义的删除器来忽略异常。或者,使用一个原始指针。或者,只使用手动生命周期管理的 _aligned storage_(如果您想避免动态分配的过度死亡,这在这里可能不是必要的)。 - Daniel Langr
你不应该在析构函数之外做任何事情。只需声明A的析构函数为noexcept(false)。B的析构函数也应该是noexcept(false) - Bernd
显示剩余3条评论
3个回答

5
析构函数默认情况下是noexcept(true),除非明确指定其他情况,或者除非基类或成员的析构函数可能会抛出异常。后一种情况适用于您的情况。之后,这只是函数签名之间的简单不匹配。
因此,virtual ~A() {}实际上是virtual ~A() noexcept {},它与virtual ~B() noexcept(false) {}不匹配。
你有两个解决方案:
  1. 显式地将~B标记为noexcept(true),但如果~MyObject引发异常,则在~B的边界处终止程序。
  2. 同时将~A标记为noexcept(false)
从析构函数中抛出异常真的是一个很糟糕的主意。抛出信号表明对象无法被销毁,这是否是您代码中正在发生的情况呢?仅在正确的响应是立即终止程序时才抛出异常,因为如果析构函数作为堆栈取消设置的一部分被调用,那么就会发生这种情况。在这种情况下,不会调用其他任何析构函数,这可能比“不死”对象更有害。
如果您真的想要安全,并且不关心抛出的异常,可以使用具有吸收析构函数的unique_ptr来包装成员。
class B : public A {
public:
    B() : A(), _object{new MyObject,deleter} {}
     ~B()  noexcept(true) {}

private:
    constexpr static auto deleter = [](MyObject* obj){ try { delete obj;}catch(...){};};
    std::unique_ptr<MyObject,decltype(deleter)> _object;
};

3
值得一提的是,我一开始持怀疑态度,必须亲自检查。确实,当在调用 wait 前未调用 tbb::task_group 时,会抛出异常。更多详细信息请参阅 https://spec.oneapi.io/versions/0.5.0/oneTBB/task_scheduler/task_groups/task_group_cls.html。 - 463035818_is_not_a_number
2
@Uraza 或许将 task_group 包装在一些带有 noexcept 析构函数的自定义类中是一个选项。从析构函数中抛出异常真的很危险。 - 463035818_is_not_a_number
1
@Uraza 我不敢妄加猜测Intel为什么选择了这种策略,但是在析构函数中抛出异常确实非常糟糕,应该尽量避免。这就是为什么std::thread::~thread调用std::terminate而不是抛出异常的原因。 - bolov
1
需要注意的是,在销毁任务组之前必须调用wait方法,否则析构函数会抛出异常。 - 463035818_is_not_a_number
1
@Quimby 是的,这是TBB的一个奇怪设计。 :) - Uraza
显示剩余4条评论

1

根据@463035818_is_not_a_number的建议,可以将抛出异常的类包装成一个不会抛出异常的自定义类。

对于我的情况,对于tbb::task_group,可以像这样:

class TaskGroup {
public:
    TaskGroup() {
        _task = new tbb::task_group();
    }

    // This destructor will not throw.
    // Not very pleased with "catch (...)" but not much choice due to TBB's implementation.
    ~TaskGroup() {
        try {
            delete _task;
        } catch (...) {}
    }

    // Wrap any other needed method here.
    
private:
    tbb::task_group* _task;
};

你不需要使用 new。将 tbb::task_group _task; 作为成员变量应该就可以了。目前你需要注意 3/5 规则。 - 463035818_is_not_a_number
@463035818不是一个数字 如果我使用普通对象而不是new,那么它如何保护我的析构函数不抛出异常? - Uraza
哦,对了,我没有好好考虑那个。只要注意遵守3/5规则就可以了。 - 463035818_is_not_a_number
是的,确实。感谢您的反馈! - Uraza
@Uraza:你需要使用函数尝试块,然后子对象析构函数中的异常也会被捕获。请参见https://dev59.com/LW035IYBdhLWcg3wH8Ql#28685972。 - Ben Voigt
有趣的是,我不知道那个语法。感谢@BenVoigt提供的链接。 - Uraza

0

一般来说,如果我需要处理这种情况,我会强制 B 的析构函数为 noexcept(true)。例如:

class MyObject
{
  public:
      MyObject() {}
      ~MyObject() noexcept(false) {};
};

class A
{
  public:
      A() {}
      virtual ~A() {}     // implicitly noexcept(true)
};

class B : public A
{
  public:
      B() : A(), _object() {}      
     ~B() noexcept(true) {};

  private:
     MyObject _object;
};

这将允许代码编译,但缺点是每当_object的销毁(在MyObject的析构函数中)抛出异常时,将调用std::terminate()

然而,如果有一些操作可以防止_object的销毁抛出异常,则可以处理该情况。这些操作可以在B的析构函数中执行,利用B的析构函数在任何B的基类或成员的析构函数之前被调用的事实。换句话说,在上面的代码中修改B的析构函数:

     // if within definition of B
     ~B() noexcept(true)
     {
          // take actions to prevent the destructor of _object throwing
     };

如果没有任何操作可以防止_object的破坏引发异常,那么每当_object的破坏引发异常时,您就必须接受程序终止。

我上面的声明是由标准中的两条规则导致的:对象的构造构造基类和成员,然后调用最派生类的构造函数。对象的销毁顺序(析构函数调用序列)与构造顺序相反。

个人而言,如果我有一个第三方库提供了以无法防止的方式抛出异常的析构函数,我会认真考虑找到不同的库,或者编写自己的等效库,该库不具有抛出析构函数。


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