C++ Part I · Module 10 · Dynamic Memory and Object Lifetime
Why Manual delete Does Not Survive Real Control Flow
Every bug in this module so far has been a line that was missing, a line that ran twice, or a line with the wrong brackets. All of them are fixable by writing the right thing in the right place, which makes them feel like problems of attention — the sort a sufficiently careful engineer simply does not have. This chapter removes that comfort, and it is the reason the module exists. It starts from a function that is correct: the allocation and the release are both present, properly paired, and exactly where any reviewer would expect to find them. Then it does to that function what time does to all code. Somebody discovers a condition that means the work should not proceed, so an early return appears. Somebody adds validation that throws when the input is malformed. Somebody needs a second buffer, and acquiring it can fail. None of those three changes touches the release statement, none of them is unreasonable, and each of them is the sort of edit made in a two-line diff and approved in a minute. After all three, the delete is still sitting there in the source, perfectly visible, and it is no longer reached. The leak count is measured, the compiler's contribution is measured, and then the same three shapes are measured again with one structural change that takes the count to zero.
Advanced18 min readC++Part IFundamentalsMemory
Part I · Module 10 · Chapter 10.8 · Why Manual delete Does Not Survive Real Control Flow
1. The Function That Is Correct
static bool process_v1(int n)
{
int* buf = new int[n];
buf[0] = n;
delete[] buf;
return true;
}There is nothing wrong with this. One new[], one matching delete[], no early exits, nothing between them that can fail. Measured: it does not leak.
That matters, and the chapter depends on it. The pair is not defective. What follows is not a story about a bad programmer; it is a story about what happens to this function over the following six months.
2. The Three Additions
Chapter 10.8's frozen purpose names exactly three, and they are the three that actually occur:
An early return — a condition is discovered under which the work should not proceed.
A thrown exception — validation is added, or a function called in between starts throwing.
A second allocation — more state is needed, and acquiring it can itself fail.
Each is applied below to the function above, in a diff a reviewer would approve.
3. Addition One: The Early Return
static bool process_v2(int n, bool bail)
{
int* buf = new int[n];
buf[0] = n;
if (bail) return false; // added later
delete[] buf;
return true;
}The delete[] is still there. It is three lines from the new[], in the same function, in plain sight. On the bail path it does not execute.
This is the leak Chapter 10.6 §2 called "the early exit" and promised to this chapter. Note what makes it hard: the author of the if was not thinking about memory. They were thinking about the condition. The allocation is above their change and the release is below it, and nothing about the edit draws attention to either.
4. Addition Two: The Throw
static bool process_v3(int n, bool explode)
{
int* buf = new int[n];
buf[0] = n;
if (explode) throw std::runtime_error("validation failed");
delete[] buf;
return true;
}Worse than the early return in one specific way: the throw need not be in this function at all.
int* buf = new int[n];
validate(buf, n); // if THIS throws, the delete below is skipped
delete[] buf;Nothing in that listing is visibly dangerous. To know whether it leaks you must know whether validate can throw, which means reading validate, and everything validate calls, transitively. Chapter 10.3's callout said the correctness of a delete is a whole-program property; this is the sharpest possible form of it — the leak is caused by code that is not on the screen.
(This chapter borrows throw, try and catch from Chapter 22.2. There is no way to make the module's central argument without them, which is why the borrowing is worth it here when it was merely convenient in Chapter 10.3 §6.)
5. Addition Three: The Second Resource
static bool process_v4(int n, bool explode)
{
int* a = new int[n];
int* b = nullptr;
if (explode) throw std::runtime_error("second allocation failed");
b = new int[n];
delete[] b;
delete[] a;
return true;
}Two resources, and now the number of paths needing correct cleanup multiplies. If the second acquisition fails, a must be released and b must not. If a later step fails, both must be released, in the right order. With three resources there are more combinations again, and each must be handled at every exit.
This is where the manual approach stops scaling even in principle. One resource and two exits is four situations to get right; the arithmetic gets away from you quickly, and the code that results is the deeply nested cleanup ladder — or, in C, the chain of goto cleanup labels — that this pattern is notorious for.
6. The Measurement
All four versions in one program. First, the compiler's contribution:
compiler warnings about any of these leaks: 0Zero. Not a hint, for any of the three. Then the run:
v1 (nothing goes wrong):
v2 (early return):
v3 (throws):
caught: validation failed
v4 (throws before the 2nd):
caught: second allocation failedEvery version returned or threw exactly as designed. The program's observable behaviour is correct. And then:
Process 73543: 3 leaks for 96 total leaked bytes.
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in process_v2(int, bool)>':
3 controlflow process_v2(int, bool) + 48 controlflow.cpp:22
1 (32 bytes) ROOT LEAK
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in process_v3(int, bool)>':
3 controlflow process_v3(int, bool) + 48 controlflow.cpp:32
1 (32 bytes) ROOT LEAK
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in process_v4(int, bool)>':
3 controlflow process_v4(int, bool) + 48 controlflow.cpp:42
1 (32 bytes) ROOT LEAKThree leaks, 96 bytes, one from each of v2, v3 and v4 — and none from v1.
Three observations:
v1 did not leak. The original pair was correct and remained correct. The leaks were introduced by changes that did not touch it.
The tool names the function and the line — controlflow.cpp:22, :32, :42 — which is Chapter 10.6 §5's method doing its job.
But it only found them because the program took those paths. main deliberately called each version with the failing argument. Had it called process_v2(4, false), the leak would be in the code and absent from the report. That is Chapter 10.6's callout as a measurement: a leak checker reports what this run leaked, not what the code can leak.
7. The Fix Is Not More Care
Here is the same three shapes with one structural change. The release moves into the destructor of an object with automatic storage duration:
class Buffer {
public:
explicit Buffer(int n) : data_(new int[n]) {}
~Buffer() { delete[] data_; }
Buffer(const Buffer&) = delete; // Module 13's subject —
Buffer& operator=(const Buffer&) = delete; // mandatory, not optional
int* get() { return data_; }
private:
int* data_;
};
static bool process_v2(int n, bool bail)
{
Buffer buf(n);
buf.get()[0] = n;
if (bail) return false; // destructor runs anyway
return true;
}There is no delete in process_v2 at all. And the measurement:
Process 73584: 0 leaks for 0 total leaked bytes.3 leaks and 96 bytes became 0 leaks and 0 bytes, and not one of the three control-flow shapes was changed.
The early return still returns early. The throws still throw. What changed is who is responsible for the release: an automatic object, whose lifetime Chapter 10.5 §5 established is ended by the compiler on every path.
(The two = delete lines are not decoration. A copy of this class would give two objects one pointer and two destructors — Chapter 10.7 §2's copied handle, which is a double delete. Module 13 is where that is properly addressed; deleting the operations is the correct placeholder until then.)
8. Why It Works During an Exception
The early-return case is easy to believe. The exception case deserves its own measurement, because it is the one that looks like magic:
#include <cstdio>
#include <stdexcept>
struct Guard {
const char* name;
explicit Guard(const char* n) : name(n) { std::printf(" + %s acquired\n", name); }
~Guard() { std::printf(" - %s released\n", name); }
};
static void inner()
{
Guard b("b");
std::printf(" throwing now\n");
throw std::runtime_error("boom");
}
static void outer()
{
Guard a("a");
inner();
std::printf(" (never reached)\n");
}
int main()
{
try { outer(); }
catch (const std::exception& e) { std::printf(" caught: %s\n", e.what()); }
return 0;
} + a acquired
+ b acquired
throwing now
- b released
- a released
caught: boomBoth destructors ran, innermost first, before the catch body. As the exception propagates outward, every automatic object in every abandoned scope is destroyed, in reverse order of construction — Chapter 10.5 §5's rule holding on a path nobody wrote.
That is the guarantee the fix rests on, and it is why "put the release in a destructor" is a different kind of answer from "remember to release". It is not a convention that a careful author follows; it is something the language does whether anyone was thinking about it or not.
9. What to Actually Do
Do not write new followed by a later delete in the same function. Measured: three ordinary edits defeat it.
Put every release in a destructor so the compiler runs it on every path.
Use a standard type rather than your own guard — std::vector, std::string, std::unique_ptr.
Treat an early return added above a delete as a review finding. It is the commonest shape of this bug.
Assume any function you call can throw unless it is marked noexcept. Chapter 10.3 §6's new itself throws.
Count your resources, not your lines. Two resources and two failure points is already more combinations than you want to verify by eye.
Remember a leak checker tests the path it took. v2's leak was found because the test passed true.
And if you must write a guard class, settle its copy behaviour immediately — Chapter 10.7 §2's copied handle is a double delete, which is worse than the leak you were fixing.
10. Mistakes Worth Making Once
Adding an early return above a delete. Measured: 32 bytes, zero warnings.
Assuming a visible delete is a reached delete. All three leaks had one in plain sight.
Assuming a function does not throw. new does; so does anything calling it.
Concluding from a passing leak check that code cannot leak. It reports the path taken.
Managing two resources with manual releases at every exit. The combinations multiply.
Writing a guard class and ignoring copying. A leak becomes a double delete — exit 133.
Believing the problem is insufficient care. The careful version is the one that leaked.
Thinking catch can clean up for you. By the time it runs, the scopes are already gone — §8's trace shows the destructors firing before the catch.
11. Interview Relevance
"Why is RAII preferred over manual new/delete?" The answer that lands is this chapter's measurement rather than the principle: a correct pair leaks as soon as an early return or a throw is added, with no compiler warning, and the destructor version does not — because destructors run on every path, including exception unwinding. Offering the concrete 3 leaks / 96 bytes / 0 warnings → 0 leaks is far stronger than reciting the acronym.
"What happens to local objects when an exception propagates?" They are destroyed, innermost scope first, in reverse order of construction, before the handler runs. §8's trace is exactly this, and it is the mechanism the whole idea depends on.
"Where would you look for leaks in a code review?" Early returns and throws between an acquisition and its release — and functions called in between that might throw, since the cause can be off-screen.
"Is a leak checker sufficient?" No: it reports what the run leaked. An error-path leak needs a test that takes the error path, and error paths are the least-tested code.
"What is wrong with writing your own RAII wrapper?" Nothing, provided you settle copy and move semantics — otherwise you have exchanged a leak for a double delete. This is a good signal of whether someone has actually done it.
12. What You Should Be Able to Say Now
- The original pair was correct and did not leak — v1 is the control.
- Three ordinary additions break it: an early return, a throw, a second resource.
- Measured: 3 leaks, 96 bytes, 32 bytes each, named by function and line.
- Measured: 0 compiler warnings for all three.
- The program behaved correctly in every observable way.
- The
deletewas visible in the source in all three cases — visible is not reached. - A throw need not be in your function, so the leak's cause can be off-screen entirely.
- Two resources multiply the paths needing correct cleanup.
- Measured: the destructor version leaked 0 bytes, with the control flow unchanged.
- Measured: destructors run during unwinding, innermost first, before the
catch. - The difference is where the knowledge lives — a statement versus a type.
- The leak checker found these because the test took those paths.
- A hand-written guard must settle copy semantics, or a leak becomes Chapter 10.7's double delete.
- Prefer
std::vector,std::string,std::unique_ptr— the same mechanism, already correct.
13. Exercises
Exercise 1 — find the path. For process_v2, state the exact condition under which it leaks and the exact condition under which it does not.
Exercise 2 — off-screen cause. Given int* b = new int[n]; validate(b, n); delete[] b; say what you must read to know whether it leaks, and why no warning is possible.
Exercise 3 — count the paths. A function acquires three resources, each acquisition able to fail. Count the cleanup situations, and say what must be released in each.
Exercise 4 — convert it. Rewrite process_v4 using the Buffer class and state how many delete[] statements remain in the function.
Exercise 5 — the trace. Predict the exact output of unwind.cpp if outer declared a second Guard c("c") after calling inner().
Exercise 6 — the checker's blind spot. Explain why main calling process_v2(4, false) would produce a clean leak report on leaking code, and what that implies for test design.
Exercise 7 — the trade. A colleague's guard class is copyable and copies the pointer. Say which bug they have created, what it does at runtime per Chapter 10.7, and the minimal fix available before Module 13.
14. Summary
- The starting function was correct and did not leak.
- Three ordinary edits — early return, throw, second resource — produced 3 leaks, 96 bytes, and 0 compiler warnings.
- The
deleteremained visible in the source; visible is not reached. - A throw can come from a function you are not looking at, so the cause may be off-screen.
- Multiple resources multiply the cleanup combinations beyond what review can verify.
- Measured: moving the release into an automatic object's destructor gave 0 leaks, with the control flow untouched.
- Measured: destructors run during exception propagation, innermost first, before the
catch. - The difference is where the knowledge lives — a statement in a function, or a property of a type.
- A leak checker reports the path it took, so error-path leaks need error-path tests.
- Use a standard owning type; a hand-rolled one must settle copy semantics or it trades a leak for a double delete.
The fix in §7 works and the chapter has deliberately not named it. That is because the thing that made it work is not the destructor — a destructor is only the mechanism — but a decision that was made implicitly when Buffer was written: that this object, and nothing else, is responsible for that array, and that the array's lifetime is exactly this object's. Once stated plainly, that decision turns out to be the thing every bug in this module has been about. The forgotten release, the release that ran twice, the pointer used after its object ended, the leak on the error path — each is a case where the answer to one short question was unclear, unwritten, or assumed differently by two pieces of code. The final chapter of the module asks that question properly, shows that new and delete are structurally incapable of answering it, and states the answer the rest of this curriculum is built on.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
