C++删除运算符如何查找多态对象的内存位置

How does the C++ delete operator find the memory location of a polymorphic object?

本文关键字:多态 对象 位置 内存 查找 运算符 删除 何查找 C++      更新时间:2023-10-16

我想知道当给delete操作符一个基类指针时,它是如何计算出需要释放的内存位置的,该指针与对象的真实内存位置不同。

我想在我自己的自定义分配器/解除分配器中复制这种行为。

考虑以下层次结构:

struct A
{
    unsigned a;
    virtual ~A() { }
};
struct B
{
    unsigned b;
    virtual ~B() { }
};
struct C : public A, public B
{
    unsigned c;
};

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

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

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

我知道,一般来说,在给定基类指针的情况下,不可能找到多态对象的大小:找出多态对象的尺寸。我怀疑,通常也不可能找到对象的真实内存位置。

注:

  • 我的问题与新建[]和删除[]的工作方式无关。我对单对象分配的情况感兴趣。如何删除[]";知道";操作数数组的大小
  • 我也不关心析构函数是如何调用的。我对内存本身的释放感兴趣。如何';删除';删除基类的指针时有效
  • 我使用-fno-rtti和-fno异常进行了测试,因此G++不应该访问运行时类型信息

这显然是特定于实现的。在实践中,实现事物的合理方法数量相对较少。从概念上讲,这里有几个问题:

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

    在我的系统上给出了以下内容

    0x912c0080x912c0100x912c008
  2. 一旦完成,它就变成了一个标准的内存分配跟踪问题。通常,这可以通过以下两种方式之一来完成:a)在分配的内存之前记录分配的大小,然后通过指针相减来查找大小;b)在某种数据结构中记录分配和空闲内存。有关更多详细信息,请参阅这个问题,它有很好的参考。

    使用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调查技术。)空闲块的大小存储在每个块和末尾。这使得整合零散的区块非常快地变成大块。大小字段也包含位表示块是空闲的还是在使用中。

分配的区块如下所示:

chunk->+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+|上一个区块的大小(如果已分配)||+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+|区块大小,以字节为单位|M|P|mem->+-+-+-+-+/-+-+-+-+++++++++-+-+-++|用户数据从这里开始。。。(malloc_usable_size()字节)。.|nextchunk->+-+-+-+-+++++++++-+-+-++-+-+-+/-+-+-+-++|区块大小|+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
销毁基类指针需要实现一个虚拟析构函数。如果你不这么做,所有的赌注都会落空。

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

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

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

通常的实现(理论上可能还有其他实现,我怀疑在实践中是否有)是每个基对象都有一个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;

编译delete运算符时,编译器需要确定在执行析构函数后要调用的"deallocation"函数。请注意,析构函数与解除分配调用没有任何直接关系,但它确实会影响编译器如何查找解除分配函数。

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

void operator delete(void*) throw();

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

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

在查找解除分配函数时,如果用作delete操作数的指针的静态类型具有虚拟析构函数,则(C++03 12.5/4):

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

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

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

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

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

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