Create your own
Lesson illustration

Understanding C++ Object Lifetimes and Storage Duration

Hello. In the previous lesson, you used GDB’s stack frames to see which function calls are active and to inspect each call’s local state. That model is useful here, but it is not the whole story: a stack frame is an implementation and debugging concept, while an object lifetime is a C++ language rule.

This lesson makes that distinction concrete. You will trace objects created in ordinary blocks, objects created with new, and objects that persist for the whole program. By the end, you should be able to look at a declaration and state when the object begins its lifetime, when it is destroyed, and who is responsible for ending that lifetime.


Storage duration, scope, and lifetime

Three related ideas are easy to conflate:

  • Scope answers: Where can I write this name in the source code?
  • Storage duration classifies how long the storage associated with an object can persist.
  • Object lifetime is the interval from construction/initialization until destruction.

Consider:

void process() {
    int count = 0;

    {
        int temporary = 42;
        ++count;
    }

    ++count;
}

Both count and temporary have automatic storage duration because they are ordinary local variables. But their scopes and lifetimes differ:

  • count is constructed when execution reaches its declaration and is destroyed when process’s body ends.
  • temporary is constructed when execution enters its inner block and is destroyed at that inner block’s closing brace.

The central rule is:

An automatic object is destroyed when execution leaves the block in which it was declared.

This includes leaving through return or an exception, not just reaching the final closing brace normally. For class objects, destruction means that the destructor runs.

The word “automatic” does not literally guarantee physical placement in a particular hardware memory region. In ordinary implementations, automatic locals are commonly associated with stack frames, dynamically allocated objects with the heap, and static objects with a static-data region. That is a useful model for debugging, but C++ specifies lifetimes rather than exact memory layout.

A conceptual process-memory layout: automatic objects are commonly associated with stack frames, dynamically allocated objects occupy heap-managed storage, and static-duration objects occupy storage that persists for the process. The dotted arrow represents a stack-resident pointer referring to a distinct dynamically allocated object.

The diagram also highlights a crucial point for pointers: the pointer and the object it points to can have different storage durations.

void example() {
    int* p = new int{42};
}

Here, p itself is an automatic local variable. It typically lives in example’s stack frame and is destroyed when the function ends. The int{42} is a separate dynamically allocated object. Its lifetime does not end merely because p goes out of scope.


A compact reference: the three durations in this lesson

Storage durationTypical declaration or creationObject lifetime endsUsual responsibility
AutomaticOrdinary local variable, such as Trace local{"x"};When its enclosing block is exitedC++ destroys it automatically
Dynamicnew Trace{"x"}When the corresponding delete is executedCode that owns the allocation must arrange cleanup
StaticNamespace-scope object, or local declared staticDuring normal program terminationC++ destroys it automatically

For this lesson, use “global” informally to mean an object defined at namespace scope, outside every function:

int requests_seen = 0;  // static storage duration

This object has static storage duration whether or not you write the keyword static. At namespace scope, that keyword has additional linkage implications, but its presence is not what gives the object its long lifetime. We will keep linkage separate from the storage-duration question for now.


Watch the lifetime model once

The first few minutes of The Cherno’s Object Lifetime in C++ (Stack/Scope Lifetimes) give a clear visual treatment of block-based automatic lifetime and contrast it with a heap allocation that survives the block.

Object Lifetime in C++ (Stack/Scope Lifetimes)

Watch Object Lifetime in C++ (Stack/Scope Lifetimes) by The Cherno to reinforce the distinction between leaving a scope and explicitly releasing a dynamic allocation.

Start with the stack model, which connects scopes to automatic local objects. Then watch the constructor trace to see a destructor run at block exit. Finish with the heap contrast: notice that creating an object with new does not schedule its destructor at the end of the surrounding block.

The “stack of books” picture is helpful, provided you retain the more precise rule: the language promises destruction at scope exit for automatic objects; the stack is the usual implementation strategy.

For a compact written reference, read the opening material and the section on static locals and new in the CSE 202 notes.

Storage duration - CSE 202

Read Storage duration from CSE 202 for a concise statement of the default durations of local and namespace-scope objects, followed by examples of static locals and dynamic allocation.

In the opening discussion, read the duration overview. Focus on the distinction between local scope and namespace scope, and on the fact that a class destructor is invoked at the end of its lifetime. Then continue to the following discussion, beginning “A local variable declared with the static specifier.” Read static locals and dynamic allocation. Pay particular attention to the fact that a static local is initialized once, whereas an object allocated with new remains alive until explicit destruction.


