C++中的大小释放:全局运算符delete的正确行为是什么(void*ptr,std::size_t size)



我不确定我是否正确理解C++中的"大小释放"。在C++14中,以下签名被添加到全局范围中:

void operator delete(void* ptr, std::size_t size) noexcept

我使用GCC 7.1.0编译以下源代码:

#include <cstdio>   // printf()
#include <cstdlib>  // exit(),malloc(),free()
#include <new>      // new(),delete()
void* operator new(std::size_t size)
{
std::printf("-> operator ::new(std::size_t %zu)n", size);
return malloc(size);
}
void operator delete(void* ptr) noexcept
{
std::printf("-> operator ::delete(void* %p)n", ptr);
free(ptr);
}
void operator delete(void* ptr, std::size_t size) noexcept
{
std::printf("-> operator ::delete(void* %p, size_t %zu)n", ptr, size);
free(ptr);
}

struct B
{
double d1;
void* operator new(std::size_t size)
{
std::printf("-> operator B::new(std::size_t %zu)n", size);
return malloc(size);
};
void operator delete(void* ptr, std::size_t size)
{
std::printf("-> operator B::delete(void* %p, size_t %zu)n", ptr, size);
free(ptr);
};
virtual ~B()
{
std::printf("-> B::~B()");
}
};

struct D : public B
{
double d2;
virtual ~D()
{
std::printf("-> D::~D()");
}
};
int main()
{
B *b21 = new B();
delete b21;
B *b22 = new D();
delete b22;
D *d21 = new D();
delete d21;
std::printf("*****************************n");
B *b11 = ::new B();
::delete b11;
B *b12 = ::new D();
::delete b12;
D *d11 = ::new D();
::delete d11;
return 0;
}

我得到以下输出:

-> operator B::new(std::size_t 16)
-> B::~B()-> operator B::delete(void* 0x16e3010, size_t 16)
-> operator B::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator B::delete(void* 0x16e3010, size_t 24)
-> operator B::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator B::delete(void* 0x16e3010, size_t 24)
*****************************
-> operator ::new(std::size_t 16)
-> B::~B()-> operator ::delete(void* 0x16e3010, size_t 16)
-> operator ::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator ::delete(void* 0x16e3010, size_t 16)
-> operator ::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator ::delete(void* 0x16e3010, size_t 24)

MS Visual Studio 2017为我提供了以下输出:

-> operator B::new(std::size_t 16)
-> B::~B()-> operator B::delete(void* 0081CDE0, size_t 16)
-> operator B::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator B::delete(void* 00808868, size_t 24)
-> operator B::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator B::delete(void* 00808868, size_t 24)
*****************************
-> operator ::new(std::size_t 16)
-> B::~B()-> operator ::delete(void* 0081CDE0, size_t 16)
-> operator ::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator ::delete(void* 00808868, size_t 24)
-> operator ::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator ::delete(void* 00808868, size_t 24)

Clang 5.0甚至不调用全局大小的释放operator delete(只调用带有一个参数的operator delete)。正如评论部分提到的T.C.,Clang需要额外的参数-fsized-deallocation来使用大小分配,结果将与GCC相同:

-> operator B::new(std::size_t 16)
-> B::~B()-> operator B::delete(void* 0x219b6c0, size_t 16)
-> operator B::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator B::delete(void* 0x219b6c0, size_t 24)
-> operator B::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator B::delete(void* 0x219b6c0, size_t 24)
*****************************
-> operator ::new(std::size_t 16)
-> B::~B()-> operator ::delete(void* 0x219b6c0, size_t 16)
-> operator ::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator ::delete(void* 0x219b6c0, size_t 16)
-> operator ::new(std::size_t 24)
-> D::~D()-> B::~B()-> operator ::delete(void* 0x219b6c0, size_t 24)

对我来说,VS2017似乎有正确的行为,因为我对类特定运算符的理解是使用派生类的大小,即使在基类指针上调用了delete。我希望通过调用全局operator delete来实现对称行为。

我查阅了ISO C++11/14标准,但我认为我没有发现任何关于全球和类本地运营商应该如何表现的具体信息(这可能只是我在解释标准的措辞时遇到了问题,因为我不是母语人士)。

有人能详细谈谈这个话题吗?

正确的行为应该是什么?

