Variables, Scope & Lifetime
Learn the four storage durations, how scope and lifetime are distinct concepts, initialization pitfalls, and the static initialization order fiasco.
Scope and Lifetime Are Not the Same Thing
One of the most common misconceptions in C++ is conflating scope with lifetime. They are related but distinct concepts:
Scope is a compile-time property — it determines where a name is visible in the source code. A variable declared inside a block {} is visible only within that block and any nested blocks.
Lifetime is a runtime property — it determines when an object exists in memory. An object's lifetime begins when its constructor completes and ends when its destructor starts.
In many cases, scope and lifetime coincide: a local variable is visible within its block and lives until the block ends. But they can diverge. A static local variable is only visible inside its function (limited scope) but lives for the entire program (unlimited lifetime). A dynamically allocated object has a lifetime that extends beyond the scope of the pointer that created it.
The Four Storage Durations
C++ defines four storage durations that control when objects are created and destroyed:
#include <iostream>
#include <string>
// Static storage duration — lives for the entire program
int global_count = 0; // Initialized before main()
static int file_local = 42; // Same, but only visible in this file
void demonstrate_storage() {
// Automatic storage duration — created on entry, destroyed on exit
int local = 10; // Born here, dies at closing brace
// Static storage duration (local scope)
static int call_count = 0; // Initialized ONCE, persists across calls
++call_count;
std::cout << "Call #" << call_count << '\n';
// Thread-local storage duration (C++11)
// thread_local int per_thread = 0; // One copy per thread, lives until thread ends
}
int main() {
demonstrate_storage(); // Call #1
demonstrate_storage(); // Call #2 — call_count is still alive!
demonstrate_storage(); // Call #3
// Dynamic storage duration — you control the lifetime
int* heap_int = new int{99}; // Born here
std::cout << *heap_int << '\n';
delete heap_int; // Dies here — you must not forget!
heap_int = nullptr; // Good practice: nullify after delete
// In modern C++, prefer smart pointers over raw new/delete
// auto ptr = std::make_unique<int>(99); // Covered in later modules
}Initialization Forms and Zero-Initialization
C++ has three fundamental initialization concepts:
Default initialization — the variable is constructed without an initializer. For built-in types (int, double, etc.), this means the value is indeterminate (reading it is undefined behavior!). For class types, the default constructor is called.
Value initialization — triggered by empty parentheses T() or empty braces T{}. For built-in types, this zero-initializes: integers become 0, pointers become nullptr, booleans become false. For class types, the default constructor is called (and members are value-initialized if no constructor initializes them).
Zero initialization — a specific phase that occurs for static/thread-local variables before any other initialization. All static variables are zero-initialized before the program starts.
This is why static int x; is guaranteed to be 0, but a local int x; is not — the local variable is default-initialized (indeterminate), while the static variable goes through zero-initialization first.
Initialization in Practice
Understanding when a variable is zero versus indeterminate is critical for avoiding bugs:
#include <iostream>
struct Point {
int x;
int y;
};
static int global_int; // Zero-initialized → 0
static Point global_point; // Zero-initialized → {0, 0}
int main() {
int a; // Default-initialized → INDETERMINATE (UB to read!)
int b{}; // Value-initialized → 0
int c = int(); // Value-initialized → 0
int d = {}; // Value-initialized → 0
Point p1; // Default-initialized → members are INDETERMINATE
Point p2{}; // Value-initialized → {0, 0}
Point p3{1, 2}; // Aggregate-initialized → {1, 2}
// Array initialization
int arr1[5]; // Default-initialized → INDETERMINATE values!
int arr2[5]{}; // Value-initialized → {0, 0, 0, 0, 0}
int arr3[5]{1, 2}; // Partially initialized → {1, 2, 0, 0, 0}
std::cout << "global_int: " << global_int << '\n'; // Safe: 0
std::cout << "b: " << b << '\n'; // Safe: 0
// std::cout << a; // DANGER: UB — reading uninitialized variable!
std::cout << "p2: (" << p2.x << ", " << p2.y << ")\n"; // Safe: (0, 0)
std::cout << "p3: (" << p3.x << ", " << p3.y << ")\n"; // Safe: (1, 2)
}Destruction Order: LIFO
Objects with automatic storage duration (local variables) are destroyed in the reverse order of their construction — Last In, First Out (LIFO). This is not just a convention; the standard guarantees it.
This matters enormously when objects depend on each other. If object b references object a, you must declare a before b. Then b is destroyed first (while a is still alive), and a is destroyed second. If the order were reversed, b's destructor might try to use a dead a — undefined behavior.
For static variables, destruction happens in the reverse order of construction after main() returns. Global variables defined in the same translation unit are constructed top-to-bottom and destroyed bottom-to-top. But across translation units, the construction order of globals is unspecified — this is the static initialization order fiasco.
The static initialization order fiasco is one of the most notorious pitfalls in C++. When you have global or static variables in different translation units (different .cpp files), the C++ standard does not specify the order in which they are initialized. If one global depends on another from a different translation unit, it may be used before it is constructed.
The classic fix is the Construct on First Use idiom: replace the global variable with a function that returns a reference to a local static variable. Local statics are guaranteed to be initialized the first time the function is called (and this is thread-safe since C++11).
- The order of initialization of globals across translation units is unspecified
- If global A (in file1.cpp) depends on global B (in file2.cpp), B might not exist yet when A is constructed
- Fix: use the Construct on First Use idiom — wrap globals in functions with local statics
- Local static initialization is thread-safe since C++11 (known as 'magic statics')
The Construct on First Use Idiom
This pattern solves the static initialization order fiasco by deferring construction until first use:
#include <string>
#include <iostream>
// BAD: global depends on another global — order is unspecified across files
// std::string bad_prefix = "[LOG] "; // What if this is used before construction?
// GOOD: Construct on First Use idiom
std::string& log_prefix() {
static std::string prefix = "[LOG] "; // Constructed on first call, thread-safe
return prefix;
}
std::string& app_name() {
static std::string name = "MyApp";
return name;
}
void log(const std::string& message) {
// Safe: log_prefix() and app_name() are guaranteed to be initialized
std::cout << log_prefix() << app_name() << ": " << message << '\n';
}
int main() {
log("Starting up");
log("Ready");
}- Scope (where a name is visible) and lifetime (when an object exists) are distinct concepts that can diverge
- C++ has four storage durations: automatic (stack), static (program lifetime), thread-local, and dynamic (heap)
- Default-initialized built-in types have indeterminate values — reading them is undefined behavior
- Value initialization with {} guarantees zero-initialization for built-in types
- Local variables are destroyed in LIFO order — declare dependencies before their dependents
- Use the Construct on First Use idiom to avoid the static initialization order fiasco across translation units
Quiz — Test Your Knowledge
(15 XP)1. What is the value of a local `int x;` (without an initializer) in C++?
2. What is the static initialization order fiasco?
3. How does `static int count = 0;` inside a function differ from `int count = 0;`?