C++ / MEMORY OWNERSHIP AND SMART POINTERS
Dangling pointers and use-after-free symptoms
Recognise how pointers start dangling, why use-after-free often prints the right answer anyway, and how to make sanitizers surface the real free site.
What you will learn
- Explain why delete leaves your pointer variable non-null and unchanged
- Spot dangling sources: delete, scope exit, vector growth, dead temporaries
- Argue why a correct-looking value read after a free is still undefined behaviour
- Read an AddressSanitizer heap-use-after-free report and find the early free
Understanding Dangling pointers and use-after-free symptoms
A pointer dangles when the object it names has stopped existing while the pointer keeps the old address. `delete p` does two things, run the destructor and hand the storage back to the allocator, and neither of them touches the variable `p`, because the language has no way to find every pointer that held that address. So the address keeps looking like an address: non-null, aligned, inside the heap range, printing exactly as before. Objects also end their lives without any `delete`: an automatic object at the closing brace, a temporary at the semicolon, a vector element when the buffer grows, and each of those leaves any saved pointer, reference, iterator or string_view aimed at storage that is no longer yours.
Reading or writing through such a pointer is undefined behaviour, and the usual symptom is no symptom: `operator delete` typically just links the block into a free list, so the old bytes sit there untouched until another allocation claims them, and your test prints the expected value. Real symptoms show up once something else takes that block: a field quietly holds another object's data, a stored length becomes enormous, a vtable pointer sends a virtual call somewhere absurd, an unrelated `free()` aborts with a corruption message, or the program only misbehaves in the release build. The mental model is that a freed block is shared property again, so the distance between the bug and the crash is decided by whoever allocates next, not by your code.
Because the symptoms move around, do not diagnose a use-after-free from program output; make the runtime refuse to hide it. AddressSanitizer (`-fsanitize=address -g`) and Valgrind's memcheck quarantine freed blocks instead of recycling them, so the first bad access aborts with three stacks: where you read, where the block was freed, and where it was allocated, and that trio names the owner that died too early. Structurally the fix is never an assignment of nullptr after `delete`; it is arranging one owner whose lifetime provably covers every observer, and refusing to hand out pointers, references or views to objects that die at the end of an expression.
<iostream>
struct Session {
int id;
explicit Session(int i) : id(i) { std::cout << "open " << id << '\n'; }
~Session() { std::cout << "close " << id << '\n'; }
void send(const char* msg) const {
std::cout << "session " << id << " sends " << msg << '\n';
}
};
void broadcast(Session* s) {
if (!s) { std::cout << "no session\n"; return; }
s->send("hi");
}
int main() {
Session* s = new Session(7);
Session* alias = s; // a second raw pointer, not a second owner
broadcast(s);
delete s; // destructor runs, storage returns to the allocator
std::cout << "alias survived the delete: " << (alias != nullptr) << '\n';
// broadcast(alias); // use-after-free: the null guard passes, the object is gone
s = nullptr; // clearing s says nothing about alias
alias = nullptr; // every alias has to be cleared by hand
broadcast(alias);
}
Freeing an object changes the allocator's bookkeeping and never your pointer variables, so a dangling pointer keeps a plausible address and the damage surfaces wherever that memory gets reused.
Worked examples
Vector growth frees the buffer under your pointer
A cached element address dangles after a reallocation, without any delete in sight.
<cstdint>
<iostream>
<vector>
int main() {
std::vector<int> temps{18, 19, 20};
int* hot = &temps[2];
std::cout << "hot reads " << *hot << '\n';
std::uintptr_t before = reinterpret_cast<std::uintptr_t>(temps.data());
temps.reserve(temps.capacity() + 1); // more than capacity, so a new buffer is required
std::uintptr_t after = reinterpret_cast<std::uintptr_t>(temps.data());
std::cout << "buffer relocated: " << (before != after) << '\n';
hot = &temps[2]; // re-take the address after the move
std::cout << "refreshed hot reads " << *hot << '\n';
}
Example explained
Line 1`&temps[2]` is a raw address inside the vector's current heap buffer, not a handle to the vector.
Line 2`reserve(capacity() + 1)` asks for more than the current capacity, so the vector allocates a new buffer, moves the ints, and frees the old one.
Line 3The new buffer is allocated while the old one is still alive, so the two addresses must differ; the printed 1 proves the block `hot` pointed into has been freed.
Line 4Re-taking `&temps[2]` is the whole fix: never cache element addresses across an operation that can reallocate.
A reference into a temporary that already died
Destructor prints show that the temporary is gone before the next statement runs.
<iostream>
<string>
struct Config {
std::string name;
explicit Config(std::string n) : name(std::move(n)) {
std::cout << "build " << name << '\n';
}
~Config() { std::cout << "destroy " << name << '\n'; }
const std::string& id() const { return name; }
};
Config load(const char* n) { return Config(n); }
int main() {
std::cout << "statement begins\n";
const std::string& borrowed = load("temp").id();
std::cout << "statement over, borrowed dangles\n";
// std::cout << borrowed; // use-after-free on a destroyed std::string
Config kept = load("kept");
const std::string& safe = kept.id();
std::cout << "safe reads " << safe << '\n';
}
Example explained
Line 1`load("temp")` produces a temporary Config, and `id()` returns a reference to its `name` member.
Line 2Lifetime extension does not apply, because it only covers a reference bound directly to a temporary, not one handed back through a function; `destroy temp` therefore prints at that semicolon.
Line 3`borrowed` now names a destroyed std::string, so printing it would read the character buffer that string's destructor already freed.
Line 4The `kept` version keeps the owner in scope, which is why `destroy kept` appears after the last use of `safe`.
A view whose size() still lies convincingly
Shows how a dangling string_view reports a sane length while its characters are gone.
<iostream>
<string>
<string_view>
std::string_view first_word(const std::string& s) {
return std::string_view(s).substr(0, s.find(' '));
}
int main() {
std::string_view bad = first_word(std::string("hello dangling world"));
std::cout << "bad.size() still says " << bad.size() << '\n';
// std::cout << bad; // this is the read that touches freed characters
std::string owner = "hello dangling world";
std::string_view safe = first_word(owner);
std::cout << "safe view says " << safe << '\n';
}
Example explained
Line 1The temporary string is 20 characters, well past any small-string buffer, so its characters really are heap allocated and really are freed at that semicolon.
Line 2`bad.size()` returns a length stored inside the view object itself, so it prints 5 with the text already gone; the missing symptom is the symptom.
Line 3`std::cout << bad` would copy from the freed buffer, which is the actual use-after-free the harmless-looking size() call masks.
Line 4With a named `owner`, the character buffer lives to the end of the scope, so `safe` stays valid for the print.
Important notes
Dereferencing is the obvious offence, but merely using the value of a freed pointer, even comparing or printing it, is already outside what the standard guarantees, which is why `if (p)` can never be a dangle test.
Debug allocators stamp freed memory: MSVC's debug heap fills it with 0xDD and the Windows debug heap with 0xFEEEFEEE, so a field full of those repeating bytes points at use-after-free rather than at uninitialised data.
Common mistakes
Treating `p = nullptr;` after `delete p;` as a fix: the copy of that address stored in a member, vector, or capture is untouched, so the crash simply moves somewhere harder to find.
Declaring the code correct because the deleted object still printed the right value; those bytes only survive until the block is reused, so the bug passes tests and later corrupts whatever allocation lands there.
Caching `&v[0]`, `v.data()`, or an iterator across a `push_back` or `erase`; the container frees the old buffer on your behalf, producing a use-after-free in code that never says `delete`.
Try it yourself
Change, predict, then run
Write a struct whose destructor prints its name, allocate one with `new`, keep two raw pointers to it, `delete` through the first, and print whether the second still tests non-null. Then move the `delete` after the last use and confirm the destructor line is now the final thing printed.
Open the C++ workspaceCheck your understanding
A program dereferences a pointer after the object was deleted and prints the correct value on every run of your debug build. What is the most accurate conclusion?
- Nothing is wrong: the pointer holds a real address and the data has proven to be intact.
- The read is undefined behaviour; the freed block simply has not been reused yet, and a different allocator, optimisation level, or nearby allocation can change what appears.
- The compiler must have kept the object alive because the pointer was still in scope and being used.
- It is safe because `delete` only runs the destructor and leaves the storage owned by your program.
Show answer
The value looks right because `operator delete` usually just links the block into a free list, leaving the old bytes in place until something else claims them, so a passing run says nothing about correctness. Option 3 is the tempting one: `delete` does run the destructor first, but it then returns the storage, and even if those bytes were never overwritten the object's lifetime has ended, so the read is undefined either way.