C++ Object Lifetime: Storage Duration, RAII, and Dangling

Key takeaways

C++ lifetime and storage duration: automatic, static, dynamic, thread_local; destruction order; temporaries; smart pointers and RAII.

What is lifetime?

Most C++ programmers learn “storage duration” (automatic, static, dynamic, thread) as a checklist and stop there, but storage duration and object lifetime are two different concepts that only happen to line up most of the time. Storage duration describes when the memory for an object is reserved and released. Lifetime describes when the object itself — a fully constructed, invariant-respecting instance of its type — legally exists. The two diverge in exactly the cases that produce the nastiest bugs: placement-new into pre-allocated storage, unions with non-trivial members, and objects under construction or destruction. Getting this distinction right is what separates “I know RAII deletes things for me” from actually being able to reason about undefined behavior in a debugger at 2 AM.

void func() {
    int x = 10;  // x's lifetime begins here, at initialization — not when the stack frame was set up
    // x is usable here
}  // x's lifetime ends here, at scope exit

The C++ standard is explicit about this: an object’s lifetime begins when storage with proper alignment and size is obtained and its initialization is complete (for a class type, that means the constructor call has finished). It ends when the destructor call starts, or the storage is released, whichever comes first. That “and” is not decorative — it is the entire justification for why placement-new works and why touching a union’s inactive member is undefined behavior.

Storage duration

Storage duration is the more mechanical half of the picture: it tells you how the compiler and runtime manage the bytes, independent of what object (if any) currently occupies them.

Here is the func implementation showing all four kinds side by side:

// 1. Automatic
void func() {
    int x = 10;  // destroyed at end of block
}
// 2. Static
int global = 10;  // program end
void func() {
    static int count = 0;  // program end
}
// 3. Dynamic
void func() {
    int* ptr = new int(10);  // until delete
    delete ptr;
}
// 4. Thread
thread_local int x = 10;  // thread end

Notice that storage duration says nothing about when construction happens relative to when the storage exists. That gap is exactly where placement-new lives, and it is the cleanest way to see storage and lifetime as separate axes:

#include <new>
alignas(Widget) unsigned char buffer[sizeof(Widget)];
// Storage for a Widget-sized, Widget-aligned object now exists.
// But NO Widget object exists yet — buffer's lifetime as raw bytes
// began, but Widget's lifetime has not.

Widget* w = new (buffer) Widget(42);
// Widget's lifetime begins HERE, at the end of the constructor call —
// not when `buffer` was declared, even though the memory has existed
// the whole time.

w->~Widget();
// Widget's lifetime ends HERE. The bytes in `buffer` still exist
// (automatic storage duration hasn't ended), but no object lives there.

This is why “lifetime begins at construction, not allocation” is more than a pedantic footnote. If you treat buffer’s existence as equivalent to w’s existence, you will eventually call a member function on w before the placement-new runs, or continue using w after the explicit destructor call but before the storage itself is reused — both of which are undefined behavior even though the memory is technically still “there.” Custom allocators, object pools, and std::vector’s internal growth strategy (which allocates raw storage via the allocator and only later constructs elements into it with std::allocator_traits::construct / placement-new) all rely on this separation being real, not just theoretical.

The same rule governs union active-member tracking. A union reserves storage sized for its largest member, but at any moment at most one member’s lifetime is active:

union Variant {
    int i;
    float f;
    Variant() : i(0) {}  // starts i's lifetime
};

Variant v;
v.i = 42;        // fine, i is the active member
v.f = 3.14f;     // ends i's lifetime, begins f's lifetime (for trivial types, in practice)
std::cout << v.i; // UB in the strict standard sense — i is not the active member

In practice, compilers are lenient about type-punning through unions for trivial types (and it is common, sanctioned practice in C), but the standard’s model is that writing to a union member ends the previous active member’s lifetime and starts the new one’s. Reading a member other than the last one written is formally undefined behavior for non-trivial types, and UBSan / -fstrict-aliasing-driven optimizations can and do exploit this. If you need guaranteed, portable type punning, use std::bit_cast (C++20) or memcpy into an object of the target type instead of relying on union member reads.

