Scope and lifetime

Scope is where a name can be written; lifetime is when the object it names exists. The two usually end together at the closing brace of a block, and the exceptions are what this lesson is about: a static local that survives the call, a global that lives for the whole program, a thread_local that each thread gets its own copy of.

Lesson 011 showed a reference outliving a local; this lesson shows the same mistake with a pointer, where GCC prints a warning instead of an error, and it puts the fix in terms of scope and storage duration.

Block scope and shadowing

A name declared inside a block is visible from its declaration to the closing brace. An inner block may declare the same name again, and from that point the inner one hides the outer one until the block ends.

The program declares value twice, prints the inner one and then the outer one after the block. The outer value was never touched: the inner declaration created a second int, and the outer name was simply out of view for three lines.

Shadowing is legal and easy to misread. GCC has -Wshadow for the cases it can see, which is not part of -Wall -Wextra because it fires on many harmless names; enable it when a function grows long enough for the confusion to be likely.

#include <iostream> int main() { int value = 1; { int value = 2; std::cout << "inner: " << value << '\n'; } std::cout << "outer: " << value << '\n'; }

g++ -std=c++23 -Wall -Wextra block.cpp -o block
./block

inner: 2
outer: 1

A static local outlives the call

A local declared static is created once, on the first execution of its declaration, and destroyed when the program ends. The function can use it as a counter or a cache across calls without passing it in.

The program calls next_id three times and gets 1, 2 and 3. The declaration static int id = 0; runs once; every later call skips it and works with the same int.

Static locals are initialised in a thread-safe way since C++11, but a shared counter of this kind is still not a substitute for a proper id generator in concurrent code. Lesson 108 returns to that problem.

#include <iostream> int next_id() { static int id = 0; ++id; return id; } int main() { std::cout << next_id() << ' ' << next_id() << ' ' << next_id() << '\n'; }

g++ -std=c++23 -Wall -Wextra static_local.cpp -o static_local
./static_local

1 2 3

Globals around main

A global object is constructed before main starts and destroyed after it returns. The program shows the order with a small struct that prints in its constructor and its destructor; part 3 explains how to write those, here only the timing matters.

The global is constructed first, then the local inside main, then the body runs, then the local is destroyed at the closing brace, and the global last of all. A program with several globals in several files has an order that depends on the link order, which is why globals that depend on each other are a known source of bugs.

A static local that is never called is never constructed, which is one reason to prefer it to a global when the object is expensive and often unused.

#include <iostream> struct tracer { const char* name; explicit tracer(const char* n) : name(n) { std::cout << "construct " << name << '\n'; } ~tracer() { std::cout << "destroy " << name << '\n'; } }; tracer global{"global"}; int main() { tracer local{"main"}; std::cout << "body\n"; }

g++ -std=c++23 -Wall -Wextra globals.cpp -o globals
./globals

construct global
construct main
body
destroy main
destroy global

One copy per thread

thread_local gives each thread its own instance of the variable. The program starts two threads that each increment the same-named counter three times; each thread sees 3, and main sees 0 because main never incremented its own copy.

The two thread lines may appear in either order and even interleaved on a loaded machine; run the program a few times and the order changes. That is the first hint that threads need more than separate variables, which is the subject of part 10, starting with lesson 105.

The -pthread flag links the thread library and defines the macros the standard library needs. Without it the link fails on this toolchain, and the error names the missing pthread symbols.

#include <iostream> #include <thread> thread_local int counter = 0; void count(const char* name) { for (int i = 0; i < 3; ++i) { ++counter; } std::cout << name << ": " << counter << '\n'; } int main() { std::thread a(count, "thread a"); std::thread b(count, "thread b"); a.join(); b.join(); std::cout << "main: " << counter << '\n'; }

g++ -std=c++23 -Wall -Wextra -pthread thread_local.cpp -o thread_local
./thread_local

thread a: 3
thread b: 3
main: 0

A pointer to a local that is gone

