Skip to content
C++
Language
Basic

Statements

Complete reference for all C++ statement categories — expression, compound, selection, iteration, jump, declaration, and try-blocks — with version-tagged features.

Statement

A 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.

CategoryForms
Expression statementany expression followed by ;
Compound statement{ stmt... }
Selection statementif, switch
Iteration statementfor, while, do-while, range-for
Jump statementreturn, break, continue, goto
Declaration statementintroduces a name into the enclosing scope
Try blocktry compound-statement handler-seq
Null statementlone ;

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.

cpp
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.

cpp
{
    std::lock_guard<std::mutex> lk(mtx);  // acquired here
    data.push_back(value);
}   // lk destroyed, mutex released

A 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:

cpp
// 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 here

Without 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:

cpp
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:

cpp
// 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:

cpp
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:

cpp
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:

cpp
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:

cpp
for (const auto& elem : container) {   // C++11
    process(elem);
}

Since C++20, range-for accepts its own init-statement, analogous to if and switch:

cpp
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):

cpp
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.

cpp
for (int i = 0; i < rows; ++i)
    for (int j = 0; j < cols; ++j)
        if (grid[i][j] == target)
            goto found;

found:
    // handle found case

Declaration 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:

cpp
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:

cpp
// 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/switch to 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 unreachable default branches with [[unlikely]] (C++20) or std::unreachable() (C++23).
  • Prefer range-for over index loops unless you need the index value; when you need both, use std::views::enumerate (C++23) rather than a manual counter.
  • Use if constexpr rather 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):

cpp
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.

cpp
// 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 brace

In 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:

cpp
// C++17
if (std::lock_guard lock(mu); ready) {
    consume();
}
// lock released here; 'ready' could not have been safely checked without holding the lock

Both 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.

cpp
// 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 here

Mark 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:

cpp
// 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.

cpp
// 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:

cpp
// 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.

cpp
// 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:

cpp
// 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.

cpp
// 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):

cpp
// 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.

cpp
// GNU extension — not standard C++
#define SQUARE(x) ({ int _x = (x); _x * _x; })  // evaluates (x) only once

The portable C++11 replacement is an immediately-invoked lambda, which is standard, captures the enclosing scope, and supports auto return-type deduction:

cpp
// 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:

cpp
// 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 constexpr evaluation rules
  • reference/language/as-if-rule — when the compiler may reorder or elide statements