sudo rm -rf slash
sudo rm -rf slash

Reputation: 1254

How can I understand these destructors?

I'm confused about the following C++ code (run it online at http://cpp.sh/8bmp). It combines several concepts I'm learning about in a course.

#include <iostream>
using namespace std;

class A {
    public:
        A() {cout << "A ctor" << endl;}
        virtual ~A() {cout << "A dtor" << endl;}
};

class B: public A {
    public:
        B() {cout << "B ctor" << endl;}
        ~B() {cout << "B dtor" << endl;}
        void foo(){cout << "foo" << endl;}
};

int main(){
    B *b = new B[1];
    b->~B();
    b->foo();
    delete b;
    return 0;
}

Output:

A ctor
B ctor
B dtor
A dtor
foo
A dtor

Here's what I don't understand:

  1. Why can I call foo after calling the destructor?
  2. Why can I call delete after calling the destructor?
  3. If I comment out delete b; will this code leak memory?
  4. The destructor for A is virtual. I thought virtual functions overloaded in subclasses wouldn't get called. Why does ~A() get called then?
  5. If I comment out b->~B(); then the line B dtor is printed after foo. Why?
  6. If I repeat the line b->~B(); twice, then the output is: B dtor\nA dtor\nA dtor. Huh?
  7. I get the same output if I switch delete B; with delete[] b;. I think the second one is correct because b is created with new[], but it doesn't matter because I'm only pushing one instance of B to the heap. Is that correct?

I'm sorry for asking so many questions, but this is pretty confusing to me. If my individual questions are misguided, then tell me what I need to know to understand when each destructor will run.

Upvotes: 1

Views: 118

Answers (3)

Frederic Lachasse
Frederic Lachasse

Reputation: 721

Q1: Calling a method on a destroyed object is "Undefined Behaviour". Meaning that the standard does not specify what should happen. The idea behind UB is that they are supposed to be bugs in the application logic, but for performance reason, we do not force the compiler to do something special about it, as that would degrade performance for when we do things correctly.

In this case, because the foo() method does not depends on anything in the memory pointed by b, it will work as expected. Just because the compiler does not do any test on it.

Q2: This is also "Undefined Behaviour". And you can see already some bizarre things are happening. First, the B destructor is not called, just the A destructor. What is happening is that when you called previously b->~B(), the B destructor was called, then the vtable of the object was changed to a A vtable (meaning the object runtime type became A) and then the A destructor was called. When you called delete b, the runtime called the virtual destructor of the object which was A. Like said previously, this is "Undefined Beheviour". The compiler chhose to generate code that works that way when calling delete b, but it could have generated different code and still be right.

If fact, something worse has probably been done after calling the A destructor because of another bug in your code: you use delete instead of delete[]. C++ rules states that arrays allocated with operator new[] must be freed using operator delete[] and that using operator delete is "Undefined behaviour". In fact, in most implementation I know, doing that will work the first time, but has a good chance of corrupting the memory management data, so that a future call to new or delete, even valid, may crash or cause memory leak.

Q3: There will be a memory leak if you remove the call. You will probably have memory corruption if you keep the call. Memory leak will be avoided if you use delete[] b instead. There is still undefined behaviour because a destrutor will be called on the already destroyed object, but because these destructors do nothing, they will not likely do any more damage to your program

Q4: This is the rule with all destructors, not just virtual destructors: they will destroy the member objects then the base objects at the end of the code.

Q5: So when the compiler generates the code for the B destructor, it adds at the end a call to the A destructor. But there is a rule: at this time, this is not a B object anymore and during the call to any virtual method in the A destructor, A methods must be called, not B methods, even if virtual. So before calling the A destructor, the B destructor will "downgrade" the dynamic type of the object from B to A (in practice, that means setting the vtable of the object to the A vtable). As the goal of the compiler is to generate efficient code, it does not have to change the vtable at the end of the A destructor. Remember: any method call on the object after the destructor is called is "Undefined Behaviour". Priority is performance, not bug detection.

Q6: same answer as Q5, and I also talked about is in Q2