A pointer does not keep its target alive. The program takes the address of a local inside a block, keeps the pointer after the block ends, and reads through it; the int it pointed at no longer exists, and the read is undefined behaviour.

GCC tracks the address through the simple cases and warns that the pointer dangles, naming the variable and the line it was declared on. The warning is not guaranteed for every path through a program, so the program is compiled but not run here.

The fix is to keep the object alive for as long as the pointer is used, or to return the value instead of its address. References have the same rule, and lesson 011 showed the version of this mistake that does not even compile.

#include <iostream> int main() { int* p = nullptr; { int local = 5; p = &local; } std::cout << *p << '\n'; }

g++ -std=c++23 -Wall -Wextra dangling.cpp -o dangling

dangling.cpp: In function 'int main()':
dangling.cpp:10:19: warning: using dangling pointer 'p' to 'local' [-Wdangling-pointer=]
10 | std::cout << *p << '\n';
| ^
dangling.cpp:7:13: note: 'local' declared here
7 | int local = 5;
| ^~~~~

Exercises

  1. Move the static in next_id inside a loop body and call the function from two loops. What does the counter do, and why?
  2. Add a second global tracer to globals.cpp and compare the destruction order with the construction order.
  3. Remove -pthread from the thread_local build and read the linker error. Which symbol does the linker report first?

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What is the difference between scope and lifetime?

    Answer

    Scope is where a name is visible, lifetime is when the object exists

    A name can be out of scope while the object still lives, as a reference to a local shows.

  2. What does a static local variable do?

    Answer

    It is created once and keeps its value between calls

    The object has static storage and its initialisation runs once.

  3. When are global objects constructed and destroyed?

    Answer

    Constructed before main and destroyed after it returns

    The runtime runs the constructors before main and the destructors after it, in reverse order.

  4. What does thread_local give a variable?

    Answer

    One copy per thread

    Each thread sees its own object, which is why the two threads and main print different values.

  5. What does this program print? #include <iostream> int main() { int value = 1; { int value = 2; std::cout << "inner: " << value << " / "; } std::cout << "outer: " << value; }

    Answer

    inner: 2 / outer: 1

    The inner declaration hides the outer one, and the outer value is unchanged after the block.

  6. What does this program print? #include <iostream> int next() { static int value = 0; return ++value; } int main() { std::cout << next() << ' ' << next() << ' ' << next(); }

    Answer

    1 2 3

    The static local is created once, and each call increments the same object.

  7. What does this program print? #include <iostream> struct marker { const char* name; ~marker() { std::cout << "destroy " << name << '\n'; } }; marker global{"global"}; int main() { marker local{"main"}; std::cout << "body\n"; }

    Answer

    body / destroy main / destroy global

    The local dies when main returns, and the global dies after it, in reverse order of construction.

  8. What does this program print? #include <iostream> #include <thread> thread_local int counter = 0; int main() { int first = 0; int second = 0; std::thread a([&] { counter += 3; first = counter; }); std::thread b([&] { counter += 3; second = counter; }); a.join(); b.join(); std::cout << "thread a: " << first << '\n' << "thread b: " << second << '\n' << "main: " << counter; }

    Answer

    thread a: 3 / thread b: 3 / main: 0

    Each thread increments its own copy three times, and main sees its own untouched copy.

  9. The build command is g++ -std=c++23 -Wall q.cpp. What does the compiler warn about? int* p; { int local = 5; p = &local; } std::cout << *p;

    Answer

    using dangling pointer 'p' to 'local'

    The pointer outlives the block, and the warning names the pointer and the object.

  10. This code is built with g++ -std=c++23 -Wall -Wextra q.cpp, and the compiler warns that the pointer dangles. Why is the pointer invalid? int* p = nullptr; { int local = 5; p = &local; } std::cout << *p << '\n';

    Answer

    The local object was destroyed at the end of its block

    The storage of the local is gone when the block ends, and dereferencing the old address is undefined.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev