为什么,真的,删除不完整的类型是未定义的行为?

时间:2010-03-25 16:13:49

标签: c++ memory-management destructor forward-declaration delete-operator

考虑这个经典的例子,用于解释 not 与前向声明的关系:

//in Handle.h file
class Body;

class Handle
{
   public:
      Handle();
      ~Handle() {delete impl_;}
   //....
   private:
      Body *impl_;
};

//---------------------------------------
//in Handle.cpp file

#include "Handle.h"

class Body 
{
  //Non-trivial destructor here
    public:
       ~Body () {//Do a lot of things...}
};

Handle::Handle () : impl_(new Body) {}

//---------------------------------------
//in Handle_user.cpp client code:

#include "Handle.h"

//... in some function... 
{
    Handle handleObj;

    //Do smtg with handleObj...

    //handleObj now reaches end-of-life, and BUM: Undefined behaviour
} 

我从标准中了解到这个案例正朝向UB,因为Body的析构函数是非常重要的。 我想要了解的是这个的根本原因。

我的意思是,问题似乎是由Handle的dtor内联的事实“触发”,因此编译器执行类似下面的“内联扩展”(这里几乎是伪代码)。

inline Handle::~Handle()
{
     impl_->~Body();
     operator delete (impl_);
}

在Handle实例被销毁的所有翻译单元(在这种情况下仅Handle_user.cpp)中,对吗? 我只是无法理解这一点:好的,在生成上面的内联扩展时,编译器没有Body类的完整定义,但为什么它不能简单地让链接器解析为impl_->~Body()的东西,所以有它调用Body的析构函数,它实际上是在它的实现文件中定义的吗?

换句话说:我理解在Handle破坏时,编译器甚至不知道Body是否存在(非平凡的)析构函数,但为什么它不能像往常那样执行,是留下一个“占位符”供链接器填写,如果该函数真的不可用,最终会有一个“未解析的外部”链接器?

我在这里错过了一些大事(在那种情况下,抱歉这个愚蠢的问题)? 如果情况并非如此,我只是想了解这背后的理由。

6 个答案:

答案 0 :(得分:26)

要结合多个答案并添加我自己的答案,而没有类定义,调用代码不知道:

  • 该类是否具有声明的析构函数,或者是否使用默认析构函数,如果是,则默认析构函数是否为微不足道,
  • 调用代码是否可以访问析构函数
  • 存在哪些基类并具有析构函数,
  • 析构函数是否为虚拟。虚函数调用实际上使用与非虚函数不同的调用约定。编译器不能只是“发出调用​​〜Body的代码”,并让链接器稍后计算出详细信息,
  • (这只是,感谢GMan),delete是否为班级重载。

由于某些或所有这些原因,您不能在不完整类型上调用任何成员函数(另外一个不适用于析构函数的成员函数 - 您不会知道参数或返回类型)。析构函数也不例外。所以当你说“为什么它不能像往常那样做?”时,我不确定你的意思。

正如您所知,解决方案是在TU中定义Handle的析构函数,其定义为Body,与您定义Handle的每个其他成员函数的位置相同它调用函数或使用Body的数据成员。然后在编译delete impl_;的位置,所有信息都可用于发出该调用的代码。

请注意,标准实际上是,5.3.5 / 5:

  

如果要删除的对象有   不完整的类型   删除和完整的类有一个   非平凡的析构函数或者   解除分配函数,行为是   未定义。

我认为这是为了你可以删除一个不完整的POD类型,就像你在{C}中使用它一样{g}。如果你尝试的话,g ++会给你一个非常严厉的警告。

答案 1 :(得分:6)

它不知道析构函数是否公开。

答案 2 :(得分:5)

调用虚方法或非虚方法是两回事。

如果调用非虚方法,编译器必须生成执行此操作的代码:

  • 将所有参数放在堆栈上
  • 调用该函数并告诉链接器它应该解析调用

由于我们讨论的是析构函数,因此没有任何参数可以放在堆栈上,因此看起来我们可以简单地执行调用并告诉链接器解析调用。不需要原型。

但是,调用虚拟方法完全不同:

  • 将所有参数放在堆栈上
  • 获取实例的vptr
  • 从vtable获取第n个条目
  • 调用此第n个入口指向的函数

这完全不同,因此编译器必须知道您是在调用虚拟还是非虚拟方法。

第二个重要的事情是编译器需要知道在vtable中找到虚方法的位置。为此,它还需要具有该类的完整定义。

答案 3 :(得分:3)

如果没有正确声明Body Handle.h中的代码,则无法知道析构函数是virtual还是可访问(即公开)。

答案 4 :(得分:2)

我只是猜测,但也许它与每类分配操作符的能力有关。

那是:

struct foo
{
    void* operator new(size_t);
    void operator delete(void*);
};

// in another header, like your example

struct foo;

struct bar
{
    bar();
    ~bar() { delete myFoo; }

    foo* myFoo;
};

// in translation unit

#include "bar.h"
#include "foo.h"

bar::bar() :
myFoo(new foo) // uses foo::operator new
{}

// but destructor uses global...!!

现在我们错配了分配运算符,并输入了未定义的行为。保证不会发生的唯一方法是说“使类型完整”。否则,无法确保。

答案 5 :(得分:1)

这实际上只是调用方法(析构函数,间接)的一种特殊情况。删除impl_有效只是调用析构函数然后适当的运算符delete(全局或类)。您不能在不完整类型上调用任何其他函数,那么为什么删除对析构函数的调用会得到特殊处理?

我不确定的部分是复杂性导致标准使其未定义而不是像在方法调用中那样禁止它。