Q7: It matters. A lot. delete[] needs to know the number of objects created so it can call the destrutors for all the objects in the array. Often implementations of new[] will actually allocate a size_t element to store the number of objects in the array before the elements of the array. So the returned pointer is not the start of the allocated bloc, but the location after the size (4 bytes on 32 bits system, 8 bytes on 64 bits system). So first new B[1] will allocate 4 or 8 bytes more that new B and second delete[] will need to decrement the pointer by 4 or 8 bytes before deallocating it. So delete b and delete[] b are VERY different.

Note: compilers are not mandated to implement new[] and delete[] that way. And some implementations offer version of the runtime library that do more checkings so that bugs are easier to detect. However, for the best performance, delete[] MUST be called if new[] is used. And if you make the mistake to call delete on a pointer allocated by new[], most of the time, it will not crash or fail the first time you do it. It will likely crash or fail later in a perfectly legal new, new[], delete or delete[] operation. Which often means a lot of head scratching wondering why a perfectly correct operation is failing. This is the true meaning of "Undefined Behaviour"

Upvotes: 0

Cornstalks
Cornstalks

Reputation: 38238

  1. Why can I call foo after calling the destructor?

C++ doesn't stop you from shooting yourself in the foot. Just because you can do it (and the code doesn't immediately crash) doesn't mean it's legal or well defined.

  1. Why can I call delete after calling the destructor?

Same as answer #1.

  1. If I comment out delete b; will this code leak memory?

Yes. You must delete what you new (and delete[] what you new[]).

  1. The destructor for A is virtual. I thought virtual functions overloaded in subclasses wouldn't get called. Why does ~A() get called then?

I think the word you want is override, not overload. Anyway, you're not overriding ~A(). Notice that ~B() and ~A() have different names.

Destructors are kinda special. When the derived class's destructor is finished running, it implicitly calls the base class's destructor. Why? Because the C++ standard says that's what will happen.

A virtual destructor is a special destructor. I lets you polymorphically delete an object. That means you can do code like the following:

B *b = new B;
A *a = b;
delete a; // Legal with virtual destructors, illegal without virtual.

If A did not have a virtual destructor in the code above, it would not call ~B(), which would be undefined behavior. With a virtual destructor, the compiler will correctly call ~B() when delete a; is run, even though a is an A* and not a B*.

  1. If I comment out b->~B(); then the line B dtor is printed after foo. Why?

Because it's run after foo(). delete b; implicitly calls b's destructor, which is after foo() has already run.

  1. If I repeat the line b->~B(); twice, then the output is: B dtor\nA dtor\nA dtor. Huh?

It's undefined behavior. So anything can happen, really. Yeah, that's weird output. Undefined behavior is weird.

  1. I get the same output if I switch delete B; with delete[] b;. I think the second one is correct because b is created with new[], but it doesn't matter because I'm only pushing one instance of B to the heap. Is that correct?

It matters what you call. delete and delete[] are not the same thing. You can't call one in place of the other. You must call delete only on memory that's been allocated with new, and delete[] with memory that's been allocated with new[]. You cannot mix and match as you want. Doing so is undefined behavior.

You should be using delete[] here, in this code, because you used new[].

Upvotes: 1

Mats Petersson
Mats Petersson

Reputation: 129494

"Undefined behaviour" (UB for short) is where the compiler is allowed to do anything - this commonly means somewhere between "crash", "give incorrect result" and "do what you'd expect anyway". Your b->foo() is definitely undefined, since it happens after your b->~B() call,

Since your foo function doesn't actually USE anything that gets destroyed by the destructor, the call to foo "works", because there is nothing being used that has been destroyed. [This is by no means guaranteed - it JUST HAPPENS to work, a bit like sometimes it's fine to cross a road without looking, at other times it's not. Depending on what road it is, it may be a really bad idea, or might work most of the time - but there is a reason people say "look left, look right, look left, then cross if it's safe" (or something like that)]

Calling delete on an object that has been destroyed is also UB, so again, it's pure luck that it "works" (in the sense of "doesn't cause your program to crash").

Also mixing delete with new [] or vice versa is UB - again, the compiler [and it's related runtime] may do the right or the wrong thing, depending on circumstances and conditions.

Do Not rely on undefined behaviour in your program [1]. It is bound to come back and bite you. C and C++ have quite a few UB-cases, and it's good to understand at least the most common cases, such as "use after destruction", "use after free" and such, and be on the lookout for such cases - and avoid it at all costs!

Upvotes: 6

Related Questions