>_devkit
modern-cpp
skills/modern-cpp/

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_ptr you have

deliberately decided to share

  • Never [=] in a member function without thinking: it captures this, 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

ContainerInvalidated by
vector, stringAny growth invalidates every iterator, pointer, and reference. erase invalidates from the erase point.
dequeInsertion at either end invalidates iterators but keeps references valid. Anywhere else invalidates both.
unordered_map/setRehash invalidates iterators. References to elements stay valid.
map/set, listOnly 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-address catch the direct

returns.

  • clang-tidy with bugprone-dangling-handle catches 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.