Lab: make constructions and destructions visible

Rather than inferring lifetimes from addresses, instrument the program so that every construction and destruction announces itself.

Create lifetimes.cpp:

#include <cstdio>

struct Trace {
    const char* name;

    explicit Trace(const char* label) : name(label) {
        std::printf("construct %-10s at %p\n",
                    name, static_cast<void*>(this));
    }

    ~Trace() {
        std::printf("destroy   %-10s at %p\n",
                    name, static_cast<void*>(this));
    }
};

Trace global_trace{"global"};

void visit() {
    Trace outer{"outer"};

    static Trace remembered{"remembered"};

    Trace* dynamic = new Trace{"dynamic"};

    {
        Trace inner{"inner"};
        std::puts("  using objects inside the inner block");
    }

    std::puts("  inner block has ended");
    delete dynamic;
    dynamic = nullptr;

    std::puts("  leaving visit");
}

int main() {
    std::puts("main begins");

    std::puts("first visit");
    visit();

    std::puts("second visit");
    visit();

    std::puts("main ends");
}

Build it with the familiar warning baseline:

g++ -std=c++20 -Wall -Wextra -Wpedantic -g -O0 lifetimes.cpp -o lifetimes
./lifetimes

The addresses will differ on your machine and may differ between runs. Ignore their numerical values; the relative order of the messages is what matters.

A simplified trace should look like this:

construct global     at ...

main begins
first visit
construct outer      at ...
construct remembered at ...
construct dynamic    at ...
construct inner      at ...
  using objects inside the inner block
destroy   inner      at ...
  inner block has ended
destroy   dynamic    at ...
  leaving visit
destroy   outer      at ...

second visit
construct outer      at ...
construct dynamic    at ...
construct inner      at ...
  using objects inside the inner block
destroy   inner      at ...
  inner block has ended
destroy   dynamic    at ...
  leaving visit
destroy   outer      at ...

main ends
destroy   remembered at ...
destroy   global     at ...

Read this output as an execution trace, not as a list of unrelated events.

1. global_trace: static storage duration

Trace global_trace{"global"};

global_trace is defined outside any function, so it has static storage duration.

  • It is constructed before main begins.
  • It remains alive during both calls to visit.
  • It is destroyed during normal program termination, after main has finished.

The name global_trace is visible in the rest of this translation unit after its declaration. Its broad visibility is separate from its long lifetime, though broad visibility is one reason mutable global state can make programs harder to reason about.

2. outer and inner: automatic storage duration

Trace outer{"outer"};

{
    Trace inner{"inner"};
}

Both objects are automatic locals.

  • outer begins its lifetime on each entry to visit.
  • inner begins when execution enters the nested block.
  • inner is destroyed immediately on leaving the nested block.
  • outer remains alive after inner is gone, then is destroyed when visit returns.

The reverse destruction order is deliberate. Within a given scope, later-constructed automatic objects are destroyed first. This property becomes essential when one object depends on another during cleanup.

3. remembered: static storage duration, narrow scope

static Trace remembered{"remembered"};

This declaration is inside visit, so its name is visible only inside visit. However, static changes the object’s storage duration:

  • remembered is initialized only on the first call that reaches its declaration.
  • The second call to visit does not construct another remembered.
  • The same object persists until normal program termination.
  • It is destroyed near the end of the program, after main ends.

A static local is therefore useful for state that must persist across calls while not being globally visible. A counter is the classic small example:

int next_id() {
    static int last_id = 0;
    return ++last_id;
}

Each call updates the same last_id; it is not recreated as zero on every invocation.

4. dynamic: dynamic storage duration

Trace* dynamic = new Trace{"dynamic"};

This single line creates two distinct objects:

  1. dynamic, an automatic pointer variable local to visit.
  2. A Trace object with dynamic storage duration, created by new.

The pointer is merely a value that can refer to the dynamically allocated object. It does not determine the dynamic object’s lifetime automatically.

delete dynamic;
dynamic = nullptr;

delete dynamic does two things:

  1. Calls the dynamic Trace object’s destructor.
  2. Releases its dynamically allocated storage.

After delete, dereferencing dynamic would be undefined behavior because the Trace object no longer exists. Assigning nullptr makes the pointer’s non-owning, no-object state explicit. The pointer variable still exists until visit returns, but it no longer refers to a live Trace.


Trace the program against a lifetime timeline

This table condenses the lab into the exact events that determine each object’s lifetime.

