The C++ Textbook

Part 2 · Memory and objects

Lifetime and scope

When an object starts existing, when it stops, and what touching it outside that window costs.

By the end of this chapter you can

  • State the lifetime of automatic, static, and dynamic objects
  • Spot a returned reference to a local
  • Explain why destruction order is the reverse of construction

The last two chapters both ended at the same cliff: a pointer or reference that outlived the thing it named. This chapter is the map of that cliff.

Two words that get used interchangeably are worth separating first.

  • Scope is a region of source text where a name is usable. It is a compile-time idea; the compiler enforces it, and getting it wrong is an error.
  • Lifetime is the span of run time during which an object exists. It is a run-time idea; nothing enforces it, and getting it wrong is undefined behaviour.

For ordinary local variables the two line up, which is why they get conflated. Everywhere else they come apart, and that gap is where the bugs live.

The three storage durations

Every object gets its storage in one of three ways, and that choice decides its lifetime.

Three lifetimes in one program
#include <iostream>
#include <memory>

int counter() {
    static int calls = 0;   // static: created once, lives until the program ends
    return ++calls;
}

int main() {
    int automatic = 1;                              // automatic: lives until }

    std::unique_ptr<int> dynamic = std::make_unique<int>(2);  // dynamic: lives
                                                              // until released

    std::cout << "automatic " << automatic << ", dynamic " << *dynamic << '\n';
    std::cout << "counter: " << counter() << counter() << counter() << '\n';
}

Automatic objects — ordinary local variables — are created when control reaches the declaration and destroyed when control leaves the enclosing block, by any route. Their storage is the stack, which is why it costs essentially nothing: entering a function moves one register.

Static objects live from first use (or program start) until after main returns. calls above keeps its value across calls because it is not recreated each time.

Dynamic objects are created by new (usually via a smart pointer) and live until explicitly released. This is the only one where you choose the endpoint, and therefore the only one you can get wrong in both directions — releasing too early, or never.

A block is a lifetime

Watching construction and destruction
#include <iostream>
#include <string>

struct Noisy {
    std::string name;
    explicit Noisy(std::string n) : name(std::move(n)) {
        std::cout << "  + " << name << " constructed\n";
    }
    ~Noisy() {
        std::cout << "  - " << name << " destroyed\n";
    }
};

int main() {
    std::cout << "entering main\n";
    Noisy outer{"outer"};

    {
        std::cout << "entering block\n";
        Noisy inner{"inner"};
        std::cout << "leaving block\n";
    }

    std::cout << "back in main\n";
}

Three things to take from that output.

inner was destroyed at the closing brace, not at the end of main. A block — any pair of braces — is a lifetime boundary.

Destruction ran in reverse order of construction. This is guaranteed, and it is not arbitrary: a later object may have been built using an earlier one, so the earlier one must still be alive while the later one is torn down.

And the destructor ran without you writing anything at the end of the block. That automatic, guaranteed cleanup is the mechanism the whole language leans on for resource management — Chapter 3.2 gives it a name and builds on it.

Destruction happens on every exit path

Including the ones you did not write:

Early return, and an exception
#include <iostream>
#include <stdexcept>
#include <string>

struct Noisy {
    std::string name;
    explicit Noisy(std::string n) : name(std::move(n)) { std::cout << "  + " << name << '\n'; }
    ~Noisy() { std::cout << "  - " << name << '\n'; }
};

void early_return(bool bail) {
    Noisy guard{"guard"};
    if (bail) {
        std::cout << "returning early\n";
        return;              // guard is destroyed here
    }
    std::cout << "reaching the end\n";
}

void throws() {
    Noisy guard{"thrown-past"};
    throw std::runtime_error("something failed");
}

int main() {
    early_return(true);
    early_return(false);

    try {
        throws();
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << '\n';
    }
}

guard is destroyed on the early return and on the way out through the throw. Stack unwinding destroys every automatic object between the throw and the handler, in reverse order. There is no path out of a scope that skips destructors.

Static objects, and the trap in their order

Static objects inside a function are created on first use, which is well defined. Static objects at namespace scope, across different source files, are not:

Function-local statics are initialised on first use
#include <iostream>

struct Config {
    Config() { std::cout << "  Config built\n"; }
    int retries = 3;
};

const Config& config() {
    static Config instance;   // built the first time this runs, never again
    return instance;
}

int main() {
    std::cout << "before first call\n";
    std::cout << "retries: " << config().retries << '\n';
    std::cout << "retries: " << config().retries << '\n';
}

Config was built once, at the first call, not at program start. That pattern — a function-local static returned by reference — is the standard fix for the static initialisation order fiasco: two namespace-scope statics in different translation units have no defined initialisation order relative to each other, so if one’s constructor uses the other, it may run first and read a not-yet-constructed object. Wrapping each in a function makes “first use” the ordering, which is always correct.

Function-local static initialisation is also thread-safe since C++11: if two threads reach it at once, one initialises and the other waits.

Dangling, precisely

Now the rule can be stated exactly. A pointer or reference is valid only within the lifetime of the object it names. Every dangling bug is a violation of that one sentence, and they come in a small number of shapes:

Four shapes of the same mistake
#include <iostream>
#include <string>
#include <vector>

int main() {
    // 1. Referring into a container that reallocates.
    std::vector<int> v{1, 2, 3};
    int* first = &v[0];
    v.push_back(4);              // may reallocate, moving every element
    std::cout << "stale element: " << *first << '\n';   // undefined
}

The other three, which you now have the vocabulary for:

  1. A reference to a local, returned. Covered in the last chapter; the compiler catches the single-function case.
  2. A pointer into a temporary that has been destroyed. const char* p = std::string("hi").c_str(); — the string dies at the end of the full expression, and p points into freed memory on the next line.
  3. A pointer to a dynamic object after delete. The classic use-after-free; the pointer is unchanged, the memory is not yours.

Container invalidation, shape 1, is the one that surprises people most, because nothing was deleted and no scope was left. push_back needed more room, allocated a bigger block, moved the elements, and freed the old one — and every pointer, reference, and iterator into the old block became stale. Chapter 4.2 gives the exact rules for which operations invalidate what.

Check yourself

Practice