C++ Virtual Destructor Pitfalls — Polymorphic Leaks
Non-virtual destructors skip derived cleanup, leaking sockets until ulimit crashes your app.
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
- Constructors guarantee an object is fully initialised the moment it exists
- Destructors run automatically when scope ends — even during stack unwinding
- RAII ties resource lifetime to object lifetime: acquire in ctor, release in dtor
- Rule of Five: custom destructor implies need for custom copy/move ops
- Virtual destructors prevent partial destruction in class hierarchies
- Worst mistake: letting a destructor throw — calls std::terminate instantly
Think of a constructor like a hotel check-in desk. The moment you arrive (an object is created), someone hands you a room key, sets up your Wi-Fi, and turns on the lights — everything is ready before you even walk to your room. A destructor is the check-out process: you hand back the key, the room gets cleaned, and resources are freed for the next guest. You never have to remember to 'clean up' yourself — the hotel handles it automatically when you leave.
Every program manages resources — memory, file handles, network connections, database locks. In C, you had to manually open every resource and manually close it; if anything went wrong in between, you were left with leaks that could crash servers and corrupt data. C++ was designed from the ground up to fix this. Constructors and destructors are the foundation of that fix. They're not just syntax sugar — they're the mechanism that makes C++ the language of choice for systems where resource safety is non-negotiable, from game engines to operating systems to financial trading platforms.
The core problem they solve is predictability. Without a guaranteed setup and teardown mechanism, object state is fragile — you rely on the programmer to remember to call before using an object and init() afterward. Constructors guarantee that an object is fully initialised the instant it exists. Destructors guarantee that cleanup happens the instant an object goes out of scope, no matter how the scope exits — whether normally, or via an exception.cleanup()
By the end of this article, you'll understand not just how to write constructors and destructors, but why each type exists, how the RAII pattern makes resource management automatic, and the exact mistakes that cause memory leaks and double-free crashes in production codebases.
What Constructors Actually Do (and Why You Can't Skip Them)
A constructor is a special member function that runs automatically the moment an object is created. It has the same name as the class and no return type — not even void. This 'no return type' rule isn't arbitrary: the language designers wanted it to be unmistakably distinct from regular functions because it behaves differently. It doesn't return a value to a caller; it initialises the object in place.
The most important thing to understand is that C++ guarantees the constructor runs before any code can use the object. This makes it impossible to accidentally use an uninitialised object. If construction fails (for example, a new allocation inside the constructor throws), the object never exists in the first place, ensuring you never have to deal with 'zombie' objects in half-broken states.
C++ provides several constructor types: the default constructor (no arguments), parameterised constructors (custom state), the copy constructor (cloning), and the move constructor (ownership transfer). Reaching for the right one is a matter of performance—especially when objects hold expensive resources like heap memory or socket handles.
BankAccount(int n) : accountNumber(n) {} instead of BankAccount(int n) { accountNumber = n; }. The initialiser list directly constructs members, while the body version default-constructs them first and then assigns — that's a wasted construction cycle for non-trivial types like std::string. For const and reference members, the initialiser list is mandatory.Constructor vs Destructor — Side-by-Side Comparison
Understanding the precise differences between constructors and destructors is crucial for writing correct C++ code. While constructors handle setup, destructors handle teardown. The following table summarizes the key differences:
| Aspect | Constructor | Destructor |
|---|---|---|
| Purpose | Initialise object state and acquire resources | Release resources and clean up object state |
| When it runs | Automatically when the object is created | Automatically when the object goes out of scope or is deleted |
| Return type | None (not even void) | None (not even void) |
| Parameters allowed | Yes — can be overloaded with multiple versions | No — exactly one, takes no parameters |
| Can be overloaded | Yes — default, parameterised, copy, move | No — only one destructor per class |
| Can throw exceptions | Yes — throwing prevents the object from existing | Should never throw — wrap risky code in try-catch |
| Inheritance behaviour | Base class constructor runs FIRST | Base class destructor runs LAST (reverse order) |
| Virtual keyword | Never virtual — type isn't fully known yet | Should be virtual in base classes with virtual functions |
Use this table as a quick reference. Remember that constructors can be overloaded, but destructors cannot. Also note that destructors should never throw, while constructors can throw to indicate failure. These two functions are the bookends of an object's lifetime; knowing their differences helps avoid subtle bugs in polymorphic hierarchies.
The `explicit` Keyword — Preventing Silent Implicit Conversions
Single-argument constructors in C++ are implicit conversion points by default. This means the compiler can use them to convert a value of the parameter type to an object of the class type automatically, without any cast. While this is convenient for something like std::string (const char* to std::string), it can lead to subtle bugs when you don't expect the conversion.
For example, a class Account with a constructor Account(int balance) would allow a function void process(const Account&) to be called as process(1000). The integer 1000 is silently converted to an Account. This hides the fact that an object is being created, and can lead to unintended copies or unexpected resource consumption.
The explicit keyword (C++98) disables this implicit conversion. When a constructor is declared explicit, the programmer must use brace or parenthesis syntax explicitly: Account a{1000}. This makes the creation of the object visible in code reviews and prevents accidental conversions.
In modern C++ (C++20), you can use explicit(bool) to conditionally enable implicit conversion based on a template parameter. This is powerful for generic code where you want implicit conversion for some types but not others.
Best practice: Mark every single-argument constructor explicit unless you have a specific reason to allow implicit conversion. For multi-argument constructors, explicit is not relevant because they cannot participate in implicit conversions.
explicit only applies to constructors that can be called with one argument. Multi-argument constructors (e.g., Account(int, std::string)) are never used for implicit conversions, so adding explicit has no effect but is harmless as documentation.explicit by default and only removing it when intentional makes the codebase more predictable and review-friendly. We've seen production bugs where a std::map<int, std::string> keyed by an ID was accidentally used with a different integer type, causing unexpected lookups.explicit on all single-argument constructors unless you explicitly want implicit conversion. It prevents accidental type conversions that hide bugs.Destructors and RAII — The Pattern That Makes C++ Resource-Safe
A destructor is the mirror of a constructor. It runs automatically when an object's lifetime ends — when it goes out of scope on the stack, or when delete is called on a heap-allocated object. It has the same name as the class, prefixed with a tilde (~), takes no parameters, and returns nothing. You can only have one per class.
The reason destructors matter so deeply is a design pattern called RAII — Resource Acquisition Is Initialisation. The idea: tie the lifetime of a resource directly to the lifetime of an object. Acquire the resource in the constructor, release it in the destructor. Because the destructor is guaranteed to run when the object goes out of scope — including when an exception unwinds the stack — you get automatic, exception-safe cleanup for free.
This is how std::fstream, std::unique_ptr, and std::lock_guard work. You don't call manually because the destructor handles it. This eliminates an entire class of bugs. If your class acquires a resource (heap memory, a file, a mutex, a socket), you need a destructor.file.close()
std::terminate() and your program dies instantly. Always wrap potentially failing cleanup inside a try-catch block and swallow or log the error. The destructor's job is cleanup — not error reporting.new/delete inside a class, you're back to manual management.Private Destructors — Controlling Object Lifetime from Within
A destructor can be declared private in C++. This prevents code outside the class (including stack allocation) from destroying objects directly. The only way to destroy such objects is through a friend function or a member function (often a static factory method).
- Heap-only objects: When you want to force all instances to be created on the heap. Stack allocation is automatically prevented because the destructor is private, and the compiler will not let a local variable's implicit destruction happen.
- Reference-counted objects: In some designs, you want to control when the object is actually deleted (e.g., after a reference count reaches zero). By making the destructor private and providing a
method, you ensure deletion only happens through your controlled path.release() - Singleton pattern: The classic Singleton uses a private destructor to prevent deletion from outside. The static instance is destroyed through a friend or a destructor call from a wrapper.
Important: Even with a private destructor, you can still create objects dynamically (via new), but you cannot delete them from outside the class. You must provide a static member function or friend that calls delete or delete[].
Another nuance: If a class has a private destructor and you try to create an automatic (stack) object, the compiler will reject it because the implicit destruction on scope exit is unavailable. Similarly, if you inherit from such a class, the derived class destructor will be unable to call the base destructor, so the base class must be declared final or the derived class must be a friend or use a workaround.
Virtual Destructors: The Silent Resource Leak in Class Hierarchies
When you delete a derived class object through a base class pointer, C++ must call the correct destructor for the complete object. If the base destructor is not virtual, the call is resolved statically — only the base destructor runs. The derived destructor is never called. This is the 'static type vs dynamic type' problem.
The fix is simple: declare the base class destructor as virtual. Once virtual, the destructor call uses the vtable to dispatch to the most derived destructor, which then calls the base destructor automatically in reverse order of construction.
A common misconception is that you only need a virtual destructor if the class has virtual functions. That's wrong. If you ever intend to delete a derived object through a base pointer, regardless of other virtual functions, the base destructor must be virtual. The cost is one vtable pointer per object (usually 8 bytes) — negligible compared to a memory leak in production.
- Each object with virtual functions has a vtable pointer (vptr) pointing to a table of function pointers.
- When you call
deleteon a base pointer, the compiler emits a call through the vtable entry for the destructor. - The vtable entry points to the most derived destructor, which chains to base class destructors.
- Without virtual, the call is resolved at compile time to the base destructor only.
Pure Virtual Destructors — Making Abstract Classes Safe to Delete
A pure virtual destructor is a destructor declared with = 0 in a base class. This makes the class abstract (cannot be instantiated), but unlike other pure virtual functions, a pure virtual destructor must still have a definition. Why? Because the derived class destructor will call the base destructor during destruction — if the base destructor is pure virtual but undefined, the linker will fail with an unresolved external symbol.
The typical pattern is to declare the destructor as pure virtual and then provide an empty implementation out-of-line:
```cpp class AbstractBase { public: virtual ~AbstractBase() = 0; // pure virtual };
// Must provide implementation: AbstractBase::~AbstractBase() {} ```
When would you use a pure virtual destructor? When you want a class to be abstract (uninstantiable) but you don't have any other pure virtual functions to declare. A common example is a base interface class that only has non-virtual utility functions or data members. By making the destructor pure virtual, you force derived classes to be complete, but you still get the guarantee that destructor chaining works correctly.
Important: The derived class destructor is still responsible for its own cleanup, and it will call the base destructor (the implementation you provided) after its own body. This allows the abstract base to have its own resource management if needed.
Copy, Move, and the Rule of Five — When the Compiler Gets It Wrong
If your class manages a raw resource (a raw pointer, a file descriptor), the compiler-generated copy constructor will do a shallow copy. Now two objects think they own the same memory. When both destructors run, you get a double-free crash. This is one of the most common sources of undefined behaviour in C++.
The Rule of Five states: if you define any one of — destructor, copy constructor, copy assignment, move constructor, or move assignment — you almost certainly need to define all five. They are a package deal because they all relate to resource ownership semantics.
The move constructor and move assignment operator (C++11) are the performance piece. Instead of deep-copying an expensive resource from a temporary object, you 'steal' the resource and leave the temporary in a null state. It's the difference between duplicating a 1GB file and simply renaming it.
std::unique_ptr and std::string) that manage their own resources, you don't need to write any of the five special functions. The compiler defaults will be perfectly safe. Always aim for Rule of Zero first.= default and smart pointers over manual resource management.The Rule of Zero — When You Shouldn't Write Any Special Functions
The Rule of Zero, popularized by Scott Meyers, states that classes should avoid defining any of the special member functions (destructor, copy/move constructor/assignment) if they don't manage resources directly. Instead, use resource-management classes like std::unique_ptr, std::shared_ptr, std::string, std::vector, etc. The compiler-generated special functions will then be correct and efficient. This is the ideal: let the types that own resources handle the bookkeeping, and your class stays simple.
Contrast this with the Rule of Five. The Rule of Five exists because sometimes you must manage a novel resource (e.g., a file descriptor, a custom memory pool). But in modern C++, you can almost always wrap that resource inside a library class. For example, instead of a raw FILE, use std::fstream. Instead of a raw char, use std::string. Instead of a raw allocation of int*, use std::vector<int>.
When you use these library types, the compiler-generated destructor, copy constructor, move constructor, copy assignment, and move assignment are all automatically correct. They will call the respective operations of each member, which in turn manage their own resources. You get exception safety, deep copy when needed, and move semantics when possible — all for free.
The Rule of Zero is not just about convenience; it dramatically reduces the surface for bugs. In production codebases, most memory leaks and double-frees occur in classes that manually manage resources. By following the Rule of Zero, you eliminate those bugs entirely.
In modern C++, aim for Rule of Zero as your default. Write a custom special member function only when you have a class that manages a resource not already wrapped by the standard library. And when you do, write all five (or delete the ones you don't want).
Constructor Delegation and Member Initializer Lists — Production Patterns
Constructor delegation (C++11) lets one constructor call another constructor of the same class. This reduces duplication of initialisation logic. The delegate call must appear in the member initializer list, not in the body. If you put it in the body, you're not delegating — you're constructing a temporary.
Member initializer lists are the preferred way to initialise member variables because they construct members directly. Without them, members are default-constructed and then assigned, which is wasteful for types like std::string, std::vector, or any user-defined type with non-trivial constructors. For const and reference members, the initializer list is mandatory because they cannot be assigned after default construction.
In production, use delegation when you have multiple constructors that all share a common setup — like initializing a logger, establishing a database connection, or validating invariants. The delegating constructor runs after the delegated constructor completes, so any code in its body can assume the object is fully set up.
init method.Putting It All Together: RAII in Action with Dockerized Testing
To validate that your resource management patterns are robust, deploy a stress test inside a Docker container. This isolates the test from your host environment and allows monitoring tools to catch memory leaks early. The following Dockerfile builds a performance test that creates and destroys thousands of RAII objects, checking for resource exhaustion.
A common trap: even with perfect RAII, if you use std::make_unique in a constructor body instead of the initializer list, you risk a memory leak if the constructor throws after allocation. Always use the initializer list for members. The container build also demonstrates multi-stage builds — keeping the final image small while maintaining reproducibility.
-fsanitize=address flag is your safety net during development. Remove -fsanitize=address and -g for the release build to avoid performance overhead.Practice Exercises — Apply Constructor/Destructor Patterns
Test your understanding with these real-world inspired exercises. Each reinforces one key concept from this article. Attempt them before looking at the hints or solutions.
1. RAII File Manager Write a class FileManager that opens a file in its constructor (using std::fstream) and closes it in the destructor. Ensure that the class cannot be copied (delete copy operations). In , demonstrate that the file is properly closed when the object goes out of scope. Use main()std::ofstream::is_open() to verify. Concept: RAII, implicit destructor call, copy prevention.
2. Smart Pointer Clone Design a base class Clonable with a virtual destructor and a pure virtual method returning clone()std::unique_ptr<Clonable>. Implement two derived classes. Show that you can copy polymorphic objects by calling through a base pointer, and that deleting the original does not affect the clone. Concept: virtual destructor, virtual clone idiom for polymorphic copying.clone()
3. Virtual Destructor Hierarchy Create a base class Transport with a non-virtual destructor. Derive Car and Boat. In , delete a main()Boat object through a Transport* and observe that the derived destructor is not called (add print statements). Then make the base destructor virtual and see the correct behaviour. Concept: static vs dynamic type, virtual destructor necessity.
4. Private Destructor Singleton Implement a singleton class Logger with a private destructor. Provide a static method that returns a reference to the single instance. Ensure that the singleton cannot be destroyed from outside. (Note: In production, use a static local variable or instance()std::shared_ptr — this exercise focuses on destructor access control.) Concept: private destructor, static factory, lifetime control.
5. Rule of Zero Refactor Given a class LegacyBuffer that manages a raw char* and has a destructor, copy constructor, etc., refactor it to use std::string and/or std::unique_ptr, eliminating all custom special member functions. Show that the new class is correct and simpler. Concept: Rule of Zero, resource ownership transfer, eliminating boilerplate.
virtual.When Is the Destructor Called? — Not When You Think
Every C++ dev knows destructors fire on scope exit. The part they forget: order of destruction is reverse order of construction — including base classes and member objects. That's not a trivia fact; it's the difference between a clean shutdown and a segfault.
Local variables unwind in reverse declaration order. Class members are destroyed in reverse of their declaration order (not initialization order, declaration order). Base class destructors run after derived destructors complete. If you allocate a resource in a member declared second but use it in a member declared first, you've just written a use-after-free bug waiting to happen.
Dynamic objects are trickier. You can destroy them early with delete, but only if you own the pointer. For smart pointers, destruction happens when the last shared_ptr goes out of scope or the unique_ptr goes null — which can be anywhere in your codebase. That's why RAII works: destruction timing is deterministic and tied to the scope you control.
Explicit Destructor Calls — When You Need Manual Cleanup
You almost never call a destructor manually. The compiler handles it. But placement new and memory pools break that rule. When you allocate raw memory and construct an object into it with placement new, the compiler doesn't know the object exists — so it won't call the destructor when the memory goes out of scope. You must call it explicitly.
The syntax is ugly on purpose: object.~ClassName(). That's a direct invocation, no pointer needed. For dynamic arrays in custom allocators, you iterate and call destructors on each element manually before deallocating the raw memory. Miss one, and you leak resources. Call it twice, and you get undefined behavior — usually a corrupt heap or crash.
Another case: objects stored in unions. C++11 and later allow non-trivial types in unions, but you're responsible for manually calling the destructor before changing the active member. The compiler won't do it for you. This is rare but deadly when it bites you in parsing or protocol code.
The Virtual Destructor That Wasn't: A $500k Debugging Session
Connection had a non-virtual destructor. When delete was called on a Connection* pointing to a TcpConnection, only ~Connection() ran. ~TcpConnection() never executed, leaving the socket descriptor open.virtual ~Connection() = default; to the base class. This ensured that deleting through a base pointer triggered the full polymorphic destructor chain. The socket leak stopped immediately.- If a class has any virtual function, its destructor must be virtual.
- Always mark base class destructors virtual unless you have an explicit reason not to.
- Use smart pointers (unique_ptr with custom deleter) to avoid manual delete entirely.
lsof -p <pid> to list open handles. If handles belong to your class, suspect missing virtual destructor.valgrind --leak-check=full --show-leak-kinds=all. Look for 'definitely lost' blocks tied to derived classes.-fsanitize=address -fno-omit-frame-pointer. ASan will print the stack trace at the point of the bug — often a missing virtual dtor.unique_ptr<Base> calls delete through the pointer type — virtual dispatch only works if the destructor is virtual.valgrind --leak-check=full --show-leak-kinds=all ./your_program 2>&1 | grep 'definitely lost'heaptrack ./your_program && heaptrack_print heaptrack_output.gz | head -30virtual ~Base() = default; if missing. Otherwise, check that all member pointers are managed (unique_ptr, not raw).Key takeaways
explicit to prevent unintended implicit conversions.Common mistakes to avoid
4 patternsForgetting virtual destructor in base class
virtual ~Base() = default; or make it pure virtual with definition. If no other virtual functions, consider final class.Allowing destructor to throw exceptions
std::terminate is called and the process aborts without cleanup.Missing copy/move operations when managing raw resources
Using explicit conversion constructors unintentionally
explicit unless implicit conversion is intentional. Review all constructors that can be called with one argument.Interview Questions on This Topic
What is the Rule of Five and why does it exist?
Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
That's C++ Basics. Mark it forged?
14 min read · try the examples if you haven't