29

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

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

考虑以下层次结构:

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'实际上指向不同的地址,因为对象在内存中是如何布局的,而删除操作符“知道”如何找到和释放正确的内存位置。

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

笔记:

4

7 回答 7

14

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

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

    在标准 C++ 中,您可以使用以下命令执行此操作dynamic_cast

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

    C*仅从 a得到支持B*,例如:

    #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_sizelibc 源代码的 malloc/malloc.c 中给出了实现的确切细节:

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

内存块使用“边界标记”方法维护,如 Knuth 或 Standish 中所述。(有关此类技术的调查,请参阅 Paul Wilson 的论文 ftp://ftp.cs.utexas.edu/pub/garbage/allocsrv.ps。)空闲块的大小存储在每个块的前面和结尾。这使得将碎片化的块合并成更大的块非常快。大小字段还包含表示块是空闲还是正在使用的位。

分配的块如下所示:

    块-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ -+-+-+-+-+-+-+-+-+
            | 前一个块的大小,如果已分配 | |
            +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- +-+-+-+-+-+-+-+
            | 块的大小,以字节为单位 |M|P|
      内存-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ -+-+-+-+-+-+-+-+-+  
            | 用户数据从这里开始......  
            . .  
            . (malloc_usable_size() 字节)。  
            . |   
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ -+-+-+-+-+-+-+-+-+     
            | 块大小 |  
            +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- +-+-+-+-+-+-+-+  
于 2012-07-31T17:24:33.083 回答
10

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

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

于 2012-07-31T17:23:58.437 回答
7

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

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

于 2012-07-31T17:26:03.633 回答
1

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

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

void operator delete(void*) throw();

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

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

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

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

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

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

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

于 2012-07-31T18:54:25.423 回答
1

通常的实现(理论上,可能还有其他实现,我怀疑实际上是否存在)是每个基础对象都有一个 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;
于 2012-07-31T17:45:06.967 回答
0

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

于 2012-07-31T17:24:53.733 回答
0

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

于 2012-07-31T17:27:10.830 回答