Statements
Complete reference for all C++ statement categories — expression, compound, selection, iteration, jump, declaration, and try-blocks — with version-tagged features.
StatementA statement is the basic unit of execution in C++: a complete instruction that the implementation executes in sequence, branching, or repeating according to the statement's form.
Overview
C++ organizes statements into eight categories. Every function body is a compound statement, and each category has precise rules about scope, initialization, and control transfer.
| Category | Forms |
|---|---|
| Expression statement | any expression followed by ; |
| Compound statement | { stmt... } |
| Selection statement | if, switch |
| Iteration statement | for, while, do-while, range-for |
| Jump statement | return, break, continue, goto |
| Declaration statement | introduces a name into the enclosing scope |
| Try block | try compound-statement handler-seq |
| Null statement | lone ; |
Attributes may appear at the start of any statement since C++11.
Expression Statements
An expression followed by a semicolon discards the result after applying all side effects. This covers assignments, function calls, pre/post-increment, and anything else that reduces to an expression.
x = 42; // assignment expression
std::cout << x; // operator<< call
++it; // increment
(void)f(); // cast to void — suppresses [[nodiscard]] warning (C++17)The explicit (void) cast is idiomatic when you intentionally discard a [[nodiscard]] return value. Casting to void is defined behavior; an unused-expression statement with a [[nodiscard]] function is a diagnostic.
Compound Statements (Blocks)
Braces { } group zero or more statements into a single statement and introduce a new block scope. Variables declared inside are destroyed when control leaves the closing brace, in reverse order of construction — the fundamental mechanism behind RAII.
{
std::lock_guard<std::mutex> lk(mtx); // acquired here
data.push_back(value);
} // lk destroyed, mutex releasedA compound statement with no statements is valid and commonly used as a do-nothing body.
Selection Statements
if and if-else
The condition may be an expression or a single declaration. Since C++17, an optional init-statement can precede the condition, keeping auxiliary variables scoped to the if-else block:
// C++17 — init-statement in if
if (auto it = map.find(key); it != map.end()) {
process(it->second);
} else {
map.emplace(key, default_value);
}
// 'it' is not visible hereWithout the init-statement (C++11 and earlier), it would escape into the surrounding scope. The init-statement form eliminates that leakage cleanly.
if constexpr (C++17) evaluates the condition at compile time. The discarded branch is not instantiated, which makes it the correct tool for template conditionals that would otherwise require SFINAE or tag dispatch:
template <typename T>
void serialize(T val) {
if constexpr (std::is_integral_v<T>) { // C++17
write_int(val);
} else {
write_float(static_cast<double>(val));
}
}if consteval (C++23) selects a branch based on whether the evaluation context is a constant-evaluation context, replacing the more cumbersome std::is_constant_evaluated() function call.
switch
switch transfers control to the matching case label or default. Since C++17, it also accepts an init-statement:
// C++17 — init-statement in switch
switch (auto status = compute(); status) {
case Status::Ok:
handle_ok();
break;
case Status::Retry:
schedule_retry();
break;
default:
log_error(status);
}Each case falls through to the next unless a jump statement intervenes. Mark intentional fall-through with [[fallthrough]] (C++17) to suppress compiler warnings:
switch (ch) {
case 'a':
case 'e':
[[fallthrough]]; // C++17
case 'i':
count_vowel();
break;
}Jumping into a case body past a variable initialization with a non-trivial constructor is ill-formed. Jump past a trivially-initialized variable is allowed but leaves it uninitialized.
Iteration Statements
while and do-while
while evaluates its condition before each iteration; do-while guarantees at least one execution:
while (!queue.empty()) {
process(queue.front());
queue.pop();
}
int attempts = 0;
do {
result = try_connect();
++attempts;
} while (!result && attempts < 3);Traditional for
The init-statement, condition, and iteration expression are each optional. Omitting the condition creates an infinite loop. The init-statement scope is the entire for statement, not just the body:
for (std::size_t i = 0, n = vec.size(); i < n; ++i) {
// i and n visible throughout, destroyed after closing brace
}Range-based for (C++11)
Iterates over any range — arrays, containers with begin()/end(), or types supporting ADL-found begin/end:
for (const auto& elem : container) { // C++11
process(elem);
}Since C++20, range-for accepts its own init-statement, analogous to if and switch:
for (auto& cache = get_cache(); const auto& key : keys) { // C++20
if (auto it = cache.find(key); it != cache.end())
use(it->second);
}The loop variable binding can be a structured binding (C++17):
for (auto& [key, value] : map) { // C++17
value *= 2;
}Jump Statements
return transfers control out of the enclosing function, optionally supplying a value. In a function returning non-void, a path that reaches the end without return is undefined behavior (the compiler may not diagnose it in all cases).
break exits the innermost enclosing for, while, do-while, or switch. It does not cross function boundaries or try-block boundaries in a meaningful way — it only exits the immediately containing iteration or selection statement.
continue skips the remainder of the current loop body and proceeds to the next iteration's condition check (or, for do-while, the iteration itself).
goto transfers control to a labeled statement within the same function. It cannot jump forward past a variable initialization with a non-trivial constructor (ill-formed), though it may jump over trivially-initialized variables. Legitimate uses are rare but include breaking out of nested loops without a sentinel variable, and generated state machines.
for (int i = 0; i < rows; ++i)
for (int j = 0; j < cols; ++j)
if (grid[i][j] == target)
goto found;
found:
// handle found caseDeclaration Statements
A declaration that introduces a name is itself a statement. The variable is constructed when control first reaches the declaration and destroyed when its enclosing block exits:
void f(bool cond) {
if (cond) {
std::string msg = build_message(); // constructed here
log(msg);
} // msg destroyed here
}Declaring a variable inside a condition (for if, while, or for) limits its scope to that statement, which is preferred over declaring it in the surrounding scope.
Try Blocks
A try block is itself a statement and may appear anywhere a statement is legal — including as the body of a function:
// function-try-block — catches exceptions from member initializers
Foo::Foo(int x)
try : member_(x) {
// constructor body
} catch (const std::exception& e) {
// only re-throw is sensible here; any other path re-throws automatically
}Inside a handler, re-throwing with bare throw; preserves the original exception object and its dynamic type.
Best Practices
- Prefer the C++17 init-statement form of
if/switchto limit auxiliary variable scope to where it is logically relevant. - Declare variables at the point of first use, inside the tightest enclosing block. This minimizes lifetime and documents intent.
- Annotate intentional fall-through with
[[fallthrough]](C++17); annotate unreachabledefaultbranches with[[unlikely]](C++20) orstd::unreachable()(C++23). - Prefer range-
forover index loops unless you need the index value; when you need both, usestd::views::enumerate(C++23) rather than a manual counter. - Use
if constexprrather than template specialization for compile-time branching in function templates when both branches are syntactically well-formed for every instantiation.
Common Pitfalls
Dangling reference from range-for over temporary. If the range expression is a function returning by value and is not bound to a variable, the temporary is destroyed before the loop body executes (C++23 partially fixes this for certain cases with range adaptors, but the general case remains dangerous):
for (auto c : get_string()) // UB if get_string() returns std::string by value
// (fixed in C++23 for range-based for specifically — P2012)switch initialization past non-trivial constructors. Jumping into a case that skips a non-trivially-constructible variable is ill-formed. Use a block scope inside the case or move declarations above the switch.
continue in do-while. continue jumps to the condition of a do-while, not the top of the body, which surprises programmers coming from languages where do-while is rare.
Shadowing the outer loop variable. A variable declared in a range-for init-statement with the same name as an outer variable creates a new variable — it does not modify the outer one. Enable -Wshadow to catch this.
if with Initializer (C++17)
The init-statement scopes its variable to the entire if-else block. Before C++17, any variable declared to test a condition leaked into the enclosing scope, polluting the surrounding namespace and inviting misuse after the branch.
// C++17
if (auto it = map.find(key); it != map.end()) {
use(*it);
} else {
map.emplace(key, default_value);
}
// 'it' is not visible here — scope ends with the closing braceIn pre-C++17 code, it had to be declared before the if, remaining live long after the branch was done. The initializer form is also useful for lock-and-check patterns, where both the lock and the condition belong logically together:
// C++17
if (std::lock_guard lock(mu); ready) {
consume();
}
// lock released here; 'ready' could not have been safely checked without holding the lockBoth the init-statement and the condition see the same scope, so the lock is held for the entire if-else body and released immediately afterward.
switch with Initializer (C++17)
An init-statement in a switch scopes its variable to the entire switch block, preventing it from leaking into the surrounding function. The syntax mirrors the if init-statement and keeps status values tightly coupled to the dispatch that consumes them.
// C++17
switch (auto status = get_status(); status) {
case Status::OK:
handle_ok();
break;
case Status::ERROR:
handle_error(status);
break;
default:
log_unexpected(status);
}
// 'status' is not visible hereMark intentional fall-through with [[fallthrough]] to suppress compiler warnings; without it, GCC and Clang will warn on any case body that falls through to the next:
// C++17
switch (auto code = classify(ch); code) {
case Code::Alpha:
[[fallthrough]];
case Code::AlphaNum:
record_identifier(ch);
break;
case Code::Digit:
record_digit(ch);
break;
}When the switched type is an enum class, Clang and GCC warn (under -Wswitch) about unhandled enumerators. Pair exhaustive case labels with a default: std::unreachable(); (C++23) to document that all cases are covered and let the optimizer elide the dead path.
Range-Based for with Initializer (C++20)
The init-clause in a range-for (C++20) binds a name that persists for the entire loop, solving the dangling-temporary problem. Without an initializer, writing for (auto& item : get_container()) is undefined behavior when get_container() returns by value: the temporary is materialized, begin() and end() are called, and then the temporary is destroyed before the first iteration executes.
// UB — temporary destroyed before loop body runs
for (auto& item : get_container()) { // C++11 — dangerous if get_container() returns by value
process(item);
}The C++20 init-clause binds the container to a named variable, extending its lifetime to the full loop:
// C++20 — safe: 'container' owns the value for the duration of the loop
for (auto& container = get_container(); auto& item : container) {
process(item);
}The initializer can also cache a derived value — for example, a filtered view or a size — that would otherwise be recomputed on every iteration.
Structured Bindings in Statements (C++17)
Structured bindings decompose aggregates, tuples, and arrays into named components directly inside statements, eliminating .first/.second noise and making intent explicit. Any place an auto declaration is legal accepts a structured binding.
// C++17 — copy from map node
auto [key, value] = *map.find("x");
// C++17 — reference binding; modifying v modifies the map entry
auto& [k, v] = *it;
v += 10;
// C++17 — const reference to a three-element aggregate
const auto& [x, y, z] = point3d;Structured bindings integrate naturally with range-for, which is the most common usage:
// C++17
for (auto& [k, v] : mymap) {
v = transform(v); // modifies the map in place
}Structured bindings work with: arrays (binds to elements), types providing a std::tuple_size specialization (tuples, pairs, std::array), and aggregates with all public non-static data members. They do not work with types that have base classes containing data members.
constexpr if and consteval if (C++17/C++23)
if constexpr eliminates dead branches at compile time; the discarded branch is syntactically checked but never instantiated. This makes it the correct replacement for most uses of SFINAE, tag dispatch, and enable_if in function templates, because both branches must parse but only the selected branch produces code.
// C++17
template <typename T>
auto to_string(T val) {
if constexpr (std::is_integral_v<T>) {
return std::to_string(val); // only instantiated for integral T
} else if constexpr (std::is_floating_point_v<T>) {
return std::to_string(val); // only instantiated for floating-point T
} else {
return std::string(val); // only instantiated otherwise
}
}if consteval (C++23) selects a branch based on whether the current evaluation is a constant-evaluation context, replacing the more verbose if (std::is_constant_evaluated()) idiom from C++20 and avoiding its subtle pitfalls (the function call form was easy to misuse inside if constexpr):
// C++23
constexpr int safe_sqrt(int n) {
if consteval {
return const_isqrt(n); // compile-time path — may use non-constexpr-friendly ops
} else {
return static_cast<int>(std::sqrt(static_cast<double>(n))); // runtime path
}
}if not consteval provides the inverse condition for cases where the runtime path should be guarded. Unlike if constexpr, if consteval is not a template feature — it works in any constexpr function regardless of whether the function is a template.
Statement Expressions and Compound Literals (GNU Extension)
Statement expressions (({ ... })) are a GCC/Clang extension, not standard C++; they should not appear in portable code. A statement expression evaluates a brace-enclosed sequence of statements and produces the value of the final expression, which was historically useful in macros that needed to perform multiple steps and return a value.
// GNU extension — not standard C++
#define SQUARE(x) ({ int _x = (x); _x * _x; }) // evaluates (x) only onceThe portable C++11 replacement is an immediately-invoked lambda, which is standard, captures the enclosing scope, and supports auto return-type deduction:
// Standard C++11 — preferred over statement expressions
auto result = [&] {
int x = f();
return x * x;
}();For macros that must be both multi-statement and safe to use in single-statement if bodies, the do { ... } while(0) wrapper is the portable idiom:
// Portable macro pattern
#define LOG_AND_FAIL(msg) do { log_error(msg); return false; } while(0)The do-while(0) form allows the macro to be followed by a semicolon without creating an empty statement problem and works correctly as the body of an if without braces. C++11 lambdas with immediate invocation cover nearly all other statement-expression use cases in standard code.
See Also
reference/language/attributes—[[fallthrough]],[[likely]],[[unlikely]],[[nodiscard]]reference/language/constant-expressions—if constexprevaluation rulesreference/language/as-if-rule— when the compiler may reorder or elide statements