我认为前缀为delete运算符的双冒号运算符绕过了"正确"的operator delete()。我已经在GCC、Clang和Intel的编译器上练习了代码,他们都同意delete运算符应该发送16字节的大小。这是因为他们似乎将C++规范解释为明确要求全局范围的删除函数,而忽略了任何动态调度。稍后将对此进行详细介绍。

发生了什么首先,让我们稍微调整一下您的原始代码,以消除一些变量:

struct B
{
double d1;
virtual ~B() = default;
};
struct D : public B
{
double d2;
};
int main()
{
B *b01 = new D();
::delete b01; // 1: The "problem" case.
D *d01 = new D();
::delete d01; // 2: The "problem" case (sanity check).
B *b02 = ::new D();
delete b02;   // 3: Typical deletion.
return 0;
}

实际上,不需要任何重写来表现这种行为。我们可以查看发射的程序集来了解发生了什么。默认情况下,GCC似乎使用大小为delete的运算符,因此上面的内容很有趣(我使用GCC 11、-O0编译)。正如您所注意到的,编译器将sizeof(*b01)传递给删除函数:

mov     rdx, QWORD PTR [rax]
sub     rdx, 16
mov     rdx, QWORD PTR [rdx]
lea     rbx, [rax+rdx]
mov     rdx, QWORD PTR [rax]
mov     rdx, QWORD PTR [rdx]
mov     rdi, rax
call    rdx
mov     esi, 16  // Passed as the size to delete().
mov     rdi, rbx
call    operator delete(void*, unsigned long)

从本质上讲,查找虚拟析构函数,调用它,然后调用大小为*b01的delete函数(注意:在标准库的情况下,这可能很好,因为堆知道实际分配的大小,并且会完全获得它)。

为了确认我们正在静态地查找当前范围中的大小,我添加了示例2,它将sizeof(*d01)发送到第二个参数:

call    rdx
mov     esi, 24  // Passed as the size to delete().
mov     rdi, rbx
call    operator delete(void*, unsigned long)

真正有趣的地方是在"正常"情况下,示例3:

mov     rdx, QWORD PTR [rax]
add     rdx, 8   // Offset 8 in the vtable for b02.
mov     rdx, QWORD PTR [rdx]
mov     rdi, rax
call    rdx

在这里,它在vtable中查找b02,并找到"删除析构函数"。这是一个函数,它包装了我们通常认为的D的析构函数(因为它在vttable上,我们将找到它),并在该函数执行后调用delete运算符。例如:

// (Prolog omitted.)
call    D::~D() // [complete object destructor]
mov     rax, QWORD PTR [rbp-8]
mov     esi, 24
mov     rdi, rax
call    operator delete(void*, unsigned long)

因此,我们对析构函数进行了虚拟查找,运行了正确的析构函数,然后delete运算符为它的第二个参数获取了24字节的大小。

c++规范的对正

如果我们看一下C++(在这种情况下是C++14)规范§12.5.4(免费存储),它指出:

类特定的释放函数查找是通用释放函数查找(5.3.5)的一部分,其发生方式如下。如果删除表达式用于解除分配静态类型具有虚拟析构函数的类对象,则解除分配函数是在定义动态类型的虚拟析构符时选择的函数(12.4)。否则,如果delete表达式用于解除分配类T或其数组的对象,对象的静态和动态类型应相同,并且在CCD_ 18的范围中查找解除分配函数的名称。如果此查找未能找到名称,则常规解除分配函数查找(5.3.5)将继续。。。

换句话说(我的解释是),当您为B定义虚拟析构函数时,您定义了一个隐式operator delete,但通过调用::delete,您实际上要求编译器忽略动态类型,而只引用当前作用域中的静态型,该类型的大小为16字节您已经选择了一个删除函数,因此编译器不需要动态查找

同样,在§5.3.5.9(删除)中:

删除表达式中的关键字delete前面有一元::运算符时,将在全局范围中查找解除分配函数的名称。否则,查找会考虑类特定的解除分配函数(12.5)。如果找不到类特定的取消分配函数,则会在全局范围中查找取消分配函数的名称。

另一种说法是,"你要求全局函数,所以我跳过了查找类特定函数的部分。">

有人可能会争辩说,MSVC行为也是有效的,因为通过所有这些,没有任何明确声明传递给delete函数的大小与函数本身密不可分当然,同样,MSVC行为使程序员不必在未定义行为雷区中导航另一个地雷,因为编译器设法从某个地方获取了实际的正确大小。然而,查看GCC发出的代码,在显式调用全局范围的delete函数时,很难收集到正确的大小。

最新更新