Automatic, static, and dynamic storage in code

Automatic storage

#include <iostream>
class Widget {
public:
    Widget(int id) : id(id) {
        std::cout << "Widget " << id << " ctor" << std::endl;
    }

    ~Widget() {
        std::cout << "Widget " << id << " dtor" << std::endl;
    }

private:
    int id;
};
void func() {
    Widget w1(1);

    if (true) {
        Widget w2(2);
        // w2 lifetime: this block
    }  // w2 destroyed

    Widget w3(3);
}  // w3, w1 destroyed (reverse order)
int main() {
    func();
}

Automatic storage is the easiest case to reason about because the compiler generates the destructor calls for you at every exit point of the block — normal fall-through, return, break, or an exception propagating through (as long as the object’s own constructor completed). The order is always the reverse of construction within a scope: w3 was built last, so it is destroyed first, and w1 (built first) is destroyed last. This reverse ordering is not an implementation detail you can rely on loosely — it is guaranteed by the standard, and it is the mechanical basis for RAII: if w2 had opened a file handle in its constructor and w3’s constructor threw an exception, w2 would still be correctly unwound before w1, because stack unwinding follows the same reverse-of-construction rule that normal scope exit does.

Static storage

#include <iostream>
class Logger {
public:
    Logger() {
        std::cout << "Logger init" << std::endl;
    }

    ~Logger() {
        std::cout << "Logger shutdown" << std::endl;
    }

    void log(const std::string& msg) {
        std::cout << "[LOG] " << msg << std::endl;
    }
};
Logger globalLogger;
void func() {
    static Logger localLogger;
    localLogger.log("func");
}
int main() {
    globalLogger.log("start");
    func();
    func();
    globalLogger.log("end");
}

Static storage duration means the object lives for the entire program, but when it is constructed depends on which flavor of “static” you used. A namespace-scope global like globalLogger is constructed before main begins (subject to the static initialization order rules discussed below). A function-local static, like localLogger, is constructed the first time control passes through its declaration — this is called “magic statics” and has been thread-safe (guaranteed exactly-once initialization even under concurrent first calls) since C++11. That thread-safety guarantee is not free: the compiler inserts a hidden guard variable and, on some ABIs, a lock, so a function-local static in a hot path can carry measurable overhead on first call and a small branch check on every subsequent call. For most code this is irrelevant; for a function called millions of times per second in a tight loop, it is worth knowing the check exists.

Dynamic storage

#include <memory>
class Resource {
public:
    Resource(int size) : size(size) {
        data = new int[size];
        std::cout << "Resource alloc " << size << std::endl;
    }

    ~Resource() {
        delete[] data;
        std::cout << "Resource free " << size << std::endl;
    }

private:
    int* data;
    int size;
};
void manualManagement() {
    Resource* res = new Resource(100);
    delete res;
}
void smartPointer() {
    auto res = std::make_unique<Resource>(100);
}
int main() {
    manualManagement();
    smartPointer();
}

Dynamic storage is the one case where the language gives you no automatic lifetime management whatsoever — the object lives exactly as long as you say it does, via delete, and not one instruction longer or shorter. manualManagement() is correct as written, but it is correct only because every exit path from that function reaches the delete. Add an early return, or let an exception get thrown between the new and the delete, and res leaks — the destructor never runs, and whatever resources it would have released (the int[] array, and by extension any file handles, sockets, or locks a more complex Resource might hold) leak with it. This is precisely the failure mode RAII exists to eliminate: smartPointer() ties the Resource’s lifetime to std::unique_ptr’s own lifetime, which is automatic storage duration, which means the compiler-generated cleanup on scope exit now applies transitively to the dynamically allocated object. You get new/delete’s flexibility (heap allocation, polymorphic ownership, transferable ownership) with automatic storage’s safety guarantees.

Lifetime extension of temporaries

#include <iostream>
class Temp {
public:
    Temp(int val) : value(val) {
        std::cout << "Temp ctor: " << value << std::endl;
    }

    ~Temp() {
        std::cout << "Temp dtor: " << value << std::endl;
    }

