C++ Part I · Module 8 · Pointers and References
Dangling Pointers and Dangling References
Four chapters in this module deferred this one by name. Chapter 8.1's callout said the genuinely hard thing about a pointer is that its value refers to something whose existence it does not control. Chapter 8.3 drew a table of two failure modes and took only the testable one. Chapter 8.5 admitted that a reference cannot be null and can still dangle, and Chapter 8.6 refused to call references safe for precisely that reason. This is the chapter all of them were pointing at, and it needs no new model — Chapter 7.2 already built it, measured both ways in which a name and an object can come apart, and derived the returned-reference bug as a corollary rather than a rule. What is left is an enumeration and a property. The enumeration is the ways it actually happens in code, and there are four worth knowing rather than one. The property is the thing that makes this bug class different from everything else in Part I: the storage usually still exists and usually still holds the old value, so the program works — through testing, through review, and for some number of releases — and then stops working somewhere else entirely. The chapter closes on the tools, and the measurement there is the useful one: the compiler and the sanitizer each catch what the other misses.
Intermediate18 min readC++Part IFundamentalsPointers
Part I · Module 8 · Chapter 8.8 · Dangling Pointers and Dangling References
1. The Chapter Four Others Deferred
| Chapter | What it said | What it deferred |
|---|---|---|
| 8.1 §6 callout | a pointer's value refers to something whose existence it does not control | how that goes wrong |
| 8.3 §1 | two failure modes; null is testable | the one that is not |
| 8.5 §5 | a reference cannot be null and can dangle | the dangling |
| 8.6 §6 | so references are not "safe" | which bug remains |
All four were pointing here. And the model needed to understand it is already built.
2. The Model, Inherited Whole
Chapter 7.2 established that scope and lifetime are independent, measured both off-diagonal cases, and drew a 2×2. One cell of that grid is this chapter:
A dangling pointer or reference is a valid name for an object whose lifetime has ended.
Chapter 7.2 §5's bottom-left quadrant — name in scope, object not alive — is the only one of the four where you can write a name and get something wrong. Chapter 7.2 §6 derived the returned-reference case from it and said the enumeration would come later. This is later.
Nothing new is required. What changes in Module 8 is that a program can now hold such a name: store it, copy it, pass it to a function, keep it in a structure. Chapter 7.2's bug was confined to one return statement; with pointers it can travel.
3. Case One: A Returned Local
static int& largest(int a, int b)
{
int m = a > b ? a : b;
return m; // m stops existing here
}warning: reference to stack memory associated with local variable 'm' returned [-Wreturn-stack-address]Chapter 6.3 §6 measured this and Chapter 7.2 §6 explained it. It is the easy case in two senses: the compiler sees it, and the mistake is local — the cause and the bug are on the same line.
It is a warning, not an error. The program builds and runs.
Under AddressSanitizer, something worth knowing happened: the program printed 8 and exited 0. ASan did not flag it. That is an observation about one build, not a rule — Chapter 5.4 §5's discipline — and §8 makes it part of a pattern rather than a curiosity.
4. Case Two: Use After Scope
#include <iostream>
int main()
{
int* p = nullptr;
{ int local = 11; p = &local; } // local dies at the brace
std::cout << *p << '\n'; // use-after-scope
return 0;
}This is Case One's sibling and it is strictly worse, because the pointer escaped the block by assignment rather than by a return the compiler was already inspecting.
$ c++ -std=c++17 -Wall -Wextra -Wpedantic uas.cpp -o uas
$ ← zero warningsNothing. The compiler that caught the returned reference says nothing about this.
With the sanitizer:
==45599==ERROR: AddressSanitizer: stack-use-after-scope on address 0x00016bd5e7e0
SUMMARY: AddressSanitizer: stack-use-after-scope uas.cpp:6 in mainCaught, with the line number.
This is the pair that makes §8's table worth having: two nearly identical bugs, and the two tools disagree about which one they can see.
5. Case Three: A Reference Into a Resized Container
The frozen purpose names this one, and it is the case that matters most in real code. It needs a container, which is Module 11's subject — borrowed here the way Chapter 5.6 §2 borrowed arrays and Chapter 6.2 §5 borrowed a struct.
The mechanism can be shown without executing undefined behaviour, by watching where the container keeps its elements:
#include <iostream>
#include <vector> // Chapter 11's subject, borrowed
int main()
{
std::vector<int> samples{1, 2, 3, 4};
std::cout << "capacity " << samples.capacity()
<< " data at " << static_cast<const void*>(samples.data()) << '\n';
for (int i = 0; i < 6; ++i) {
samples.push_back(i);
std::cout << " after push_back: size " << samples.size()
<< " capacity " << samples.capacity()
<< " data at " << static_cast<const void*>(samples.data()) << '\n';
}
return 0;
}Measured output, zero warnings:
capacity 4 data at 0x104f2da50
after push_back: size 5 capacity 8 data at 0x104f2d920
after push_back: size 6 capacity 8 data at 0x104f2d920
after push_back: size 7 capacity 8 data at 0x104f2d920
after push_back: size 8 capacity 8 data at 0x104f2d920
after push_back: size 9 capacity 16 data at 0x104f2d940
after push_back: size 10 capacity 16 data at 0x104f2d940The storage moved at exactly two points. From 0x…da50 to 0x…d920 when capacity went 4 → 8, and from there to 0x…d940 when it went 8 → 16. In between — three insertions — the address did not change at all.
So a pointer or reference taken into that container:
- is valid before the first insertion,
- becomes stale at the first reallocation,
- would have been fine for the three insertions after it,
- becomes stale again at the second reallocation.
Nothing announces either transition. A program holding &samples[0] across insertions is correct or broken depending on a capacity it did not check.
The undefined version, run only under the sanitizer so that its output is a diagnostic rather than a claim:
int* p = &v[0]; // valid now
v.push_back(5); // reallocates -- p is now stale
std::cout << *p << '\n'; // use-after-free==45289==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000d0
READ of size 4 at 0x6020000000d0 thread T0
#0 0x000104becaf0 in main uaf.cpp:8At the standing flags: zero warnings.
6. Case Four: A Captured Temporary — and the Case That Is Not a Bug
The purpose's third named case needs care, because the obvious version of it is not a bug and I assumed it was until I measured it.
Not a bug:
const int& r = twice(4); // binds directly to a temporaryA const reference bound directly to a temporary extends that temporary's lifetime to match the reference. Chapter 6.5 §5 measured a const int& parameter binding to the literal 42 and concluded it was safe to read during the call; this is the same rule in a declaration. Measured:
r = 8
still readable later: 8Zero warnings, and zero sanitizer errors — the extension is real, not merely unflagged.
A bug:
#include <iostream>
struct Holder { int v; };
static Holder make() { return Holder{7}; }
static const int& pick(const Holder& h) { return h.v; } // returns a ref INTO the argument
int main()
{
const int& r = pick(make()); // the temporary Holder dies at the end of this statement
std::cout << r << '\n'; // r refers to a destroyed object
return 0;
}The difference is the function in the middle. r is not bound directly to the temporary; it is bound to something inside it, via a function that returned a reference to its own parameter. Lifetime extension does not reach through a function call, so the Holder dies at the end of the full expression and r is left referring into it.
$ c++ -std=c++17 -Wall -Wextra -Wpedantic temp2.cpp -o temp2
$ ← zero warnings
$ c++ -std=c++17 -Wall -Wextra -Wpedantic -Wdangling -Wreturn-stack-address temp2.cpp -o temp2
$ ← still zero warningsAdding the flags that exist for this does not help. Under the sanitizer:
==45678==ERROR: AddressSanitizer: stack-use-after-scope
SUMMARY: AddressSanitizer: stack-use-after-scope temp2.cpp:8 in mainLifetime extension applies to a temporary bound directly to a reference, and does not propagate through a function that returns a reference into it. That one sentence separates the safe form from the bug, and the two look almost identical.
7. Why the Symptom Appears Far From the Cause
The frozen purpose asks for this property specifically, and §5 measured the mechanism.
The storage still exists. Chapter 7.2 §4 measured two different locals occupying the same address because the first's lifetime had ended and its storage was reused. Reading through a stale pointer does not read nothing — it reads whatever is in that storage now.
So the read often succeeds, with the right value. Immediately after an object is destroyed, its bytes are usually untouched. Chapter 7.2 §6 noted this and §3's measurement above is an instance: the returned-reference program printed 8, which is the correct answer, under a sanitizer, from an object that no longer existed.
And it fails when something else reuses the storage. Which depends on what the program does next: which functions are called, how deep the call stack goes, what the allocator decides. None of that is visible at the line holding the stale pointer.
That produces a specific and nasty signature:
| A null dereference | A dangling dereference | |
|---|---|---|
| When it fails | immediately | later, or never, or in production |
| Where it fails | at the cause | somewhere else |
| Reproducible | yes | often not |
| Survives testing | no | routinely |
| Changes with unrelated edits | no | yes |
Chapter 8.3 §8's callout called the null dereference "the friendly failure" for exactly this contrast. The last row is the one that costs the most time: adding a function call, or changing an optimisation level, or a different allocator on a different platform can turn a working program into a broken one with no change to the defective line.
§5's measurement is the clean illustration. The address was stable for three consecutive insertions. A test suite that inserts three items passes; one that inserts five does not — and the code that is wrong is identical in both.
8. What Catches What
Four cases, two tools, measured:
| Case | -Wall -Wextra -Wpedantic | AddressSanitizer |
|---|---|---|
| returned reference to a local (§3) | warns -Wreturn-stack-address | silent — printed 8, exit 0 |
| use after scope (§4) | silent | catches stack-use-after-scope |
| container reallocation (§5) | silent | catches heap-use-after-free |
| reference into a temporary argument (§6) | silent, even with -Wdangling | catches stack-use-after-scope |
The compiler caught exactly the one case the sanitizer missed, and the sanitizer caught the three the compiler missed.
That is not a coincidence worth philosophising about — it is a straightforward consequence of how each works. The compiler reasons about one function at compile time, so it sees the returned local, where the cause and the effect are in one body. The sanitizer instruments the program and checks at run time, so it sees anything that actually executes a bad access — but only if that code path runs, which is why it said nothing about §3 (the access it would have flagged was optimised into something benign).
The practical conclusion is not subtle:
Use both, and understand that neither is a guarantee. The compiler misses three of four. The sanitizer needs the bad path to execute, which means it is only as good as your tests.
Chapter 25.6 is the chapter on sanitizers properly. For now: -fsanitize=address -g on your test builds costs roughly a 2× slowdown and catches three of this chapter's four cases with exact line numbers.
9. Prevention
No checks, so: habits.
Never return a pointer or reference to a local. Chapter 6.3 §10's rule — return by value. The compiler helps here, and it is the only case where it does.
Never let a pointer outlive the block its target was declared in. §4's bug is a pointer assigned inside a block and used outside it.
Treat any pointer or reference into a container as valid only until the next modification. §5 measured why. If you need it across an insertion, store an index instead — an index survives reallocation because it does not name storage. Measured, across six insertions that moved the storage:
before: v[i] = 10 data at 0x100765b30
after: v[i] = 10 data at 0x100765a20The address changed and v[i] still gave the right element, with zero sanitizer errors. A pointer taken at the same moment would have been stale.
Do not return a reference to anything that is not guaranteed to outlive the call. Chapter 6.3 §6's rule, and Chapter 11.3's exception is a member function returning a reference to a member of an object the caller owns.
Know that const T& r = f(); is safe and const T& r = g(f()); may not be — §6's distinction, and the reason is that lifetime extension does not reach through a function.
Prefer forms that have no lifetime question. Return by value (Chapter 6.3). Pass const& and use it only during the call (Chapter 6.5). Use a range-based for rather than holding pointers (Chapter 5.6).
Run -fsanitize=address in testing. Three of four, with line numbers.
And when ownership is the question, use a type that encodes it. Module 14. A raw pointer says nothing about who destroys what, and Chapter 10.9 is the chapter named after that gap.
10. Mistakes Worth Making Once
Returning a reference to a local. Caught — the one case that is.
Assuming the compiler catches the rest. Measured: silent on three of four, including with -Wdangling.
Assuming a sanitizer catches everything. Measured: silent on §3, and it needs the bad path to execute.
Holding a pointer into a container across an insertion. Measured: the storage moved at two of ten insertions.
Concluding it is fine because the test passed. The address was stable for three consecutive insertions.
Thinking const T& r = pick(make()); is the same as const T& r = make(); It is not — extension does not cross a function call.
Reading a value through a stale pointer and believing it. It read 8, correctly, from a destroyed object.
Treating references as immune. Chapter 8.5 §5 said it: not null, can dangle.
11. Interview Relevance
This is the module's last Critical chapter and the questions are senior-level.
"What is a dangling pointer?" A valid pointer to an object whose lifetime has ended. The strong answer adds that scope and lifetime are independent properties, which is Chapter 7.2's model, rather than describing it as "pointing at freed memory".
"Why are these bugs hard to find?" The storage usually still exists and usually still holds the old value, so the access succeeds. Whether it fails depends on what happens between the destruction and the access, which is elsewhere in the program. §5's measurement — stable for three insertions, moved at the fourth — is the example to have ready.
"How do you find them?" Both tools, and knowing the split is the discriminator: the compiler catches a returned local, and AddressSanitizer catches use-after-scope, use-after-free and the temporary case. Neither is sufficient.
"How do you prevent them?" Structurally, not by checking. Return by value; treat container references as valid until the next modification; use smart pointers where ownership is the question.
12. What You Should Be Able to Say Now
- A dangling pointer or reference is a valid name for an object whose lifetime has ended — Chapter 7.2 §5's bottom-left quadrant.
- The model is inherited whole from Chapter 7.2; what Module 8 adds is the ability to hold such a name and move it around.
- Case one, a returned local: the compiler warns
-Wreturn-stack-address, and under ASan the program printed 8 and exited 0. - Case two, use after scope: zero warnings, and ASan reports
stack-use-after-scopewith the line. - Case three, container reallocation: measured, the storage moved at exactly two of ten insertions and was stable for three in between.
- The undefined version gives ASan's
heap-use-after-free; the standing flags say nothing. - Case four, a reference into a temporary argument: zero warnings even with
-Wdangling; ASan catches it. const T& r = f();is safe — a temporary bound directly to a reference has its lifetime extended.const T& r = g(f());may not be — extension does not reach through a function that returns a reference into the temporary.- The symptom appears far from the cause because the storage still exists and often still holds the old value — Chapter 7.2 §4.
- So it survives testing routinely and changes behaviour with unrelated edits.
- The compiler caught exactly the one case the sanitizer missed, and the sanitizer caught the three it missed — use both.
- Neither is a guarantee: the compiler sees one function; the sanitizer needs the path to run.
- There is no check, so prevention is structural — return by value, indices instead of pointers into containers, and Module 14 where ownership is the question.
13. Exercises
Exercise 1 — name the quadrant. For each, give Chapter 7.2 §5's quadrant and say whether it is a bug: (a) a reference to a local, used inside its function (b) a reference returned from a function (c) a static local seen from main (d) a pointer into a container after an insertion.
Exercise 2 — predict the tooling. For each of the four cases in §8, say what -Wall -Wextra -Wpedantic reports and what AddressSanitizer reports.
Exercise 3 — read the reallocation. From §5's output, say at which insertions a previously-taken pointer became stale, and how many insertions it remained valid across after the first reallocation.
Exercise 4 — the two temporaries. Explain why const int& a = twice(4); is safe and const int& b = pick(make()); is not, in terms of what lifetime extension does and does not do.
Exercise 5 — fix them. Give a repair for each: (a) a function returning a reference to a local (b) a pointer assigned inside a block and used after it (c) a pointer held across a container insertion.
Exercise 6 — why it passed. A test inserts three items into a container while holding a pointer to the first element, and passes. Say why, and what minimal change to the test would fail.
Exercise 7 — the limits of tooling. You enable -fsanitize=address and your suite is clean. State precisely what you may and may not conclude, and name the two chapters that cover the remaining gaps.
14. Summary
- A dangling pointer or reference is a valid name for a dead object — Chapter 7.2's model, inherited.
- Module 8's addition is that such a name can now be stored and passed around.
- Returned local: compiler warns; ASan was silent and the program printed the right answer.
- Use after scope: compiler silent; ASan catches it.
- Container reallocation: measured — storage moved at two of ten insertions, stable for three in between.
- Reference into a temporary argument: silent even with
-Wdangling; ASan catches it. const T& r = f();is safe;const T& r = g(f());may not be — extension does not cross a function.- The symptom appears far from the cause because the storage persists and often holds the old value.
- The two tools are complementary — the compiler caught the one ASan missed, ASan caught the three it missed.
- No check exists, so prevention is structural; Module 14 is where ownership becomes part of the type.
Module 8 is finished, and the claim its description opened with has held up. A pointer is an object whose value is the address of another object; a reference is another name for an existing object. Everything in eight chapters followed from those two sentences — the two operators as inverses, the deliberate nothing, arithmetic scaled by the type's promise, three refusals that are one property, a choice decided by two questions, two independent consts, and finally the one risk both forms share.
What the module also did, four times, was borrow. Chapter 5.6 took a built-in array to have something to iterate; Chapter 6.2 took a struct to have something expensive to copy; Chapter 8.4 took an array again to have something contiguous to walk; and this chapter took a std::vector because the canonical lifetime bug cannot be shown without a container. Each borrowing was declared and each pointed at the same place.
Module 9 is that place. It is where data stops being one value at a time: built-in arrays and C strings taught honestly because real interfaces still use them, then std::string and std::string_view as what you write instead, then structs for grouping values that belong together. It is also where several debts come due at once — Chapter 8.4's array bounds that the compiler does not check, Chapter 6.2's struct that was borrowed without its layout, and the std::string whose copy cost Chapter 6.5 could only describe.
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.
