Reputation: 151
I'm not sure if I understood "sized deallocation" correctly in C++. In C++14 the following signature was added to the global scope:
void operator delete(void* ptr, std::size_t size) noexcept
I'm using GCC 7.1.0 to compile the following source:
#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;
}
And I get the following output:
-> 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 gives me the following output:
-> 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)
And Clang 5.0 does not even call the global sized deallocation operator delete
(just the the operator delete
with one parameter). As T.C. mentioned in the comment section Clang needs the additional parameter -fsized-deallocation
to use sized allocation and the result will be the same as for 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)
For me VS2017 seems to have the correct behaviour because my understanding of the class specific operator is to use the size of the derived class even if delete was called on a base class pointer.
I would expect a symmetrical behaviour by calling the global operator delete
.
I've looked through the ISO C++11/14 standard but I don't think I've found any specific on how the global and class local operators should behave (that might be just me having problems to interpret the wording of the standard and because I'm not a native speaker).
Can someone elaborate on this topic?
What should be the correct behaviour?
Upvotes: 15
Views: 4189
Reputation: 402
I believe the double-colon operator prefixing your delete
operator is circumventing the “correct” operator delete()
. I have exercised the code on GCC, Clang, and Intel’s compiler, and they all agree that the delete
operator should be sent a 16 byte size. This is because they seem to be interpreting the C++ specification as saying that you have explicitly asked for the globally-scoped deletion function, ignoring any dynamic dispatch. More on this later.
First, let’s tweak your original code a bit to eliminate some variables:
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;
}
Actually, none of the overrides are required to exhibit this behavior. We can look at the emitted assembly to see what’s happening. By default, GCC seems to use the sized delete
operator, so the above is interesting (I compiled with GCC 11, -O0
). As you noticed, the compiler passes sizeof(*b01)
to the deletion function:
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)
... essentially, look up the virtual destructor, invoke it, then call the delete function with the size of *b01
(note: in the standard library case, this is probably fine, because the heap knows how large the allocation actually is, and will reap it fully).
To confirm that we’re looking for the size in the current scope, statically, I added example 2, which emits sizeof(*d01)
into the second parameter:
call rdx
mov esi, 24 // Passed as the size to delete().
mov rdi, rbx
call operator delete(void*, unsigned long)
Where is really gets interesting is in the “normal” case, example 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
Here, it looks in the vtable for b02
, and finds the “deleting destructor.” This is a function that wraps what we typically think of as the destructor (since it’s on the vtable, we’re going to find it’s) for D
, and calls the delete
operator after this function executes. E.g:
// (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)
So we did a virtual lookup for the destructor, ran the correct one, then the delete
operator gets a 24 byte size for it’s second parameter.
If we take a look at the C++ (C++14, in this case) specification, §12.5.4 (Free store), it notes:
Class-specific deallocation function lookup is a part of general deallocation function lookup (5.3.5) and occurs as follows. If the delete-expression is used to deallocate a class object whose static type has a virtual destructor, the deallocation function is the one selected at the point of definition of the dynamic type’s virtual destructor (12.4). Otherwise, if the delete-expression is used to deallocate an object of class
T
or array thereof, the static and dynamic types of the object shall be identical and the deallocation function’s name is looked up in the scope ofT
. If this lookup fails to find the name, general deallocation function lookup (5.3.5) continues...
In other words (my interpretation is), when you defined a virtual destructor for B
, you defined an implicit operator delete
, but by invoking ::delete
, you essentially ask the compiler to ignore the dynamic type, and refer only to the static type in the current scope, which has a size of 16 bytes. You have selected a delete function, so there’s no need for the compiler to dynamically look one up.
Again, in §5.3.5.9 (Delete):
When the keyword
delete
in a delete-expression is preceded by the unary::
operator, the deallocation function’s name is looked up in global scope. Otherwise, the lookup considers class-specific deallocation functions (12.5). If no class-specific deallocation function is found, the deallocation function’s name is looked up in global scope.
Another way of saying, “you asked for the global function, so I skipped the part where I look up the class-specific function.”
One could argue that the MSVC behavior is also valid, as through all this, there’s nothing that explicitly states that the size passed to the delete function is inexorably tied to the function itself. Certainly, also, the MSVC behavior insulates the coder from having to navigate another mine in the Undefined Behavior minefield, as the compiler managed to grab the actual correct size from somewhere. Looking at the emitted code from GCC, however, it would be “difficult” to gather the correct size, while explicitly calling the globally scoped delete function.
Upvotes: 3