The Silent Killer in C++ Inheritance: When RAII Breaks Down into Undefined Behavior
As part of my deeper dive into C++, I want to talk about a trap that catches almost every C++ developer at least once. It’s a subtle bug that compiles without a single warning (unless your linter is strictly configured), runs seemingly fine in simple tests, but creates a ticking time bomb of memory leaks and Undefined Behavior (UB) in production.
I'm talking about the intersection of inheritance, polymorphism, and Resource Acquisition Is Initialization (RAII).
Specifically: Deleting a derived class object through a base class pointer without a virtual destructor.
The Promise of RAII
RAII is the bedrock of C++. The concept is elegantly simple: you acquire a resource (memory, file handles, network sockets, mutex locks) in the constructor, and you guarantee its release in the destructor. As long as the object goes out of scope —whether through normal execution or an exception— the destructor cleans up the mess.
But what happens when the destructor isn't called? The entire RAII contract breaks.
Let's look at how inheritance can silently break this contract.
The Setup: The Trap Imagine we are building a simple system where we have a base class and a specialized derived class. The derived class allocates some memory dynamically (or holds onto a database connection, a file, etc.).
#include <iostream>
class Base {
public:
Base() {
std::cout << "Base acquired resources.\n";
}
// Notice anything missing here?
~Base() {
std::cout << "Base released resources.\n";
}
};
class Derived : public Base {
private:
int* buffer;
public:
Derived() {
buffer = new int[1024]; // Acquiring a resource
std::cout << "Derived acquired massive buffer.\n";
}
~Derived() {
delete[] buffer; // Releasing the resource
std::cout << "Derived released massive buffer.\n";
}
};
If we use these classes normally on the stack, everything works perfectly.
However, C++ relies heavily on polymorphism. We often want to manage a collection of objects through pointers to their base class. Let's see what happens when we do that:
int main() {
std::cout << "--- Creating object ---\n";
Base* myObject = new Derived();
std::cout << "--- Destroying object ---\n";
delete myObject;
return 0;
}
The Output If you run this code, here is what the console prints:
--- Creating object ---
Base acquired resources.
Derived acquired massive buffer.
--- Destroying object ---
Base released resources.
Wait. Where is "Derived released massive buffer."?
The Derived destructor was completely ignored. The 1024 integers we allocated in the constructor just leaked. RAII has failed.
Why Does This Happen? (The Undefined Behavior) In C++, function calls are resolved statically (at compile time) by default. This is done for performance.
When the compiler sees delete myObject;, it looks at the type of the pointer, which is Base*. Because the ~Base() destructor is not marked as virtual, the compiler uses static binding. It hardcodes a call strictly to Base::~Base(). It doesn't care that the pointer is actually pointing to a Derived object in memory.
Technically, according to the C++ Standard, deleting a derived class object via a base class pointer that lacks a virtual destructor is not just a memory leak—it is Undefined Behavior.
This means the compiler is allowed to do anything. It might crash your program, it might silently leak memory, or it might corrupt the heap. In a complex system, the Derived class might have std::string or std::vector members; none of their destructors will be called either.
The Fix: The virtual Keyword The solution is incredibly simple, yet easily forgotten. If a class is meant to be inherited from, and you intend to manipulate derived objects via base pointers, the base class must have a virtual destructor.
class Base {
public:
Base() { std::cout << "Base acquired resources.\n"; }
// The crucial fix:
virtual ~Base() {
std::cout << "Base released resources.\n";
}
};
(Note: In modern C++, if your base class destructor doesn't actually need to do anything, you should write it as virtual ~Base() = default; to keep it clean and optimized).
By marking the destructor as virtual, you tell the compiler to use dynamic binding (via the vtable). Now, when delete myObject; is executed, the program checks the actual runtime type of the object being pointed to.
It correctly identifies it as Derived, calls ~Derived() (which frees our buffer), and then automatically cascades up to call ~Base().
The Correct Output:
--- Creating object ---
Base acquired resources.
Derived acquired massive buffer.
--- Destroying object ---
Derived released massive buffer.
Base released resources.
RAII is restored. The leak is fixed.
What About Smart Pointers? You might be thinking: "I write modern C++. I use std::unique_ptr. Raw new and delete are for dinosaurs."
Let's test that theory:
#include <memory>
int main() {
std::unique_ptr<Base> myObject = std::make_unique<Derived>();
// ... unique_ptr goes out of scope and cleans up automatically
return 0;
}
If Base does not have a virtual destructor, std::unique_ptr<Base> will still suffer from the exact same Undefined Behavior. The smart pointer essentially just calls delete internally on the stored pointer type.
Interestingly, std::shared_ptr behaves differently. Due to type erasure during its initialization, a std::shared_ptr<Base> ptr = std::make_shared<Derived>(); will actually call the correct Derived destructor even if the base destructor isn't virtual. However, relying on this quirk is terrible practice and brittle design.
The Golden Rule of C++ Inheritance
If you take away one thing from this post, make it this:
If a class has any virtual functions, it should almost certainly have a virtual destructor. If a class is designed to be a polymorphic base class, a virtual destructor is mandatory.
C++ gives you ultimate power over memory and execution, but it assumes you know exactly what you are doing. The language won't hold your hand, and it won't clean up after you if you break the rules of polymorphism. Understand the hardware, understand the compiler, and respect the RAII contract.