【问题标题】:Why, really, deleting an incomplete type is undefined behaviour?为什么,真的,删除不完整的类型是未定义的行为?
【发布时间】:2011-01-31 19:32:37
【问题描述】:

考虑这个用于解释前向声明做什么的经典示例:

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

我从标准中了解到,由于 Body 的析构函数并不简单,因此该案例将走向 UB。 我试图理解的是真正的根本原因。

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

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

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

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

我是否在这里遗漏了一些重要的东西(在这种情况下,我很抱歉这个愚蠢的问题)? 如果不是这样,我只是想了解这背后的基本原理。

【问题讨论】:

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


    【解决方案1】:

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

    • 该类是否具有已声明的析构函数,或者是否要使用默认析构函数,如果是,则默认析构函数是否微不足道,
    • 调用代码是否可以访问析构函数,
    • 存在哪些基类并具有析构函数,
    • 析构函数是否是虚拟的。实际上,虚函数调用使用与非虚函数不同的调用约定。编译器不能只是“发出代码来调用~Body”,而让链接器稍后处理细节,
    • (刚刚插入,感谢 GMan)delete 是否为类重载。

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

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

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

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

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

    【讨论】:

    • 或者把Handle做成一个模板类,所以在使用点发出~Body的调用,而不是定义点Handle,所以此时可能Body已经完成了。
    • @Ben:我猜是这样,虽然我认为提问者根本不打算让 Body 的定义出现在调用者的 TU 中。
    • @Ben:是的,我在其他情况下使用了模板类,以确保使用不完整类型调用函数,这些类型实际上是在这些类型变得完整的时候发出的,但正如史蒂夫指出的那样,在在这种情况下,我对“解决方法”不感兴趣,而是对基本原理感兴趣
    • 为了完整起见,我的“为什么不能像往常一样”实际上有点不幸。我只是在隐含地考虑不需要知道被调用函数的原型,它确实可以为链接器留下一个占位符来填充,而不需要任何其他信息,但我显然忽略了它实际需要知道的所有其他可能的事情正如您在此答案中总结的那样,这会发生。谢谢
    • @Nils:我怀疑他们这样做是因为它适用于具有微不足道的析构函数的类,并且他们希望允许这样做。我同意如果他们不允许,他们会省去所有这些麻烦。任何前向声明类型的人都必须声明一个函数来删除它,而不是使用delete,并在 TU 中使用完整类型定义该函数。无论如何,标准从不要求某些东西不能编译,只是编译器必须至少警告格式错误的程序。至少哪个 g++ 已经这样做了,所以只需使用 -Werror ;-)
    【解决方案2】:

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

    【讨论】:

      【解决方案3】:

      调用虚拟方法或非虚拟方法是完全不同的两件事。

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

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

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

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

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

      这是完全不同的,所以编译器真的必须知道你是在调用虚拟方法还是非虚拟方法。

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

      【讨论】:

        【解决方案4】:

        如果没有正确声明BodyHandle.h 中的代码不知道析构函数是virtual 还是可访问(即公共)。

        【讨论】:

          【解决方案5】:

          我只是猜测,但可能与每个类分配运算符的能力有关。

          即:

          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...!!
          

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

          【讨论】:

            【解决方案6】:

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

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

            【讨论】:

            • 为什么它是未定义的?仅当析构函数不平凡时才未定义。如果不破坏对已明确定义的前向声明的 POD 类型的删除,该语言就无法禁止它。
            猜你喜欢
            • 2014-12-23
            • 2017-01-23
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 2015-12-17
            • 2023-03-31
            • 1970-01-01
            • 1970-01-01
            相关资源
            最近更新 更多