    int getValue() const {
        return value;
    }

private:
    int value;
};
Temp createTemp(int val) {
    return Temp(val);
}
int main() {
    const Temp& ref = createTemp(10);
    std::cout << "value: " << ref.getValue() << std::endl;
    // destroyed when ref goes out of scope
}

This works, and it is worth understanding exactly why, because the rule is much narrower than most explanations suggest. When a const T& (or an rvalue reference T&&) is bound directly to a temporary, the standard extends that temporary’s lifetime to match the reference’s lifetime — instead of being destroyed at the end of the full expression that created it, the temporary survives until ref itself goes out of scope. This is a genuinely useful rule; without it, binding a reference to a function’s return value (as above) would leave you with a dangling reference the instant the statement ended.

But “bound directly” is the operative phrase, and it does not survive being passed through another expression. Two cases that look superficially similar to the lifetime-extension example but are not covered:

struct Wrapper {
    const Temp& ref;
    Wrapper(const Temp& t) : ref(t) {}  // does NOT extend anything
};

Wrapper w(createTemp(10));
// The temporary Temp(10) is destroyed at the end of the full expression
// that constructed `w` — i.e., immediately after this line.
// w.ref is now a dangling reference. Using it is UB.
std::cout << w.ref.getValue(); // UB, even though it "looks" analogous to the lifetime-extension example

Lifetime extension only happens when the reference is bound directly in a declaration; it does not propagate through a member-initializer-list, a function parameter, or a chain of function calls. A member reference initialized from a temporary passed to the constructor gets no extension at all — the member outlives the temporary it points to by exactly zero instructions past the full expression. The same non-propagation applies to a temporary returned by a member function called on another temporary:

const std::string& s = std::string("hello").substr(0, 3);
// The Temp `std::string("hello")` is bound to nothing here — substr()
// returns a NEW temporary std::string, and IT is what gets bound to `s`.
// Only the substr() result gets extended; the original "hello" temporary
// is destroyed at the end of the full expression regardless.
// (This particular example happens to be safe because substr's result
// doesn't reference the original's buffer — but the general pattern of
// "chain a call off a temporary and extend the tail result" is fragile
// and easy to get wrong with types that DO alias into the original,
// like std::string_view or std::span.)

The safe mental model is: lifetime extension is a one-hop, syntactic rule tied to the exact reference declaration, not a transitive guarantee that “anything reachable from a temporary I bound to a const ref stays alive.” If you need a temporary to outlive the current full expression in any shape other than a direct const T&/T&& binding, copy it, move it into owned storage, or restructure the code so the temporary has a name and an explicit scope.

Construction / destruction order

#include <iostream>
class A {
public:
    A(int id) : id(id) {
        std::cout << "A" << id << " ctor" << std::endl;
    }

    ~A() {
        std::cout << "A" << id << " dtor" << std::endl;
    }

private:
    int id;
};
A global1(1);  // global first
int main() {
    A local1(2);
    static A static1(3);
    A local2(4);

    // Destroy order: local2, local1, static1, global1
}

Within a single translation unit, namespace-scope objects are constructed in the order they are defined in the source file, and destroyed in the exact reverse order at program exit — this part is fully specified and reliable. Function-local statics are constructed on first use and destroyed in reverse order of construction (not declaration) at program exit, which is why static1 — constructed partway through main — is destroyed after local2 and local1 unwind, but before global1 at true program teardown.

sequenceDiagram
    participant Program as Program start
    participant G1 as global1 (static)
    participant Main as main()
    participant L1 as local1 (automatic)
    participant S1 as static1 (static, lazy)
    participant L2 as local2 (automatic)

    Program->>G1: construct before main
    Program->>Main: enter main()
    Main->>L1: construct
    Main->>S1: construct (first use)
    Main->>L2: construct
    Main->>L2: destroy (scope exit)
    Main->>L1: destroy (scope exit)
    Note over S1,G1: program exit begins
    Main->>S1: destroy (reverse of construction)
    Main->>G1: destroy (reverse of TU order)

