【问题标题】:How does the C++ delete operator find the memory location of a polymorphic object?C++ 删除操作符如何找到多态对象的内存位置?
【发布时间】:2021-09-27 16:27:30
【问题描述】:

我想知道当给定一个与对象的真实内存位置不同的基类指针时,删除运算符如何计算出需要释放的内存位置。

我想在我自己的自定义分配器/释放器中复制此行为。

考虑以下层次结构:

struct A
{
    unsigned a;
    virtual ~A() { }
};

struct B
{
    unsigned b;
    virtual ~B() { }
};

struct C : public A, public B
{
    unsigned c;
};

我想分配一个 C 类型的对象并通过 B 类型的指针删除它。据我所知,这是对 operator delete 的有效使用,它在 Linux/GCC 下工作:

C* c = new C;
B* b = c;

delete b;

有趣的是,指针'b'和'c'实际上指向不同的地址,因为对象在内存中的布局方式不同,而删除操作符“知道”如何找到并释放正确的内存位置。

我知道,一般来说,在给定基类指针Find out the size of a polymorphic object 的情况下,不可能找到多态对象的大小。我怀疑通常也无法找到对象的真实内存位置。

注意事项:

【问题讨论】:

  • 简短回答:它是定义的实现,除了标准说符合标准的编译器必须支持这一点。然而,我的猜测是 G++ 使用 vtable 跳转到内部销毁逻辑的某些部分,这将正常工作。
  • 不过,看看一些实现会很有趣。 (在某种程度上,C 中的malloc/free 是相似的,我相信方法是在从malloc 返回的指针“之前”保留一些空间以进行内务处理。)
  • 虽然,我没有得到b != c .. 有不同的变量,但都“指向”同一个对象(内存),不是吗? (我不使用 C++,所以我失去了它的魔力)。
  • @jrok 哦,是的。在多重继承的情况下,父指针和基指针可能不会指向同一个地方。
  • 我建议尝试 g++ 中的 -fdump-class-hierarchy 选项,它提供了对 vtable 结构的大量洞察,在这种情况下使用它。您需要通过 c++filt 运行输出。

标签: c++ memory-management g++


【解决方案1】:

销毁基类指针需要您实现了虚拟析构函数。如果你不这样做,所有的赌注都会被取消。

调用的第一个析构函数将是由虚拟机制 (vtable) 确定的最派生对象的析构函数。这个析构函数知道对象的大小!它可以将这些信息隐藏在某个地方,或者将其传递到析构函数链中。

