分享

C : Deleting destructors and virtual operator del...

 astrotycoon 2020-08-24

This post starts with a fairly obscure topic - how an overloaded operatordelete behaves in light of polymorphism; amazingly, it then gets even moreobscure - shedding light on the trickery the compiler employs to make thiswork, by generating more than one destructor for certain classes. If you're intosuch things, read on. If not, sorry about that; I heard that three newJavascript libraries were released this week for MVC JSON-based dynamic CSSlayout. Everyone's switching! Hurry up to keep up with the cool guys and leavethis grumpy compiler engineer to mumble to himself.

Virtual operator delete?

Consider this code sample:

#include <cstdio>class Animal {public: virtual void say() = 0; virtual ~Animal() {}};class Sheep : public Animal {public: virtual void say() { printf('Sheep says baaaaa\n'); } virtual ~Sheep() { printf('Sheep is dead\n'); } void operator delete(void* p) { printf('Reclaiming Sheep storage from %p\n', p); ::operator delete(p); }};int main(int argc, char** argv) { Animal* ap = new Sheep; ap->say(); delete ap; return 0;}

What happens when ap is deleted? Two things:

  1. The destructor of the object pointed to by ap is called.
  2. operator delete is called on ap to reclaim heap storage.

Part 1 is fairly clear: the static type of ap is Animal, but thecompiler knows that Animal has a virtual destructor. So it looks up theactual destructor to invoke in the virtual table stored in the object appoints to. Since the dynamic type of ap is Sheep, the destructor foundthere will be Sheep::~Sheep, which is correct.

What about that operator delete, though? Is operator delete virtual too?Is is also stored in the virtual table? Because if it isn't, how does thecompiler know which operator delete to invoke?

No, operator delete is not virtual. It is not stored in the virtualtable. In fact, operator delete is a static member. The C 11 standard saysso explicitly in secton 12.5:

Any deallocation function for a class X is a static member (even if notexplicitly declared static).

It also adds:

Since member allocation and deallocation functions are static they cannot bevirtual.

And if you keep reading, it actually mandates that even though this is the case,when the base destructor is virtual operator delete will be correctly lookedup in the scope of the class that is the dynamic, not the static type of theobject.

Indeed, the code snippet above works correctly and prints:

Sheep says baaaaaSheep is deadReclaiming Sheep storage from 0x1ed1be0

Deleting destructor

So how does this work, if operator delete is not virtual? Then answer is ina special destructor created for by the compiler. It's called the deletingdestructor and its existence is described by the Itanium C ABI:

deleting destructor of a class T - A function that, in addition to theactions required of a complete object destructor, calls the appropriatedeallocation function (i.e,. operator delete) for T.

The ABI goes on to provide more details:

The entries for virtual destructors are actually pairs of entries. The firstdestructor, called the complete object destructor, performs the destructionwithout calling delete() on the object. The second destructor, called thedeleting destructor, calls delete() after destroying the object.

So now the mechanics of this operation should be fairly clear. The compilermimics 'virtuality' of operator delete by invoking it from the destructor.Since the destructor is virtual, what ends up called eventually is thedestructor for the dynamic type of the object. In our example this would bethe destructor of Sheep, which can call the right operator delete sinceit's in the same static scope.

However, as the ABI says, such classes need two destructors. If an object isdestructed but not deleted from the heap, calling operator delete is wrong.So a separate version of the destructor exists for non-delete destructions.

Examining how the compiler implements deleting destructors

That's quite a bit of theory. Let's see how this is done in practice by studyingthe machine code generated by gcc for our code sample. First, I'll slightlymodify main to invoke another function that just creates and discards a newSheep without involving the heap.

void foo() { Sheep s;}int main(int argc, char** argv) { Animal* ap = new Sheep; ap->say(); delete ap; foo(); return 0;}

And compiling this with the flags [1]:

g   -O2 -g -static -std=c  11 -fno-inline -fno-exceptions

We get the following disassembly for main. I've annotated the disassemblywith comments to explain what's going on:

