C++ if and switch Pitfalls: Fallthrough, Crossed Initialization, if (x = 5) and the Warnings That Catch Them

Key takeaways

if/else and switch look simple, but most conditional bugs in C++ come from a handful of rules: switch falls through, labels cannot jump over initializations, switch only takes integers and enums, and = inside a condition compiles. -Wall -Wextra catches nearly all of them.

if / else: what the condition really is

if (age >= 18) {
    std::cout << "adult\n";
} else if (age >= 13) {
    std::cout << "teen\n";
} else {
    std::cout << "child\n";
}

The condition is contextually converted to bool. Integers and pointers convert (non-zero / non-null is true), as do types with an explicit operator bool() such as std::optional, std::unique_ptr and streams. A std::vector does not convert — if (v) is a compile error; you write if (!v.empty()).

An else if chain is evaluated top to bottom and stops at the first true condition, so order matters: put the narrowest test first when ranges overlap.

&& and || short-circuit, which is what makes guards like this safe:

if (ptr != nullptr && ptr->value > 0) { /* ... */ }
if (i < v.size() && v[i] == target)   { /* ... */ }

Swap the operands in either line and you dereference null or read out of bounds on exactly the input the guard was meant to reject.

The if bugs the compiler will point out

All of the following compile. With -Wall -Wextra, g++ 10.3 flags every one:

void g(int x, int y) {
    if (x = 5) std::cout << "x\n";        // assignment, not comparison
    if (x > 0)
        if (y > 0) std::cout << "both\n";
    else std::cout << "which?\n";          // binds to the INNER if
    if (y > 0);                            // empty body
}
warning: suggest parentheses around assignment used as truth value [-Wparentheses]
warning: suggest explicit braces to avoid ambiguous 'else' [-Wdangling-else]
warning: suggest braces around empty body in an 'if' statement [-Wempty-body]
  • if (x = 5) assigns 5 and tests the result, so the branch always runs and x is silently overwritten. If you really mean assign-then-test, write if ((x = next())) with the extra parentheses; that is exactly what the warning suggests, and it documents intent.
  • Dangling else: indentation is ignored. An else always belongs to the nearest unmatched if. Calling g(1, -1) prints which? even though the indentation suggests that branch is for x <= 0. Braces on every branch make this impossible.
  • if (cond); makes the empty statement the body, and the following { ... } block runs unconditionally.

“Yoda conditions” (if (5 == x)) were invented to turn the first bug into a compile error. With the warning available, I would rather keep natural ordering and treat warnings as errors (-Werror or at least -Werror=parentheses) in CI. The warnings only help if someone reads them; in a build that already prints two hundred warnings, the one that matters is invisible.

Floating-point equality

double x = 0.1 + 0.2;
std::cout << std::setprecision(17) << x << ' ' << (x == 0.3) << '\n';
// 0.30000000000000004 0

Neither 0.1 nor 0.3 is exactly representable in binary, so the sum and the literal differ in the last bit. Compare with a tolerance that fits your data (std::abs(a - b) <= 1e-9 * std::max(std::abs(a), std::abs(b)) for a relative check), store money as integer cents, and consider -Wfloat-equal, which g++ does not enable by default:

warning: comparing floating-point with '==' or '!=' is unsafe [-Wfloat-equal]

C++17: if with an initializer

if (auto it = m.find("key"); it != m.end()) {
    use(it->second);
} else {
    // it is still in scope here, and equals m.end()
}
// it is gone here

The variable lives for the whole if / else if / else statement and no longer. This is more than tidiness: it stops a stale iterator or lock from being reused further down the function, and it lets two adjacent checks both name their variable it. The same syntax works for switch (auto r = parse(s); r.kind).

A related form with a longer history is a declaration as the condition: if (auto* p = dynamic_cast<Derived*>(base)) { ... }. The initializer form is the general version of it.

switch: dispatch on one integral value

switch (day) {
    case 1:  name = "Monday";  break;
    case 2:  name = "Tuesday"; break;
    // ...
    default: name = "Invalid"; break;
}

The controlling expression must be an integral type, an enum, or a class type that converts unambiguously to one. Case labels must be compile-time constants and unique. Given those constraints, the compiler is free to emit a jump table or a binary search instead of a chain of comparisons — which is where switch can be faster than a long else if ladder, though optimizers often turn simple ladders into the same code.

No strings, no doubles

switch (cmd) {            // cmd is std::string
    case "start": ...
}
error: switch quantity not an integer

The idiomatic workaround is to convert once, at the edge, into an enum and then switch on that:

enum class Cmd { Start, Stop, Unknown };

Cmd parse(std::string_view s) {
    static const std::unordered_map<std::string_view, Cmd> table = {
        {"start", Cmd::Start}, {"stop", Cmd::Stop}};
    if (auto it = table.find(s); it != table.end()) return it->second;
    return Cmd::Unknown;
}