Program eventObjects constructedObjects destroyed
Before main startsglobal_trace
First entry to visitouter, then remembered, then dynamic Trace, then inner
First inner block endsinner
First delete dynamicfirst dynamic Trace
First visit returnsfirst outer
Second entry to visitsecond outer, second dynamic Trace, second inner
Second inner block ends and function returnsinner, dynamic Trace, outer
Normal program terminationremembered, then global_trace

Two observations should stand out:

  • The remembered object has function-local scope, but it outlives every call to visit.
  • Each dynamically allocated Trace outlives neither the first nor second call by accident; it ends exactly where delete is executed.

If you set a breakpoint at visit in GDB, the first and second stops are also a useful continuation of the previous lesson:

gdb ./lifetimes
break visit
run

At each stop, outer and the pointer variable dynamic belong to the current visit stack frame. The static local remembered, once initialized, is not recreated with that frame. The dynamically allocated Trace is separate again: its address is stored in dynamic, but it is not part of the frame’s automatic object set.


The failure modes this model prevents

Returning an address of an automatic object

This function is incorrect:

int* broken() {
    int local = 42;
    return &local;
}

local is destroyed when broken returns. The returned pointer may still contain an address-like value, but it does not point to a live int. Dereferencing it is undefined behavior.

A valid design normally returns the value itself:

int make_value() {
    return 42;
}

For collections or objects whose result should have independent ownership, C++ value types such as std::vector usually express the design more safely. The next lesson will address the ownership side directly with RAII and smart pointers.

Forgetting to destroy a dynamic object

This is a leak:

void leaking_function() {
    Trace* p = new Trace{"leaked"};
    // No delete p.
}

At the closing brace, p is destroyed because it is automatic. But destroying a raw pointer does not call delete on the object it points to. The dynamically allocated Trace remains alive and unreachable: a memory leak.

As a short experiment, comment out delete dynamic; in the lab program and build with AddressSanitizer:

g++ -std=c++20 -Wall -Wextra -Wpedantic -g -O0 \
    -fsanitize=address -fno-omit-frame-pointer \
    lifetimes.cpp -o lifetimes_asan

ASAN_OPTIONS=detect_leaks=1 ./lifetimes_asan

On a typical Linux/WSL setup, LeakSanitizer reports the unreleased allocation at program exit. Restore the delete afterward. Importantly, the operating system reclaiming process memory after termination does not make a leak correct: destructors and resource cleanup may have been skipped, and a long-running program can exhaust memory.

Mismatching array allocation and destruction

For raw dynamic arrays, the allocation and destruction forms must match:

int* values = new int[100];

// use values

delete[] values;

Using plain delete values; here is undefined behavior. In modern C++, prefer std::vector for dynamically sized collections; it gives the collection automatic lifetime management without manual new[] and delete[].


A reliable classification routine

When reading unfamiliar C++ code, classify each object using this sequence:

  1. Find the object itself. Do not confuse a pointer with the pointee.
  2. Locate its declaration or creation. Is it an ordinary local, a namespace-scope object, a static local, or an object created with new?
  3. Identify its construction point. For an automatic local, execution reaches the declaration; for a static local, the first execution reaches the declaration; for new, the allocation expression runs.
  4. Identify its destruction point. A block exit, program termination, or an explicit delete.
  5. Check whether a pointer, reference, iterator, or callback could outlive the object it refers to.

This is more dependable than asking only, “Is it on the stack or heap?” The latter question can be a useful debugging shortcut, but lifetime analysis catches the actual correctness issue: whether the object exists at the point where code uses it.


Takeaways

  • Automatic storage duration is the default for ordinary local objects. Their lifetimes end at block exit, and destructors run automatically in reverse construction order.
  • Dynamic storage duration comes from explicit allocation such as new. The pointer variable and the dynamically allocated object are separate objects with separate lifetimes; delete ends the pointee’s lifetime.
  • Static storage duration applies to namespace-scope objects and local static objects. Namespace-scope objects are generally initialized before main; static locals initialize once on first use. Both persist until normal program termination.
  • Scope, storage duration, and lifetime are related but different. A static local has narrow scope and long lifetime; a local pointer can have short automatic lifetime while referring to a longer-lived dynamic object.
  • Constructors and destructors provide a practical way to trace lifetime events, while GDB can show which automatic locals belong to the currently active frame.

Next, you will use this model to apply RAII and smart pointers to repair a resource leak. The key idea will be to place an owning object with automatic lifetime around a dynamically allocated resource, so cleanup occurs reliably at scope exit.

Can't find a good explanation? Sign up and we'll make it for you

Sign up