0000000000400cf0 <main>: 400cf0: push %rbx 400cf1: mov $0x8,%edi // Call operator new to allocate a new object of type Sheep, and call // the constructor of Sheep. Neither Sheep nor Animal have fields, so // their size is 8 bytes for the virtual table pointer. // The pointer to the object will live in %rbx. The vtable pointer in this // object (set up by the constructor of Sheep) points to the the virtual // table of Sheep, because this is the actual type of the object (even // though we hold it by a pointer to Animal here). 400cf6: callq 401750 <_Znwm> 400cfb: mov %rax,%rbx 400cfe: mov %rax,%rdi 400d01: callq 4011f0 <_ZN5SheepC1Ev> // The first 8 bytes of an Animal object is the vtable pointer. So move // the address of vtable into %rax, and the object pointer itself ('this') // into %rdi. // Since the vtable's first entry is the say() method, the call that // actually happens here is Sheep::say(ap) where ap is the object pointer // passed into the (implicit) 'this' parameter. 400d06: mov (%rbx),%rax 400d09: mov %rbx,%rdi 400d0c: callq *(%rax) // Once again, move the vtable address into %rax and the object pointer // into %rdi. This time, invoke the function that lives at offset 0x10 in // the vtable. This is the deleting destructor, as we'll soon see. 400d0e: mov (%rbx),%rax 400d11: mov %rbx,%rdi 400d14: callq *0x10(%rax) // Finally call foo() and return. 400d17: callq 4010d0 <_Z3foov> 400d1c: xor %eax,%eax 400d1e: pop %rbx 400d1f: retq

A diagram of the memory layout of the virtual table for Sheep can be helpfulhere. Since neither Animal nor Sheep have any fields, the only'contents' of a Sheep object is the vtable pointer which occupies the first8 bytes:

                          Virtual table for Sheep:ap:--------------            -----------------------| vtable ptr | ---------> |     Sheep::say()    |  0x00--------------            -----------------------                          |   Sheep::~Sheep()   |  0x08                          -----------------------                          | Sheep deleting dtor |  0x10                          -----------------------

The two destructors seen here have the roles described earlier. Let's see theirannotated disassembly:

// Sheep::~Sheep0000000000401140 <_ZN5SheepD1Ev>: // Call printf('Sheep is dead\n') 401140: push %rbx 401141: mov $0x49dc7c,%esi 401146: mov %rdi,%rbx 401149: movq $0x49dd50,(%rdi) 401150: xor %eax,%eax 401152: mov $0x1,%edi 401157: callq 446260 <___printf_chk> 40115c: mov %rbx,%rdi 40115f: pop %rbx // Call Animal::~Animal, destroying the base class. Note the cool tail // call here (using jmpq instead of a call instruction - control does not // return here but the return instruction from _ZN6AnimalD1Ev will return // straight to the caller). 401160: jmpq 4010f0 <_ZN6AnimalD1Ev> 401165: nopw %cs:0x0(%rax,%rax,1) 40116f: nop// Sheep deleting destructor. The D0 part of the mangled name for deleting// destructors, as opposed to D1 for the regular destructor, is mandated by// the ABI name mangling rules.00000000004011c0 <_ZN5SheepD0Ev>: 4011c0: push %rbx // Call Sheep::~Sheep 4011c1: mov %rdi,%rbx 4011c4: callq 401140 <_ZN5SheepD1Ev> 4011c9: mov %rbx,%rdi 4011cc: pop %rbx // Call Sheep::operator delete 4011cd: jmpq 401190 <_ZN5SheepdlEPv> 4011d2: nopw %cs:0x0(%rax,%rax,1) 4011dc: nopl 0x0(%rax)

Now, going back to the amended code sample, let's see what code is generated forfoo:

00000000004010d0 <_Z3foov>:  4010d0:    sub    $0x18,%rsp  4010d4:    mov    %rsp,%rdi  4010d7:    movq   $0x49dd30,(%rsp)  4010df:    callq  401140 <_ZN5SheepD1Ev>  4010e4:    add    $0x18,%rsp  4010e8:    retq  4010e9:    nopl   0x0(%rax)

foo just calls Sheep::~Sheep. It shouldn't call the deleting destructor,because it does not actually delete an object from the heap.

It is also interesting to examine how the destructor(s) of Animal look,since unlike Sheep, Animal does not define a custom operator delete:

