2

Let's say we have a class BST_Node :

struct BST_Node {
  BST_Node* left;
  BST_Node* right;
}

And a class AVL_Node :

struct AVL_Node : BST_Node {
  int height;
}

and in some function

void destroyTree() {
  BST_Node *mynode = new AVL_Node;
  delete mynode; //  Is it ok ?
}

Question #1

When the destructor is non virtual but there are only primitives types in derived, is it safe to call delete on base class ? (will there be no memory leaks ?)

Question #2

What is the rule when declaring a destructor virtual in derived class only ? As I understood, all of the destructors are the same function, we can call it destructor() and then when we delete a base pointer, the destructor is called only for the base class, but when deleting a derived class, the destructor will also being dispatched into sub-derived classes.

rafoo
  • 1,506
  • 10
  • 17
  • 1
    I believe it is undefined behavior. If you compile with `-fsanitize=undefined`, then you should get a finding when deleting through the base class. – jww Apr 15 '19 at 16:13
  • 1
    It's a closer duplicate of https://stackoverflow.com/questions/42845059/ or https://stackoverflow.com/questions/4294066/ (or possibly others). – jamesdlin Apr 15 '19 at 16:16
  • Queation1: I tried it with valgrind and it reports memory leak only if I allocate some memory dynamically in `AVL_Node` (but it doesn't mean that you will not have leaks with other compilers). Q2: I think you misunderstood the virtual DTors concept. Base class should always have virtual DTor, because if you call `delete` on a pointer to Base, the compiler only knows that it should delete Base, it doesn't know what might be the type (possibly derived, more complicated) you created with `new`. – pptaszni Apr 15 '19 at 16:23

2 Answers2

0

Run away

Deleting a derived object through a pointer to base when there is no virtual destructor is undefined behavior. This is true regardless of how simple the derived type is.

Now, at runtime, every compiler turns delete foo into "find the destructor code, run it, then clean up the memory". But you cannot base your understanding of what C++ code means based on the runtime code emitted by a compiler.

So you naively can think "I don't care if we run the wrong destruction code; the only thing I added was an int. And the memory cleanup code handles over-allocation. So we are good!"

You even go and test it, and you look at the assembly produced, and everything works! And you conclude there is no problem here.

You are wrong.

Compilers do two things. First, the emit runtime code. Second, they use the structure of your program to reason about it.

That second part is a powerful feature, but it also makes doing undefined behavior extremely dangerous.

What your C++ program means in the "abstract machine" the C++ standard specifies matters. It is in that abstract machine that optimizations and code transformations occur. Knowing how an isolated snippet of code gets emitted on your physical machien does not tell you what that snippet of code does.

Here is a concrete example:

struct Foo {};
struct Bar:Foo{};

Foo* do_something( bool cond1, bool cond2 ) {
  Foo* foo = nullptr;
  if (cond1)
    foo = new Bar;
  else
    foo = new Foo;

  if (cond2 && !cond1)
    inline_code_to_delete_user_folder();

  if (cond2) {
    delete foo;
    foo = nullptr;
  }
  return foo;
}

here is a toy with some toy types.

In it, we create a pointer to either a Bar or a Foo based on cond1.

Then we possibly do something dangerous.

Finally, if cond2 is true, we cleanup the Foo* foo.

The thing is, if we call delete foo and foo is not a Foo, it is undefined behavior. The compiler can legitimately reason "ok, so we are calling delete foo, thus *foo is an object of type Foo".

But if foo is a pointer to an actual Foo, then obviously cond1 must be false, because only when it is false is foo pointing at an actual Foo.

Thus, logically, cond2 is true implies cond1 is true. Always. Everywhere. Retroactively.

So the compiler actually knows this is a legitimate transformation of your program:

Foo* do_something( bool cond1, bool cond2 ) {
  if (cond2) {
    Foo* foo = new Foo;
    inline_code_to_delete_user_folder();
    delete foo;
    return nullptr;
  }       
  Foo* foo = nullptr;
  if (cond1)
    foo = new Bar;
  else
    foo = new Foo;

  return foo;
}

which is pretty dangerous, isn't it? We just elided checking cond1 and deleted the user folder whenever you passed true to cond2.

I don't know if any current or future compilers use the detection of UB in deleting the wrong type to do logical back-propogation of UB branches, but compilers do do something similar with other kinds of UB, even things as seemingly innocuous as signed integer overflow.

And to ensure this cannot happen, you need to audit every optimization in every compiler from every compiler that will ever compile your code.

Run away

Community
  • 1
  • 1
Yakk - Adam Nevraumont
  • 262,606
  • 27
  • 330
  • 524
-1

When the destructor is non virtual but there are only primitives types in derived, is it safe to call delete on base class ? (will there be no memory leaks ?)

You might not be aware of it, but these are two different questions.

The latter answer is: no, there won't be any memory leaks for this specific example, but there could be for other examples.

And the reason why is the answer to the former question: no, it is not safe to do this. This constitutes undefined behavior, even if the behavior is well-understood for nearly all compilers—and 'understood' is not synecticy for "is safe to do", just to be clear.

When you write code like delete mynode;, the compiler has to figure out which destructor to call. If the destructor for mynode is not virtual, then it will always use the base destructor, doing whatever the base destructor needs to do, but not whatever the derived destructor needs to do.

In this case, that's not such a big deal: the only thing AVL_Node adds is a locally allocated int variable, which will be cleaned up as part of the same process that cleans up the whole pointer.

But if your code were like this instead:

struct AVL_Node : public BST_Node {
    std::unique_ptr<int> height = std::make_unique<int>();
};

Then this code would definitely cause memory leaks, even though we've expressly used a smart pointer in the construction of the derived object! The smart pointer doesn't save us from the tribulations of deleteing a base pointer with a non-virtual destructor.

And in general, your code could cause any kind of leak, including but not limited to resource leaks, file handle leaks, and so on, if AVL_Node were responsible for other objects. Consider, for example, if AVL_Node had something like this, which is extremely common in certain kinds of graphics code:

struct AVL_Node : public BST_Node {
    int handle;
    AVL_Node() {
        glGenArrays(1, &handle);
    }
    /*
     * Pretend we implemented the copy/move constructors/assignment operators as needed
     */
    ~AVLNode() {
        glDeleteArrays(1, &handle);
    }
};

Your code wouldn't leak memory (in your own code), but it would leak an OpenGL object (and any memory allocated by that object).

What is the rule when declaring a destructor virtual in derived class only ?

If you never plan to store a pointer to the base class, then this is fine.

It's also unnecessary unless you plan to also create further derived instances of the derived class.

So here's the example we'll use for the sake of clarity:

struct A {
    std::unique_ptr<int> int_ptr = std::make_unique<int>();
};

struct B : A {
    std::unique_ptr<int> int_ptr_2 = std::make_unique<int>();
    virtual ~B() = default;
};

struct C : B {
    std::unique_ptr<int> int_ptr_3 = std::make_unique<int>();
    //virtual ~C() = default; // Unnecessary; implied by B having a virtual destructor
};

Now here's all the the code that's safe and unsafe to use with these three classes:

auto a1 = std::make_unique<A>(); //Safe; a1 knows its own type
std::unique_ptr<A> a2 = std::make_unique<A>(); //Safe; exactly the same as a1
auto b1 = std::make_unique<B>(); //Safe; b1 knows its own type
std::unique_ptr<B> b2 = std::make_unique<B>(); //Safe; exactly the same as b1
std::unique_ptr<A> b3 = std::make_unique<B>(); //UNSAFE: A does not have a virtual destructor!
auto c1 = std::make_unique<C>(); //Safe; c1 knows its own type
std::unique_ptr<C> c2 = std::make_unique<C>(); //Safe; exactly the same as c1
std::unique_ptr<B> c3 = std::make_unique<C>(); //Safe; B has a virtual destructor
std::unique_ptr<A> c4 = std::make_unique<C>(); //UNSAFE: A does not have a virtual destructor!

So if B (a class with a virtual destructor) inherits from A (a class without a virtual destructor), but as a programmer, you promise you will never refer to an instance of B with an A pointer, then you have nothing to worry about. So in that case, like my example tries to show, there may be valid reasons to declare the destructor of a derived class virtual while leaving the super class' destructor non-virtual.

Community
  • 1
  • 1
Xirema
  • 19,889
  • 4
  • 32
  • 68
  • Thanks, for #2, the part `std::unique_ptr c3 = std::make_unique(); //Safe; B has a virtual destructor` answered it. – rafoo Apr 15 '19 at 16:52
  • This presumes to describe what behaviour undefined behaviour causes. That is not a good plan; it is undefined behaviour. The compiler could do *anything* and still be compliant. It could assume your code is unreachable due to UB, and back-optimize through time to rule out the possibility your function is invoked. Instead it says "well, it could leak in a different situation"; no, it could cause your program to email your browser history to your mother in law in the OP's exact situation, and still be a compliant C++ program. Specific versions of specific compilers? You can conclude different. – Yakk - Adam Nevraumont Apr 15 '19 at 17:17
  • @Yakk-AdamNevraumont *"The compiler could do anything and still be compliant."* This is one of those arguments I see come up every now and again, and it's an argument that is "true" (lower-case-t) without being "Accurate" (upper-case-A). The compiler can not, in fact, do "whatever it likes", and pointing out, accurately, that the behavior under most compilers is well-understood isn't harming anyone's comprehension of Undefined Behavior. Accessing an inactive member of a union is undefined behavior in C++, but we still often upvote answers that make explicit use of union-based Type Punning. – Xirema Apr 15 '19 at 17:21
  • @Xirema You might upvote; I wouldn't without the answer describing *why* the operation is dangerous and *why* they think it is "safe" and effective. You don't do this; someone who doesn't say "no that is wrong" could be mislead into thinking that deleting through a pointer to base a derived that adds only trivial types is perfectly safe. Compilers have and do use detection of UB to eliminate branches from possibility; proving that a *specific* compiler couldn't possible do this with a delete-through-base would require a *lot* of time, let alone *all* compilers from now until the end of time. – Yakk - Adam Nevraumont Apr 15 '19 at 17:37
  • @Yakk-AdamNevraumont *"You don't do this"* My answer is very clear that the practice is unsafe. I even **bolded** that part at the beginning of the answer. Did you even read the whole answer? – Xirema Apr 15 '19 at 17:40
  • @Xirema Yes, I read the whole answer. And I said "no, that is wrong, Xirema cannot guarantee there would be no memory leaks without auditing every compiler to an insane level of detail". You qualify with a pile of complex passive voice constructions in the next paragraph. Then you proceed to only talk about the possibility of leaking in cases different than the OP's, and act as if the only possible issue could be calling the wrong destructor. I cannot tell if **you** understand the risks from your answer, let alone believe you reliably communicate the risks to someone looking for advice. – Yakk - Adam Nevraumont Apr 15 '19 at 17:59
  • You delete an object via a pointer-to-`Foo` which has a non-virtual destructor. The compiler is perfectly reasonable in concluding that the pointer to `Foo` *must have been a pointer to an actual `Foo`*. So if we have `struct Bar:Foo{}; Foo* f; if (cond1) { f = new Bar; } else { f = new Foo; } if (cond2) { delete f; }`, the compiler can **legitimately** prove that `cond2` implies `!cond1` *because the alternative is UB*, so can be discarded. Then it can transform your code in arbitrary ways based off that proof. Meanwhile, the runtime implementation of delete f could match your model. – Yakk - Adam Nevraumont Apr 15 '19 at 18:01
  • @Yakk-AdamNevraumont If you think there's information that isn't being adequately conveyed by this post to the level of detail you demand, you're more than welcome to submit your own answer. – Xirema Apr 15 '19 at 18:12