The part that does not have a reliable order is initialization of namespace-scope objects across different translation units — and that leads directly into one of the most infamous categories of C++ bugs.

Dangling references, init-order fiasco, and dead temporaries

Returning a reference to a local

const std::string& func() {
    std::string s = "Hello";
    return s;
}
int main() {
    const std::string& ref = func();  // UB
}
// Fix: return by value
std::string func() {
    std::string s = "Hello";
    return s;
}

s is automatic storage local to func; it is destroyed the instant func returns, before the caller ever sees the reference. Note that lifetime extension does not rescue this case — extension only applies when a reference is bound to a temporary at the point of binding, and s is a named local variable, not a temporary, from the callee’s perspective. Returning by value sidesteps the whole problem, and thanks to guaranteed copy elision (C++17) and NRVO in virtually every real compiler even before that, it costs nothing extra in practice — this used to be a real performance trade-off worth discussing, but on any C++17-or-later toolchain it no longer is.

Pointers into a destroyed stack frame

int* func() {
    int x = 10;
    return &x;
}
int main() {
    int* ptr = func();  // UB
}
// Fix: dynamic allocation or return by value

Same root cause as returning a reference to a local, just with a raw pointer instead of a reference — and arguably more dangerous in practice, because a stale pointer to a destroyed stack frame will often still print a plausible-looking value if you dereference it immediately (the bytes haven’t been overwritten yet), which lulls people into believing the bug isn’t real until it manifests later as corrupted, seemingly unrelated data once another function reuses that stack slot.

Static initialization order fiasco

// file1.cpp
int globalA = 10;
// file2.cpp
extern int globalA;
int globalB = globalA * 2;  // order across TUs unspecified
// Safer: function-local static
int& getGlobalA() {
    static int globalA = 10;
    return globalA;
}

I hit this one for real early on, and it is one of those bugs that makes you distrust code you’ve read a hundred times. We had a small config-loading library: one translation unit defined a Config object with static storage duration whose constructor parsed a default config path from a Logger object — also global, also with static storage duration, defined in a different translation unit. It worked for months. Then someone reordered the object files in the build system’s link line (nothing about the actual source changed), and the Config constructor started reading an uninitialized Logger — because the standard makes zero guarantees about the order in which namespace-scope objects in different translation units are constructed relative to each other. On our old link order, Logger happened to get constructed first, purely by luck; on the new order, it didn’t. The Logger’s member (a std::string for the log path) hadn’t been constructed yet, so reading it wasn’t “wrong value,” it was undefined behavior on an unconstructed std::string — which in our case manifested as a segfault, but easily could have been silent corruption. The standard actually gives you a strong, specific guarantee here that’s easy to forget under pressure: within one translation unit, order is guaranteed and matches declaration order; across translation units, it’s completely unspecified, full stop, no matter how logical the “correct” order feels.

The fix we used — and the fix in the snippet above — is the “construct on first use” idiom: wrap the global in a function returning a reference to a function-local static. Function-local statics are guaranteed to be constructed the first time execution passes through their declaration, which means the first caller to actually need getGlobalA() triggers its construction, regardless of translation unit or link order. This converts an unspecified, link-order-dependent global construction order into a well-defined, demand-driven one. The cost is a per-call guard check (mentioned above), which is negligible for anything that isn’t itself a hot inner loop.

c_str() on a temporary

std::string getName() {
    return "Alice";
}
const char* ptr = getName().c_str();  // UB after full expression
const std::string& name = getName();
const char* ptr = name.c_str();  // OK until name dies

getName() returns a temporary std::string; .c_str() hands back a pointer into that temporary’s internal buffer, and the temporary is destroyed at the end of the full expression — meaning ptr is dangling the moment the semicolon is reached, before you ever get a chance to use it. This is a special case of the lifetime-extension boundary discussed in the lifetime-extension example: ptr is a raw const char*, not a reference, so no extension rule applies to it at all — extension only ever applies to the reference variable directly bound to the temporary, never to something derived from that temporary’s contents. Binding name as a const std::string& first, then calling .c_str() on the named reference, works because now the temporary genuinely is lifetime-extended to name’s scope, and .c_str() is called on a live object.

