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.
#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
#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:
#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:
#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:
#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:
- A reference to a local, returned. Covered in the last chapter; the compiler catches the single-function case.
- 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, andppoints into freed memory on the next line. - 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.