switch (parse(input)) {
    case Cmd::Start:   start(); break;
    case Cmd::Stop:    stop();  break;
    case Cmd::Unknown: usage(); break;
}

For three or four strings an if/else if chain is perfectly fine; the table pays off when the set grows or when the same parse is used from several places. For numeric ranges, a common trick is to switch on a derived value, like switch (score / 10) for letter grades. g++ also accepts case 1 ... 5:, but that is a GNU extension, and -Wpedantic says so: range expressions in switch statements are non-standard.

Fallthrough: intentional and accidental

Without break, control continues into the next case. Stacking labels is the deliberate, warning-free use:

switch (month) {
    case 1: case 3: case 5: case 7: case 8: case 10: case 12:
        days = 31; break;
    case 4: case 6: case 9: case 11:
        days = 30; break;
    case 2:
        days = isLeap(year) ? 29 : 28; break;
    default:
        days = -1; break;
}

When a case does work and then falls through, -Wextra (which enables -Wimplicit-fallthrough) warns:

switch (n) {
    case 1:
        r += 1;          // no break
    case 2:
        r += 2;
        break;
}
warning: this statement may fall through [-Wimplicit-fallthrough=]

If that is what you meant, say so with the C++17 attribute, which silences the warning and tells the next reader it is not a bug:

case Status::Pending:
    prepare();
    [[fallthrough]];
case Status::Active:
    process();
    break;

g++‘s default level also accepts a // fall through comment right before the next label, which is how older code avoided the warning. The attribute is portable across compilers; comments are not.

Accidental fallthrough is the switch bug I have seen cause the most confusing behavior in real code, because it usually appears during an edit, not when the switch is first written: someone adds a new case above an existing one, copies the body from a neighbour, and the break stays behind. Everything still compiles, and the new case now also runs the old case’s code. This is the strongest argument I know for -Wextra on every build — the warning points at exactly the line.

”jump to case label” — declarations inside a switch

The whole switch body is one scope, and case labels are just jump targets inside it. So a variable declared under one label is visible under the later ones, and jumping to a later label would skip its initialization:

switch (n) {
    case 1:
        int x = 10;
        return x;
    case 2:
        return 2;
}
error: jump to case label
note:   crosses initialization of 'int x'

Give the case its own block and the variable’s scope ends before the next label:

switch (n) {
    case 1: {
        int x = 10;
        return x;
    }
    case 2:
        return 2;
}

Clang words the same error as “cannot jump from switch statement to this case label” with “jump bypasses variable initialization”. Either way, braces are the fix; moving the declaration above the switch also works when several cases need it.

Enums and the missing-case warning

With an enum operand and no default, -Wall checks that every enumerator is handled:

enum class Color { Red, Green, Blue };

const char* name(Color c) {
    switch (c) {
        case Color::Red:   return "red";
        case Color::Green: return "green";
    }
    return "?";
}
warning: enumeration value 'Blue' not handled in switch [-Wswitch]

This is the reason I avoid default in enum switches: the moment someone adds Color::Yellow, every switch that forgot it lights up. Adding default: return "?"; inside the switch makes all of those warnings vanish, and the new enumerator silently takes the fallback path. If a project needs a default for other reasons, -Wswitch-enum still warns about unlisted enumerators even when default is present.

Keep a fallback after the switch, though. An enum can hold values that are not named enumerators (static_cast<Color>(7), a value read from an old file), and falling off the end of a non-void function is undefined behavior.

[[likely]] and [[unlikely]] (C++20)

int process(int n) {
    if (n < 0) [[unlikely]] {
        return -1;
    }
    return n * 2;
}

These attributes tell the compiler which branch to lay out on the fall-through path. They do not change behavior, and g++ 10.3 accepts them with -std=c++20. In practice, the effect is usually small: modern branch predictors learn the pattern at run time, and profile-guided optimization gives the compiler better data than a hand annotation. I use them only on hot error-check paths after a profile shows the branch matters, and never as a substitute for measuring — a wrong hint can make the common path slower.

if-else or switch?

Prefer switch whenPrefer if / else if when
One integral or enum value against many constantsRanges or compound conditions (x > 10 && y < 3)
You want the compiler to check enum coverageConditions involve different variables
The cases are flat and similarFloating-point or string comparisons
Only two or three branches

Short ternaries (auto label = ok ? "pass" : "fail";) are fine for picking a value; once they nest, an if chain or a small function is easier to read and to step through in a debugger.

A compile line worth adopting

Every warning shown in this article came from:

g++ -std=c++17 -Wall -Wextra file.cpp

plus -Wpedantic, -Wfloat-equal or -Wswitch-enum where noted. -Wall -Wextra alone covers if (x = 5), dangling else, empty bodies, implicit fallthrough and unhandled enum values — which is most of the conditional bugs worth worrying about.