Hello! Welcome back to our deep dive into C++ internals.
In the last lesson, we demystified the virtual function call, tracing it from the C++ source code down to the assembly instructions. We established that the vptr and vtable form a mechanism for runtime polymorphism, but this mechanism comes with performance trade-offs, primarily by inhibiting compiler optimizations like inlining.
Today, we will apply our understanding of the vtable to a critical aspect of object-oriented programming: proper resource cleanup. The learning outcome for this lesson is to implement and explain the necessity of virtual destructors for preventing resource leaks in polymorphic deletion. We will see how a simple delete operation can go wrong in a polymorphic hierarchy and how the virtual keyword, when applied to a destructor, leverages the vtable to ensure correctness.
1. The Polymorphic Deletion Problem
In C++, destructors are responsible for releasing resources an object might hold—memory, file handles, network sockets, etc. The destruction sequence is the reverse of construction: a derived class's destructor runs first, followed by its base class's destructor. This works perfectly when the compiler knows the object's concrete type.
But what happens when we use a base class pointer to manage a derived class object? Let's build a scenario.
#include <iostream>
// A base class that allocates some memory
class Base {
public:
Base() {
std::cout << "Base constructor: Allocating data.\n";
data = new int[10]; // Allocate a resource
}
~Base() {
std::cout << "Base destructor: Releasing data.\n";
delete[] data; // Release the resource
}
private:
int* data;
};
// A derived class that also allocates its own memory
class Derived : public Base {
public:
Derived() {
std::cout << "Derived constructor: Allocating extra_data.\n";
extra_data = new int[20]; // Allocate another resource
}
~Derived() {
std::cout << "Derived destructor: Releasing extra_data.\n";
delete[] extra_data; // Release its own resource
}
private:
int* extra_data;
};
If we create and delete a Derived object directly, everything is fine:
// Correct destruction
Derived* d = new Derived();
delete d;
Output:
Base constructor: Allocating data.
Derived constructor: Allocating extra_data.
Derived destructor: Releasing extra_data.
Base destructor: Releasing data.
The cleanup is complete. But the power of polymorphism lies in using base pointers. Let's see what happens there.
// The problematic case
Base* b = new Derived();
delete b;
Output:
Base constructor: Allocating data.
Derived constructor: Allocating extra_data.
Base destructor: Releasing data.
Notice what's missing? The Derived destructor was never called! The 20 integers allocated for extra_data are now leaked memory.
This happens because the destructor, by default, is a non-virtual function. The call delete b; is resolved using static binding. The compiler only sees that b is a Base*, so it generates a call to ~Base(). It has no information at compile time that the pointer actually points to a Derived object.
To see a very clear, step-by-step demonstration of this exact problem, please watch the following video.
Virtual Destructors | C++ Tutorial
The video 'Virtual Destructors' from Portfolio Courses provides an excellent walkthrough of creating base and derived classes that manage resources, demonstrating the memory leak and then showing the solution.
Please watch the video from the beginning up to 08:32. This covers: The reason virtual destructors are needed. The setup of the BaseData and DerivedData classes, which is very similar to our example. The demonstration of the memory leak when deleting a derived object through a base class pointer.
2. The Solution: virtual ~Base()
The solution is to tell the compiler that the destructor should be resolved using dynamic dispatch, just like any other virtual function. We do this by marking the base class destructor as virtual.
class Base {
public:
Base() { /* ... */ }
// Make the destructor virtual!
virtual ~Base() {
std::cout << "Base destructor: Releasing data.\n";
delete[] data;
}
// ...
};
// Derived class destructor is automatically virtual
class Derived : public Base {
public:
Derived() { /* ... */ }
// This destructor implicitly overrides Base's virtual destructor
~Derived() {
std::cout << "Derived destructor: Releasing extra_data.\n";
delete[] extra_data;
}
// ...
};
Now, when we run the polymorphic deletion code again:
Base* b = new Derived();
delete b;
The output is correct:
Base constructor: Allocating data.
Derived constructor: Allocating extra_data.
Derived destructor: Releasing extra_data.
Base destructor: Releasing data.
With virtual ~Base(), the delete b; expression now triggers a virtual call. The runtime follows the object's vptr to the Derived vtable and finds the address of the correct destructor to call, which is ~Derived(). C++ guarantees that after the ~Derived() body executes, the ~Base() destructor is called automatically, ensuring the entire object is cleaned up properly.
Let's complete the video you started to see this solution in action.
Virtual Destructors | C++ Tutorial
Now, let's see how adding the virtual keyword fixes the issue in the example from the Portfolio Courses video.
Watch the remainder of the video from 08:32 to 09:41. It shows how making the base destructor virtual enables dynamic binding and ensures the correct chain of destructors is called.
3. Under the Hood: The Deleting Destructor
Your interest is in the low-level details, so let's go deeper. Simply saying "the vtable has a pointer to the destructor" is an oversimplification. Compilers, following the Itanium C++ ABI (used by GCC and Clang on Linux), actually generate two versions of the destructor for a polymorphic class and place both in the vtable.
This is a subtle but crucial implementation detail. The article below provides an excellent hands-on exploration of the vtable, revealing these two destructors.
Visualizing the C++ Object Memory Layout Part 1: Single ...
The article 'Visualizing the C++ Object Memory Layout' from Sofia Belen's blog provides a fantastic, practical guide to manually exploring the vtable. We will focus on the section where a virtual destructor is added.
Please read the section titled 'Difference Between Deleting and Complete Object Destructors'. It explains this core concept and shows the assembly symbols generated by the compiler. Don't worry about the code for manually calling functions; focus on understanding the purpose of the two destructor variants.
As the article explains, the two destructors are:
- The Complete Object Destructor: This is responsible for destroying the object's members and calling base class destructors. It does not deallocate the memory the object itself occupies. This is the version called for stack-allocated objects when they go out of scope.
- The Deleting Destructor: This is the version that the
deleteoperator calls via the vtable. Its job is twofold: first, it calls the complete object destructor, and second, it callsoperator deleteto free the memory block where the object resided.
This distinction is why manually calling the destructor found in the vtable on a stack object causes a crash (free(): invalid pointer): you are telling the program to free memory that was never allocated on the heap.
The vtable layout for a class with a virtual destructor looks something like this (on a 64-bit system):
A diagram showing the vtable for a Child class.
Memory layout for an object with a virtual base class. The object starts with an 8-byte vptr, followed by member variables. The vtable itself contains entries for the typeinfo object and the two destructor variants: the complete object destructor and the deleting destructor.
When you execute delete b; where b is a Base* pointing to a Derived object:
- The
vptrin theDerivedobject is dereferenced to find theDerivedvtable. - The runtime looks up the address of the deleting destructor from the vtable.
- This
Derived::deleting_destructoris called. - It in turn calls the
Derived::complete_destructor. - The complete destructor executes the body of
~Derived()and then calls~Base(). - Once the complete destructor chain finishes, the deleting destructor calls
operator delete(this)to free the object's memory.
This elegant mechanism, hidden behind the virtual keyword, is what ensures polymorphic objects are safely destroyed.
The Golden Rule
This leads to one of the most important rules in C++ class design:
If you intend a class to be a polymorphic base class, give it a virtual destructor.
The presence of any other virtual function is a strong signal that the class is meant for polymorphic use. Therefore, a simpler version of the rule is: if a class has any virtual functions, it should have a virtual destructor. This prevents the subtle but severe memory leak we've just analyzed.
Test your understanding!
Consider a class hierarchy: Shape (base), Circle (derived from Shape), and Square (derived from Shape).
The Shape class has no data members, but it has a virtual void draw() const;. The Circle and Square classes both override draw() and allocate memory in their constructors to store their geometric properties.
If a programmer writes Shape* my_shape = new Circle(); and later delete my_shape;, what happens if the Shape destructor is non-virtual? What one-word change fixes this, and how does it fix it at the machine level?
Show answer
If the Shape destructor is non-virtual, only ~Shape() will be called. The ~Circle() destructor will be skipped, and the memory allocated by the Circle object will be leaked.
The one-word fix is to declare the base class destructor as virtual: virtual ~Shape() {}.
This fixes the problem at the machine level by instructing the compiler to add entries for the destructors into the vtable for Shape and all derived classes. When delete my_shape; is executed, the C++ runtime performs a dynamic dispatch: it uses the object's vptr to find the Circle vtable, looks up the "deleting destructor" for Circle, and calls it. This ensures the ~Circle() code runs, followed by the ~Shape() code, before the memory is deallocated.
Conclusion
Today we've uncovered a common and dangerous pitfall in object-oriented C++ and seen its elegant solution.
Key Takeaways:
- Deleting a derived object through a base class pointer with a non-virtual destructor results in undefined behavior, which typically manifests as the derived destructor not being called, leading to resource leaks.
- This happens because a non-virtual function call, including a destructor call, uses static binding, resolving to the type of the pointer (
Base*), not the type of the object it points to (Derived). - Declaring the base class destructor
virtualsolves this by enabling dynamic binding for destruction. Thedeleteoperation then uses the vtable to find and call the correct derived class destructor. - At a lower level, compilers often implement this by creating two destructor variants in the vtable: a complete object destructor (for cleanup) and a deleting destructor (for cleanup and deallocation).
In our next lesson, we will investigate another classic C++ pitfall related to polymorphism: object slicing. We will analyze what happens when you pass derived class objects by value and why this can silently corrupt your program's state.