Scope
How C++ scope rules govern name visibility, lookup order, and entity lifetime — from block and function scope to namespace and class scope.
Scopesince C++98The scope of a name is the contiguous region of program text in which that name can be used without qualification.
Overview
Scope is one of the most pervasive concepts in C++. Every name — variable, function, type, template, namespace — has a scope that determines where it is visible. Understanding scope is prerequisite knowledge for name lookup, linkage reasoning, lifetime analysis, and API design.
C++ defines several distinct scope categories:
| Scope kind | Introduced by |
|---|---|
| Block scope | A {...} compound statement |
| Function parameter scope | A function declarator's parameter list |
| Function scope | The entire function body (labels only) |
| Namespace scope | A namespace definition or the implicit global namespace |
| Class scope | A class, struct, or union definition |
| Enumeration scope | An enum class / enum struct (C++11) |
| Template parameter scope | A template parameter list |
Block Scope
A name declared inside braces has block scope: it is visible from its point of declaration to the closing brace of the innermost enclosing block.
void process(int n) {
if (n > 0) {
int result = n * 2; // visible until '}'
use(result);
}
// result is not in scope here — compile error if referenced
}A name in an inner block may shadow a name in an enclosing scope. The outer name becomes inaccessible by simple name; a qualified name is required:
int x = 10;
void f() {
int x = 20; // shadows ::x
::x = 30; // explicit qualification reaches the global x
}Shadowing is legal but a persistent source of subtle bugs. Enable -Wshadow (GCC/Clang) or /W4 (MSVC) to catch unintentional hiding.
for-loop scope
A variable declared in a for init-statement is scoped to the entire loop — condition, increment, and body — but not beyond:
for (int i = 0; i < n; ++i) {
// i is in scope here
}
// i is not in scope here — intentional containmentif / switch initialisers (C++17)
Since C++17, an optional init-statement may appear between the keyword and the condition in if and switch. The declared variable is in scope for the entire if-else chain but not for the surrounding block:
// C++17
if (auto it = map.find(key); it != map.end()) {
use(it->second);
}
// it is out of scope — iterator not leaked into surrounding code
// Useful for scoping a lock tightly:
if (std::lock_guard<std::mutex> lg{mu}; !queue_.empty()) {
process(queue_.front());
queue_.pop();
} // lg released here, not at end of enclosing functionNamespace Scope
A name declared directly inside a named namespace, or at global scope (the implicit global namespace ::) has namespace scope. Such names persist for the entire translation unit and are reachable from other translation units when they carry external linkage.
namespace geometry {
struct Point { double x, y; }; // namespace scope
double distance(Point a, Point b); // namespace scope
}
geometry::Point origin{0.0, 0.0}; // qualified access from outsideThe unary ::name form explicitly names the global namespace, useful for disambiguating when a local name shadows a global one.
Class Scope
Names declared inside a class body have class scope and are accessible via the class name (for static members and nested types), via an object or reference (for non-static members), or unqualified within member function bodies.
class Timer {
public:
void start();
void stop();
private:
using Clock = std::chrono::steady_clock; // class scope
Clock::time_point begin_;
};
void Timer::start() {
begin_ = Clock::now(); // Clock is visible in class scope
}A critical property: the entire class body is a single scope, so all member declarations are mutually visible regardless of order. This differs from block scope, where a name is only visible after its point of declaration.
struct Foo {
void f() { g(); } // OK — g declared below, still visible
void g() {}
};This property enables forward references among member functions and is what makes circular member references (e.g., next_ pointing to Foo*) work without forward declarations inside the class.
Enumeration Scope (C++11)
Unscoped enumerations inject their enumerators into the enclosing scope, polluting it with bare names. Since C++11, scoped enumerations (enum class or enum struct) confine enumerators to the enumeration's own scope, requiring explicit qualification:
enum Color { Red, Green, Blue }; // Red, Green, Blue pollute enclosing scope
enum class Direction { North, South, East, West }; // C++11
Direction d = Direction::North; // bare 'North' does not exist herePrefer enum class in all new code, especially in headers included widely, to avoid silent name collisions.
Scope vs. Lifetime
Scope is a compile-time, textual property: it determines where a name is visible in source. Lifetime (storage duration) is a runtime property: it determines when the underlying object exists in memory. The two are related but independent.
void counter() {
static int n = 0; // block scope — 'n' visible only here
++n; // but the object lives for the program's lifetime
}
int* p = new int{42}; // the object outlives any named variableA local static is the canonical example of narrow scope combined with extended lifetime. A dynamically allocated object is the dual: no named scope at all, but lifetime governed entirely by delete (or a smart pointer destructor).
Scope and Name Lookup
When the compiler resolves an unqualified name, it searches scopes outward from the point of use: innermost block, enclosing blocks, enclosing namespace(s), the global namespace. Argument-dependent lookup (ADL) additionally searches the namespaces associated with a call expression's argument types — the mechanism that makes operator overloads and customisation points work across namespace boundaries.
namespace math {
struct Vec2 { float x, y; };
Vec2 operator+(Vec2 a, Vec2 b) { return {a.x + b.x, a.y + b.y}; }
}
void f() {
math::Vec2 u{1, 0}, v{0, 1};
auto w = u + v; // ADL finds math::operator+ without any qualification
}Best Practices
Declare names in the narrowest scope that satisfies correctness. This shrinks the window in which a variable can be misused, makes local reasoning possible, and gives destructors a predictable invocation point.
// Prefer — key scoped to the loop iteration
for (const auto& rec : records) {
std::string key = compute_key(rec);
cache[key] = rec;
}
// Avoid — key outlives the loop for no reason
std::string key;
for (const auto& rec : records) {
key = compute_key(rec);
cache[key] = rec;
}Use C++17 if-init to bind lookup results and guards at the point of use, preventing them from leaking into code that should not see them.
Prefer enum class over plain enum to keep enumerator names out of the enclosing namespace.
Common Pitfalls
Dangling references to block-scoped objects. Returning a pointer or reference to a local variable is undefined behaviour; the object ceases to exist at the closing brace.
int* bad() {
int x = 42;
return &x; // UB: x is destroyed on return
}Lambda captures and scope. A lambda that captures a local by reference must not outlive that variable's scope. This is a common error when lambdas are stored in callbacks or dispatched to thread pools:
std::function<int()> make_counter() {
int n = 0;
return [&n]() { return ++n; }; // UB: n is destroyed on return
// Fix:
return [n]() mutable { return ++n; }; // capture by value
}Unscoped enum leakage. Adding an enumerator to a widely included plain enum can silently clash with other names in the enclosing namespace, introducing a breaking change for all consumers of that header.
Variable shadowing across if-else branches. When an if-init variable is shadowed inside the body, the outer variable — including any RAII guard — may be partially invisible, obscuring which name is active.
Point of Declaration
A name becomes visible at its point of declaration, which is immediately after the declarator — not at the end of the full declaration statement. This distinction matters when the initializer references the name being declared.
int x = 10;
void f() {
int x = x + 1; // UB: the inner x shadows the outer x at the '=' token
// x is visible from 'x' in the declarator, before its
// initializer runs — so the right-hand 'x' is the new,
// uninitialized inner x, not the outer x = 10
}The variable x in the initializer x + 1 refers to the inner (not yet initialized) x, making this undefined behavior — not a read of the outer x. Compilers may warn under -Wuninitialized or -Winit-self, but the code is syntactically valid.
A more subtle variant using the comma operator is equally problematic:
int x = (x = 0, x + 1); // still UB: x is in scope but indeterminate
// before 'x = 0' runs; the comma operator does
// not change that x's lifetime begins hereThe rule also affects class member initializers in constructors. Member names are always visible throughout the entire class body, so this does not arise the same way there — but function-local declarations strictly obey the point-of-declaration rule.
Practical implication: never use a variable in its own initializer unless it was previously declared (e.g., int x; x = x + 1; — legal, though x is uninitialized). Prefer giving the new variable a different name.
Lambda Capture and Scope
A lambda creates a closure object at the point of the lambda expression, capturing variables from the enclosing scope according to the capture list. Captures are evaluated at the time the lambda is created, not when it is called.
By-value capture ([=], [x])
A by-value capture copies the variable at lambda-creation time. The closure object owns an independent member initialized from that copy:
int base = 10;
auto add = [base](int n) { return base + n; }; // base copied: closure.base = 10
base = 99; // does not affect the copy inside `add`
add(5); // returns 15, not 104[=] captures every ODR-used local by value. In C++20, [=] inside a member function no longer implicitly captures this — use [=, this] or [*this] explicitly.
By-reference capture ([&], [&x])
A by-reference capture stores a reference to the original variable. The lambda must not outlive the variable:
// Safe: lambda used within the variable's scope
int total = 0;
std::for_each(v.begin(), v.end(), [&total](int x) { total += x; });
// Unsafe: returned lambda outlives the local it references
auto make_adder(int n) {
return [&n](int x) { return x + n; }; // UB: n destroyed on return
}
// Fix: capture by value
auto make_adder(int n) {
return [n](int x) { return x + n; }; // n copied into closure
}The lifetime mismatch between a captured reference and the referent is one of the most common sources of undefined behavior in C++. It is easily triggered when lambdas are stored in callbacks, passed to thread pools, or returned from factory functions.
Init captures (C++14): rebinding and move-only types
Init captures let you introduce a new closure member initialized from any expression, decoupling the closure member's name and value from any enclosing variable:
// C++14: capture a computed value, not an existing variable
auto cb = [limit = config.maxRetries() * 2](int attempt) {
return attempt < limit;
};
// C++14: move a unique_ptr into the closure — impossible with [=]
auto ptr = std::make_unique<Widget>(42);
auto task = [p = std::move(ptr)]() { p->run(); };if, switch, and Loop Init-Statements (C++17)
C++17 extended if, switch, while, and range-for with optional init-statements that tightly scope a variable to the control structure:
if and switch init-statements
// C++17: variable scoped to the entire if-else chain
if (auto it = map.find(key); it != map.end()) {
use(it->second);
} else {
log_missing(key); // it is still in scope here
}
// it is out of scope here
// Scope a mutex lock to the guarded block
if (std::lock_guard<std::mutex> lg{mu}; !queue_.empty()) {
process(queue_.front());
queue_.pop();
} // lg.~lock_guard() called here, not at end of enclosing function
// switch init — 'status' visible in all cases and the else
switch (int status = get_status(); status) {
case 0: handle_ok(); break;
case 1: handle_error(); break;
default: handle_unknown(status);
}The init-statement variable is in scope for the entire if-else or switch, including all case labels and the else branch. It is not in scope outside the statement.
RAII scoping benefit
Before C++17, a common pattern to limit a lock's scope was to introduce an extra pair of braces:
// Pre-C++17
{
std::lock_guard<std::mutex> lg{mu};
if (!queue_.empty()) {
process(queue_.front());
queue_.pop();
}
} // lock released here
// C++17: same scoping, no extra braces needed
if (std::lock_guard<std::mutex> lg{mu}; !queue_.empty()) {
process(queue_.front());
queue_.pop();
}The C++17 form is more expressive: it makes explicit that the lock exists only to guard the condition check and the body.
Structured Binding Scope
A structured binding declaration introduces an anonymous entity initialized from the right-hand side, and then binds the declared names as references into that entity's elements. Both the anonymous entity and the binding names are in the scope of the enclosing block.
// The anonymous entity e is created in block scope:
// auto e = getpair();
// Then a and b refer into e's members.
auto [a, b] = getpair();Lifetime of temporaries
Binding auto&& to a temporary extends the temporary's lifetime to the scope of the binding declaration — the same rule as binding a const lvalue reference to a temporary:
// Safe: temporary's lifetime is extended to match the structured binding scope
const auto& [x, y] = std::pair{1, 2}; // pair lives until end of enclosing block
auto&& [x, y] = std::pair{1, 2}; // also extends lifetime
// UB: auto& does NOT extend temporary lifetime
auto& [x, y] = std::pair{1, 2}; // pair is immediately destroyedThe critical rule: only const auto& and auto&& extend temporary lifetime. A plain auto& (lvalue reference to non-const) cannot bind to a temporary and would be rejected by the compiler; if somehow formed, it would dangle.
Scope of the anonymous entity
The anonymous entity persists for the entire block. Modifying through the bindings modifies the underlying object:
std::pair<int, int> p{10, 20};
auto& [lo, hi] = p; // lo and hi refer directly into p (not a copy)
lo = 5; // p.first is now 5Lambda capture of structured binding names (C++20)
In C++17, capturing structured binding names in lambdas was under-specified and rejected by some compilers. C++20 fully standardized this:
auto [x, y] = std::pair{3, 4};
// C++20: fully portable
auto dist_sq = [x, y]() { return x*x + y*y; };
auto incr = [&x, &y]() { ++x; ++y; }; // modifies the anonymous entityScope of Labels
Labels in C++ have function scope, not block scope. A label defined inside an if body, a loop, or any nested block is visible from anywhere in the same function — including code textually before the label:
void f() {
goto end; // forward jump to a label in a nested block — legal
{
{
end: // visible throughout all of f()
;
}
}
}This is in sharp contrast to variable names, which are only visible from their point of declaration to the end of their enclosing block.
Forward jumps over variable initializations
While the jump itself is legal, jumping forward over a variable's initialization is ill-formed when the variable has an initializer or a non-trivial constructor or destructor:
goto skip;
int n = 42; // ERROR: has initializer — jump over it is ill-formed
std::string s; // ERROR: non-trivial constructor/destructor
skip:
;Jumping over a declaration without an initializer that has a scalar or trivially-constructible/destructible type is syntactically permitted, but reading the uninitialized variable afterward is undefined behavior.
The same restriction applies to switch statements — jumping from the switch condition into a case that follows a variable initialization in a sibling case is ill-formed for the same reason.
Labels cannot be exported or qualified
Because labels have function scope, they cannot be referenced from outside the function, and there is no syntax to qualify a label name. Duplicate label names within the same function are a compile error.
C++20 Modules and Scope
C++20 modules introduce a new visibility boundary that operates alongside (not instead of) namespace scope.
Module scope vs. namespace scope
In a traditional translation unit, any name with external linkage is reachable from other translation units by including the appropriate header. In a module, a name with external linkage that is not exported has module linkage: it is visible within the module (across all its implementation units and partitions) but not to importers, even via full qualification:
// geometry.cppm — module interface unit
export module geometry;
constexpr double kPi = 3.14159265358979; // module linkage: not exported
// importers cannot name this, even as
// geometry::kPi — the name does not exist
// in the importer's scope at all
export struct Point { double x, y; }; // exported: visible to importers
export double distance(Point a, Point b); // exportedAn importer sees only what is explicitly exported. Unlike a header's static or anonymous-namespace trick (which gives internal linkage per-TU), module linkage is shared across all TUs that are part of the same module:
// geometry_impl.cpp — module implementation unit
module geometry; // same module, not "export module"
// kPi is visible here — module linkage spans TUs in the same module
double distance(Point a, Point b) {
double dx = a.x - b.x, dy = a.y - b.y;
return std::sqrt(dx*dx + dy*dy);
}Exported names enter the importer's scope
When code writes import geometry;, all exported names become available in the importer's scope as if they were declared in a namespace, but without macro leakage:
import geometry;
void use() {
Point p{1.0, 2.0}; // exported struct
double d = distance(p, {}); // exported function
// kPi is inaccessible — not exported, has module linkage
}Macro isolation
Macros do not cross module boundaries in either direction. A macro defined inside a module interface unit does not affect importers. A macro active at the import site does not affect the module's compiled interface. This is a categorical improvement over the #include model, where macro definitions leak freely through header inclusion.
Implications for ADL
Argument-dependent lookup works normally across module boundaries for exported names. If Point is exported from geometry, then a call to an exported distance(Point, Point) found via ADL works exactly as it would with a header. Non-exported names are invisible to ADL from outside the module.
See Also
reference/idioms/scope-guard— RAII wrapper that executes a callback when a scope is exited, regardless of howreference/language/adl— how argument-dependent lookup extends unqualified name search beyond the immediately enclosing scopes