【讨论】:

    【解决方案2】:

    这显然是特定于实现的。在实践中,实现事物的明智方法相对较少。从概念上讲,这里有一些问题:

    1. 您需要能够获得指向最派生对象的指针,即(从概念上)包含所有其他类型的对象。

      在标准 C++ 中,您可以使用 dynamic_cast

      void *derrived = dynamic_cast<void*>(some_ptr);
      

      B* 返回 C*,例如:

      #include <iostream>
      
      struct A
      {
          unsigned a;
          virtual ~A() { }
      };
      
      struct B
      {
          unsigned b;
          virtual ~B() { }
      };
      
      struct C : public A, public B
      {
          unsigned c;
      };
      
      int main() {
        C* c = new C;
        std::cout << static_cast<void*>(c) << "\n";
        B* b = c;
        std::cout << static_cast<void*>(b) << "\n";
        std::cout << dynamic_cast<void*>(b) << "\n";
      
        delete b;
      }
      

      在我的系统上提供以下内容

      0x912c008 0x912c010 0x912c008
    2. 一旦完成,它就会成为标准的内存分配跟踪问题。通常这是通过以下两种方式之一完成的,要么a)在分配的内存之前记录分配的大小,然后找到大小只是一个指针减法,要么b)在某种数据结构中记录分配和释放内存。更多详情见this question,有很好的参考。

      使用 glibc,您可以相当合理地查询给定分配的大小:

      #include <iostream>
      #include <stdlib.h>
      #include <malloc.h>
      
      int main() {
        char *test = (char*)malloc(50);
        std::cout << malloc_usable_size(test) << "\n";
      }
      

      该信息可用于类似地释放/删除,并用于确定如何处理返回的内存块。

    malloc_useable_size 的具体实现细节在 libc 源代码的 malloc/malloc.c 中给出:

    (以下包括 Colin Plumb 的轻微编辑解释。)

    使用“边界标记”方法维护内存块 在例如 Knuth 或 Standish 中描述。 (见 Paul Wilson 的论文 ftp://ftp.cs.utexas.edu/pub/garbage/allocsrv.ps 进行此类调查 技术。)空闲块的大小都存储在前面 每个块和最后。这使得整合零散的块 非常快地分成更大的块。大小字段也包含位 表示块是空闲还是正在使用。

    分配的块如下所示:

    块-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ -+-+-+-+-+-+-+-+-+ |前一个块的大小,如果已分配 | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- +-+-+-+-+-+-+-+ |块的大小,以字节为单位 |M|P| 内存-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ -+-+-+-+-+-+-+-+-+ |用户数据从这里开始...... . . . (malloc_usable_size() 字节)。 . | nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ -+-+-+-+-+-+-+-+-+ |块大小 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- +-+-+-+-+-+-+-+

    【讨论】:

    • 如果您有兴趣,我可以详细说明dynamic_cast&lt;void*&gt; 是如何实现的,从概念上讲,这就是完成这项工作所需的全部内容。
    • dynamic_cast 技巧非常巧妙!它似乎甚至可以使用 -fno-rtti,但我想知道它的便携性如何?
    • 我很欣赏关于内存分配跟踪的解释,尽管这并不是我的问题的一部分。
    • @antonm - -fno-rtti 是非标准 C++,所以我不认为当你明确禁用标准功能时调用标准 C++ 不可移植是公平的。在我的测试中,我展示的示例仍然有效。手册页说 “dynamic_cast 运算符仍可用于不需要运行时类型信息的强制转换,即强制转换为“void *”或明确的基类。” 我包括了分配位,因为它似乎是需要发生的概念性事情中的一个重要细节。
    【解决方案3】:

    指向多态对象的指针通常实现为指向对象和虚拟表的指针,其中包含有关对象底层类的信息。 delete 会知道这些实现细节并找到合适的析构函数

    【讨论】:

      【解决方案4】:

      它的实现已定义,但一种常见的实现技术是operator delete实际上是由析构函数调用的(而不是其中包含delete的代码),并且析构函数有一个隐藏参数控制@987654323是否@ 被调用。

      使用这个实现,大多数对析构函数的调用(所有显式的 dtor 调用,对自动和静态变量的调用,以及从派生的析构函数对基析构函数的调用)都会将额外隐藏的 arg 设置为 false(因此 operator delete 不会称为)。但是,当存在删除表达式时,它会调用隐藏参数为 true 的对象的顶级析构函数。在您的示例中,这将是 C::~C(),因此它将知道为整个对象回收内存

      【讨论】:

      • 所以大概C::~C(true) 会调用B::~B(false),然后是A::~A(false),然后是delete(this, sizeof(C))
      • 我几乎认为该解决方案是特定于实现的,并且您对其工作原理的描述是有道理的。是否有任何文档详细描述了此实现?
      • 您必须查看实施文档以了解实施细节。我知道以这种方式进行某些工作的唯一实现是原始的 cfront,来自某处阅读的一些文章。然而,许多 cfront 的实现技术最终都出现在了其他编译​​器中。
      【解决方案5】:

      它可以像 malloc 一样做到这一点。一些 malloc 会记录对象本身之前的大小。大多数现代 malloc 都复杂得多。请参阅tcmalloc,这是一种快速分配器,可将相同大小的对象在页面上保持在一起,因此它只需要在页面粒度上保留大小信息。

      【讨论】:

        【解决方案6】:

        通常的实现(理论上,可能还有其他实现,我怀疑实际上是否存在)是每个基础对象都有一个 vtable(如果没有,基础对象不是多态的,不能用于删除)。该 vtable 不仅包含指向虚函数的指针,还包含整个 RTTI 所需的内容,包括从当前对象到最派生对象的偏移量。

        为了解释(任何真正的实现可能存在差异,我可能犯了一些错误),这里是真正使用的:

        struct A_VTable_Desc {
           int offset;
           void* (destructor)();
        } AVTable = { 0, A::~A };
        
        struct A_impl {
           unsigned a;
           A_VTable_Desc* vptr;
        };
        
        struct B_VTable_Desc {
           int offset;
           void* (destructor)();
        } BVtable = { 0, &B::~B };
        
        struct B_impl {
           unsigned b;
           B_VTable_Desc* __vptr;
        };
        
        A_VTable_Desc CAVtable = { 0, &C::~C_as_A };
        B_VTable_Desc CBVtable = { -8, &C::~C_as_B };
        
        struct C {
           A_impl __aimpl;
           B_impl __bimpl;
           unsigned c;
        };
        

        C 的构造函数隐含地做了类似的事情

        this->__aimpl->__vptr = &CAVtable;
        this->__bimpl->__vptr = &CBVtable;
        

        【讨论】:

          【解决方案7】:

          在编译delete 操作符时,编译器需要确定一个'deallocation'函数在析构函数执行后调用。请注意,析构函数与释放调用没有任何直接关系,但它确实会影响编译器如何查找释放函数。

          在通常情况下,对象没有特定类型的释放函数,在这种情况下使用全局释放函数并且总是隐式声明(C++03 3.7.3/2):

          void operator delete(void*) throw();
          

          请注意,此函数甚至不接受大小参数。它需要根据指针的值来确定分配大小。这可以通过在地址之前存储分配的大小来完成(有没有其他方式的实现?)。

          但是,在决定使用该释放函数之前,编译器会执行查找以查看是否应使用特定类型的释放函数。该函数可以有单个参数(void*)或两个参数(void*size_t)。

          查找释放函数时,如果用作delete的操作数的指针的静态类型有虚析构函数,则(C++03 12.5/4):

          释放函数是在动态定义中通过查找找到的函数 类型的虚拟析构函数

          实际上,任何operator delete() 释放函数对于具有虚析构函数的类型都是虚函数,即使实际函数必须是static(标准在 12.5/7 中对此进行了说明)。在这种情况下,编译器可以根据需要传递对象的大小,因为它可以访问对象的动态类型(对对象指针的任何必要调整都可以通过相同的方式找到)。

          如果delete 的操作数的静态类型是静态的,则operator delete() 释放函数的查找遵循通常的规则。同样,如果编译器选择了一个需要大小参数的释放函数,它可以这样做,因为它在编译时知道对象的静态类型。

          最后一种情况会导致未定义的行为:如果指针的静态类型没有虚拟析构函数但指向派生类型对象,那么编译器可能会查找错误的释放函数并传递错误的大小。但由于这是未定义行为的结果,所以没关系。

          【讨论】:

            猜你喜欢
            • 1970-01-01
            • 2016-04-15
            • 2014-02-15
            • 2011-08-20
            • 1970-01-01
            • 1970-01-01
            • 2017-05-13
            • 1970-01-01
            相关资源
            最近更新 更多