// Animal::~Animal00000000004010f0 <_ZN6AnimalD1Ev>: 4010f0: movq $0x49dcf0,(%rdi) 4010f7: retq 4010f8: nopl 0x0(%rax,%rax,1)// Animal deleting destructor0000000000401100 <_ZN6AnimalD0Ev>: 401100: push %rbx // Call Animal::~Animal 401101: mov %rdi,%rbx 401104: callq 4010f0 <_ZN6AnimalD1Ev> 401109: mov %rbx,%rdi 40110c: pop %rbx // Call global ::operator::delete 40110d: jmpq 4011f0 <_ZdlPv> 401112: nopw %cs:0x0(%rax,%rax,1) 40111c: nopl 0x0(%rax)

As expected, the destructor of Animal calls the global ::operatordelete.

Classes with virtual destructors vs. regular destructors

I want to emphasize that this special treatment - generation of a deletingdestructor, is done not for classes that have a custom operator delete, butfor all classes with virtual destructors. This is because when we delete anobject through a pointer to the base class, the compiler has no way of knowingwhat operator delete to invoke, so this has to be done for every classwhere the destructor is virtual [2]. Here's a clarifying example:

#include <cstdio>class Regular {public:  ~Regular() {    printf('Regular dtor\n');  }};class Virtual {public:  virtual ~Virtual() {    printf('Virtual dtor\n');  }};int main(int argc, char **argv) {  Regular* hr = new Regular;  delete hr;  Virtual* hv = new Virtual;  delete hv;  return 0;}

The only difference between Regular and Virtual here is the destructorbeing virtual in the latter. Let's examine the machine code for main to seehow the two delete statements are lowered:

0000000000400cf0 <main>: 400cf0: push %rbx 400cf1: mov $0x1,%edi // Allocate a new Regular object with the global ::operator new 400cf6: callq 4016a0 <_Znwm> // If hr != nullptr, call Regular::~Regular, and then call the global // ::operator delete on hr. 400cfb: test %rax,%rax 400cfe: mov %rax,%rbx 400d01: je 400d13 <main 0x23> 400d03: mov %rax,%rdi 400d06: callq 401130 <_ZN7RegularD1Ev> 400d0b: mov %rbx,%rdi 400d0e: callq 401160 <_ZdlPv> 400d13: mov $0x8,%edi // Allocate a new Virtual object with the global ::operator new 400d18: callq 4016a0 <_Znwm> 400d1d: mov %rax,%rbx 400d20: mov %rax,%rdi // Call the constructor for Virtual. We didn't define a default // constructor, but the compiler did - to populate the vtable pointer // properly. 400d23: callq 401150 <_ZN7VirtualC1Ev> // If hv != nullptr, call the deleting destructor of Virtual through the // virtual table. Do not call operator delete for vr; this will be done by // the deleting destructor. 400d28: test %rbx,%rbx 400d2b: je 400d36 <main 0x46> 400d2d: mov (%rbx),%rax 400d30: mov %rbx,%rdi 400d33: callq *0x8(%rax) 400d36: xor %eax,%eax 400d38: pop %rbx 400d39: retq 400d3a: nopw 0x0(%rax,%rax,1)

The key difference here is that for deleting Regular, the compiler inserts acall to the (global) operator delete after the destructor. However, forVirtual it can't do that so it just calls the deleting destructor, whichwill take care of the deletion as we've seen earlier.


[1]Why this set of options? Without -O2, the code produced by thecompiler is overly verbose. With -O2 it's much better but mostfunction calls are inlined, making the special calls generated for thedeleting destructor hard to follow; hence -fno-inline. I'm alsodisabling exceptions because these complicate the code around destructorswithout being relevant to the main goal of the article.
[2]

One of the derived classes may declare its own operator delete, andthe compiler doesn't know that. In fact, a pointer to a derived class cancome from a shared library that was built completely separately from themain program (as this sample demonstrates ).

But even if none of the derived classes defines a custom operator delete,it's important to know the dynamic type of the deleted object when thedestructor is called to pass the correct address to the globaloperator delete. An interesting discussion of this issue can be foundin this Reddit comment thread.

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多