C++ / CONTROL FLOW
switch statements and fallthrough behaviour
Write switch statements over integral and enum values, control fallthrough deliberately, and scope variables declared inside a case.
What you will learn
- Switch only on integral or enum values, with distinct compile-time case labels
- Read a case label as a jump target, not the start of a self-contained branch
- Use stacked labels for shared bodies and [[fallthrough]]; for intended slides
- Brace a case body that declares an initialized variable
Understanding switch statements and fallthrough behaviour
A switch evaluates its condition exactly once and transfers control to the case label whose constant value matches. The condition must have integral or enumeration type, or a class type with one unambiguous conversion to such a type, which is why switching on a std::string or a double does not compile; each case label must be a distinct constant expression converted to the promoted type of the condition. Because the dispatch happens once, a compiler can turn dense label sets into a jump table and sparse ones into a search over comparisons, so the work done does not have to grow with the number of labels you write.
The body of a switch is a single statement, almost always one block, and case 3: is a label inside that block rather than the opening of a branch. Control enters at the matching label and then runs forward through every statement below it, ignoring the labels it passes, until it reaches break, return, throw, goto, or the closing brace. Fallthrough is therefore not something switch adds on purpose; it is simply what happens when nothing exits, and break is the part you have to remember to write.
Two kinds of fallthrough are worth telling apart. Labels stacked with no statements between them, such as case 'a': case 'e':, share one body, and no compiler complains about those. A case body that does real work and then slides into the next label is usually a forgotten break, so -Wimplicit-fallthrough reports it; ending that body with [[fallthrough]]; since C++17 states that you meant it. Since the whole body is one scope, a variable declared with an initializer under one label is also in scope under later ones, and jumping past that initialization is ill-formed, which is why such a case body needs its own braces.
<iostream>
void countdown(int n) {
std::cout << "n=" << n << ":";
switch (n) {
case 3:
std::cout << " three";
[[fallthrough]];
case 2:
std::cout << " two";
[[fallthrough]];
case 1:
std::cout << " one";
break;
default:
std::cout << " out of range";
}
std::cout << '\n';
}
int main() {
for (int n = 0; n <= 3; ++n) {
countdown(n);
}
}
A switch is one block entered by a computed jump, so execution keeps running past later case labels until something explicitly exits.
Worked examples
Braces give a case its own scope
Shows why a case body that declares an initialized object must be wrapped in a block.
<iostream>
<string>
int main() {
int code = 2;
switch (code) {
case 1: {
std::string label = "created";
std::cout << label << '\n';
break;
}
case 2: {
std::string label = "updated";
std::cout << label << '\n';
break;
}
default:
std::cout << "unknown\n";
break;
}
}
Example explained
Line 1Without the inner braces both declarations of label would sit in the single switch scope, making the second one a redefinition.
Line 2Even with different names it would still fail: entering at case 2 jumps over the initialization of case 1's std::string, and that is rejected with "jump to case label crosses initialization".
Line 3The braces bound each object's lifetime, so only the case that actually ran constructs a string.
Line 4break inside the nested block still exits the switch; adding braces does not change what break refers to.
break ends the switch, not the loop
Demonstrates that a break written inside a switch nested in a loop leaves only the switch.
<iostream>
int main() {
int values[] = {4, 7, 0, 9};
for (int v : values) {
switch (v) {
case 0:
std::cout << "found zero\n";
break;
default:
std::cout << "value " << v << '\n';
break;
}
}
std::cout << "done\n";
}
Example explained
Line 1break binds to the nearest enclosing switch or loop, and here the switch is nearer, so iteration carries on after "found zero".
Line 2The line printing 9 is the giveaway that the loop was never abandoned.
Line 3continue behaves differently: a switch is not a loop, so continue inside it applies to the for loop and skips to the next element.
Line 4To really stop, return from the function, set a flag the loop condition tests, or make that check a plain if outside the switch.
Switching on a scoped enum
Uses return instead of break and relies on the missing default to get warnings about unhandled enumerators.
<iostream>
enum class Token { Plus, Minus, Number };
int apply(Token t, int a, int b) {
switch (t) {
case Token::Plus: return a + b;
case Token::Minus: return a - b;
case Token::Number: return b;
}
return 0;
}
int main() {
std::cout << apply(Token::Plus, 7, 5) << '\n';
std::cout << apply(Token::Minus, 7, 5) << '\n';
std::cout << apply(Token::Number, 7, 5) << '\n';
}
Example explained
Line 1Each case ends in return, which leaves the function outright, so no break is needed and fallthrough is impossible here.
Line 2A scoped enum requires qualified labels such as Token::Plus; case 0: would not compile because there is no implicit conversion from int.
Line 3Omitting default is deliberate: -Wswitch then reports this function the day a fourth enumerator is added.
Line 4The trailing return 0 is still needed, since a Token can hold an out-of-range value produced by a cast and falling off the end of a non-void function is undefined behaviour.
Important notes
default may appear anywhere in the body, not just last, and it is not required; a switch on an enum can still be handed a value outside the enumerator list through a cast, so code that must produce a result needs a fallback even when every enumerator is listed.
Since C++17 the condition can carry an init-statement, as in switch (int c = next(); c), which keeps c scoped to the switch.
Common mistakes
Leaving off break at the end of a case body: the next case's statements also run, so a value meant to trigger only "add" quietly performs the "subtract" work too and produces a wrong result instead of an error.
Assuming break inside a switch that sits in a loop ends the loop; it ends only the switch, so the loop keeps iterating and the intended early exit never happens.
Writing case 1: std::string s = name; with no braces: the translation unit fails to compile because jumping to a later label would cross that initialization.
Try it yourself
Change, predict, then run
Write a switch over an int month that prints the number of days, using stacked labels for the 31-day months and another group for the 30-day ones, with February handled separately. Then delete one break, predict what the program will print before running it, and check your prediction.
Open the C++ workspaceCheck your understanding
With int n = 2; int total = 0; and the body { case 3: total += 3; case 2: total += 2; case 1: total += 1; break; default: total = -1; }, what is total afterwards?
- 2
- 3
- 6
- -1
Show answer
Control jumps to case 2, adds 2, then keeps running through the case 1 label and adds 1, and the break stops it before default, giving 3. Answering 2 assumes a case label closes the previous body, but labels are only jump targets and do not end anything; 6 would require entering at case 3, and -1 would require default to run unconditionally.