references/lifetime.md
Ownership and lifetime
The dangling catalogue: the specific ways C++ lifetime bugs actually happen, and what to write instead. Every one of these compiles cleanly.
The rule that generates the rest
A non-owning type is only valid while something else keeps its referent alive. So the question at every declaration is who owns this, and does that owner outlive this name? If you cannot answer in one sentence, the design is wrong, not the syntax.
1. A view over a temporary
The single most common C++ lifetime bug.
std::string_view name = build_name(); // dangles: the string dies here
std::string_view host = config.at("host").substr(0, 4); // same, one level deeper
The temporary is destroyed at the end of the full expression. The view outlives it by exactly one line.
std::string name = build_name(); // own it
std::string_view view = name; // then view it, if you need to
A string_view or span parameter is fine; a string_view member or return value needs proof. Parameters live inside the caller's full expression, which is why the type exists. Members and returns escape it.
Returning one is only correct when the referent outlives the call by construction: a string literal, a static table, or a member of *this whose lifetime the caller already controls.
2. Lifetime extension does not chain
Binding a temporary to a const& extends it. Binding to a reference returned from a function does not, because the compiler cannot see what it refers to.
const auto& ok = make_config(); // extended, lives as long as ok
const auto& bad = make_config().name(); // dangles: only the reference is bound
The rule: extension applies to the temporary directly bound at the declaration. The moment a function returns a reference into it, you are outside the rule.
3. Range-for over a temporary's member
for (const auto& row : make_report().rows()) { ... } // pre-C++23: dangles
The make_report() temporary was destroyed before the loop body ran. C++23 (P2718) extends the lifetime of temporaries in the range initializer, which fixes exactly this. It is still worth naming the object, because the fix depends on the standard you actually compile with and readers cannot see your -std flag.
auto report = make_report();
for (const auto& row : report.rows()) { ... }
4. auto capturing a view or an expression node
auto pipeline = rows | std::views::filter(pred); // what type is this?
Ranges adaptors and expression templates (Eigen, Blaze, xtensor) return lightweight nodes holding references to their inputs. auto happily stores one. If any input was a temporary, or dies first, the node dangles.
C++20's owning_view covers the common case: piping an rvalue range moves it in, so make_rows() | views::filter(pred) is safe. It does not cover a node holding a reference to a named local that goes out of scope first, and it does not cover expression-template libraries at all.
auto total = (a + b + c).sum(); // fine: evaluated in the same expression
auto expr = a + b + c; // node holding references to a, b, c
With Eigen and friends, auto on an expression is a defect. Assign to the concrete type, or call .eval().
5. Lambdas that outlive their frame
void schedule_refresh(Cache& cache) {
timer.every(60s, [&] { cache.refresh(); }); // cache reference stored
}
[&] is correct for a lambda consumed before the function returns: an algorithm predicate, a visit handler, a scoped parallel loop. It is a bug for anything stored, queued, detached, or handed to another thread.
- Consumed here and now:
[&] - Stored, queued, or async: capture by value, or capture a
shared_ptryou have
deliberately decided to share
- Never
[=]in a member function without thinking: it capturesthis, not the
members, so the copy is a raw pointer to an object that may already be gone. C++20 lets you write [*this] for a genuine copy.
6. Coroutines holding reference parameters
task<int> fetch(const Request& req) { // reference into the caller's frame
co_await io(); // caller may return during this
co_return parse(req.body); // dangles
}
The coroutine frame copies the parameters, and a reference parameter copies the reference, not the object. After the first suspension the caller may be gone.
Take coroutine parameters by value. The move is cheap next to the frame allocation you are already paying for, and it is the only way the invariant holds without auditing every caller.
The same applies to a string_view parameter to a coroutine, for the same reason.
7. Container invalidation
| Container | Invalidated by |
|---|---|
vector, string | Any growth invalidates every iterator, pointer, and reference. erase invalidates from the erase point. |
deque | Insertion at either end invalidates iterators but keeps references valid. Anywhere else invalidates both. |
unordered_map/set | Rehash invalidates iterators. References to elements stay valid. |
map/set, list | Only the erased element is invalidated. |
The classic: holding a pointer to an element, then push_backing. reserve up front removes the surprise for vector, but the rule to internalise is that a pointer into a container is a lifetime dependency on that container's shape.
Also worth knowing: std::vector<bool> is a bitfield, and operator[] returns a proxy, not a bool&. auto x = v[0]; gives you the proxy. Use std::vector<char> or std::bitset when you wanted an array of bools.
8. A reference member
A class with a T& member is not assignable and pins its lifetime to the referent's. That is occasionally what you want and usually not.
Prefer a pointer member (reseatable, and the nullability is explicit) or, better, a value. If it must be a reference, say so at the constructor and document who owns the referent.
Catching these
Static analysis catches the shallow ones; sanitizers catch the rest at runtime.
-Wdangling-reference(GCC 13+) and-Wreturn-stack-addresscatch the direct
returns.
clang-tidywithbugprone-dangling-handlecatches views bound to temporaries.[[clang::lifetimebound]]on a parameter tells Clang the return value borrows
from it, which turns case 1 into a warning at every call site. Worth annotating your own view-returning accessors.
- ASan is the backstop. Use-after-free and use-after-scope are exactly what
it reports, with both stacks. Run the test suite under it in CI; see build.md.
None of these are complete. The design rule at the top is what actually prevents the bug; the tools catch what slipped.