Smart pointers and lifetime

#include <memory>
class Resource {
public:
    Resource() {
        std::cout << "Resource ctor" << std::endl;
    }

    ~Resource() {
        std::cout << "Resource dtor" << std::endl;
    }
};
void uniquePtr() {
    auto res = std::make_unique<Resource>();
}
void sharedPtr() {
    auto res1 = std::make_shared<Resource>();
    {
        auto res2 = res1;
    }
}
int main() {
    uniquePtr();
    sharedPtr();
}

std::unique_ptr models exclusive ownership: exactly one owner, and the object’s lifetime is tied precisely to that owner’s automatic-storage lifetime — no reference counting overhead, deterministic destruction, and move-only semantics that make transfer of ownership explicit at every call site. std::shared_ptr models shared ownership via reference counting: the underlying Resource is destroyed only when the last owning shared_ptr (including copies like res2) goes out of scope, so in the snippet above, the Resource survives past the inner block because res1 still holds a reference after res2 is destroyed. The trade-off is real: shared_ptr’s control block adds an atomic increment/decrement on every copy and destruction (for thread-safety of the reference count itself, even in single-threaded programs), and a shared_ptr cycle — two objects each holding a shared_ptr to the other — will leak forever because the reference count never reaches zero. std::weak_ptr exists specifically to break such cycles by observing an object’s lifetime without extending it. As a rule of thumb: default to unique_ptr for ownership, and reach for shared_ptr only when you have a genuine multi-owner scenario you can’t design around — not as a default “safe” choice, because it isn’t free and it isn’t automatically correct either.

RAII

#include <fstream>
#include <mutex>
void writeFile(const std::string& filename) {
    std::ofstream file(filename);
    file << "Hello";
}
std::mutex mtx;
void criticalSection() {
    std::lock_guard<std::mutex> lock(mtx);
    // critical section
}

It’s worth being precise about what RAII actually is, because “cleanup on scope exit” undersells it. RAII (Resource Acquisition Is Initialization) is a design principle that binds a resource’s validity to an object’s lifetime: acquisition happens in the constructor, release happens in the destructor, and — critically — the compiler’s guaranteed reverse-order destruction on every exit path (normal return, early return, break, or exception unwinding) becomes your resource management, for free, with no try/finally-equivalent boilerplate. std::ofstream’s destructor flushes and closes the file handle; std::lock_guard’s destructor unlocks the mutex. Neither of these classes is special-cased by the compiler — they are ordinary classes that exploit the ordinary lifetime rules described throughout this article. That’s the actual insight: RAII isn’t a language feature, it’s a consequence of the lifetime and destruction-order guarantees the language already makes, applied deliberately to resources instead of just plain data. Once you see it that way, you stop thinking of unique_ptr, lock_guard, and ofstream as three unrelated “smart” utility classes and start seeing them as the same pattern applied to memory, a mutex, and a file descriptor respectively — which makes it obvious how to write your own RAII wrapper around any acquire/release pair (a socket, a database transaction, a GPU context) rather than reaching for a raw try/catch cleanup block.

FAQ

Q1: When does lifetime start?

A: After construction completes and initialization finishes — for a class type, that means the constructor body has finished executing, not when the memory was allocated or the declaration was reached.

Q2: When does it end?

A:

  • Destructor call begins
  • Scope ends (for automatic storage)
  • delete called (for dynamic storage)
  • Storage is reused, whichever of these happens first

Q3: Storage kinds?

A:

  • Automatic (local)
  • Static (global / static)
  • Dynamic (new/delete)
  • thread_local

Q4: Avoid dangling?

A:

  • Smart pointers for owned dynamic objects
  • Return by value instead of a reference/pointer to a local
  • RAII for any acquire/release resource, not just memory
  • Prefer “construct on first use” over cross-TU global dependencies

Q5: Temporary extension?

A: const T& (or T&&) bound directly to a temporary extends that temporary’s lifetime to the reference’s scope. This does not propagate through member-initializer-lists, function parameters, or a chain of calls off another temporary — only the direct binding is covered.