Why I Dislike Switch Statements
Why I dislike switch statements
In most coding styles (and in particular, in K&R, OTBS, and Linux), switch statements are written like so:
int func(int i) {
switch (i) {
case 1:
do_something();
break;
case 2:
case 3:
do_something_else();
break;
case 4: {
int j = i * 2;
return j;
}
case 5:
do_another_thing();
/* Fall through. */
default:
return 2;
}
return i;
}
Of course this is a contrived example, but readers will hopefully agree it’s representative of the construct.
Awkward
First, there are several things I consider clunky about using switch.
- The different
case“arms” are deindented to the same layer asswitch. Conceptually this is odd because they’re still within theswitch. However, the alternatives aren’t better: indenting the arms wastes another 4 characters on the line (or tab width if you’re like that), and half-indents are an excellent demonstration of why argumentum ad temperantiam is fallacious. - Instead of being delimited by braces (
{and}), execution flow is instead delimited by:andbreak. - As in
case 2andcase 3, adding another value introduces another line in many styles. Thus, usingswitchis invariably at least as much code as the equivalentif/elseflow. - Each arm of the
switchis not a separate scope. This leads to the pattern incase 4, wherein an explicit scope is needed to declarej. - Because the
breakis needed to delimit cases, compilers warn on its absence - i.e., when control flow “falls through”. However, since this is desirable in some cases, they do not warn when it’s commented as intentional. Thus, the comment incase 5becomes syntactically necessary. - No ordering on arms is imposed. In practice this is generally helpful, but
it leads to the weird situation where
defaultdoesn’t have to be the final arm.
Finally, default prohibits flattening control flow in a helpful way.
For example, consider this code:
int func(int i) {
if (i == 0) {
return 1;
} else if (i == 1) {
return 0;
} else {
i = do_some_stuff();
if (i == 3)
return 2;
else
return -1;
}
}
Ideally, we would like to rewrite this as:
int func(int i) {
if (i == 0)
return 1;
else if (i == 1)
return 0;
i = do_some_stuff();
if (i == 3)
return 2;
return -1;
}
By flattening the control flow, we make the code significantly easier to
reason about. For instance, it’s now very clear whether all paths through the
function lead to a return.
The need for default makes this pattern much less elegant. The equivalent is:
int func(int i) {
switch (i) {
case 0:
return 1;
case 1:
return 0;
default:
i = do_some_stuff();
if (i == 3)
return 2;
return -1;
}
}
and to my mind it’s debatable whether an empty default: arm helps that much.
Interaction loops
More than the general awkwardness, though, I’m bothered by how switch
interacts with loops. (And no, I’m not referring to Duff’s
Device which, while horrible,
shouldn’t appear in modern code anyway because we have since optimized
memmove()/memcpy().)
Consider code that looks like the following:
int func(int i) {
while (1) {
switch (i) {
case 1:
continue;
case 2:
break;
default:
return 2;
}
}
/* Point of interest. */
return 0;
}
Control flow here is complex. Consider what happens in each of the three
cases. In particular, switch is “kind of” loop-like in that it services
break, but “kind of” conditional-like in that continue is handled by a
higher scope. This makes trying to run code at the marked point of interest
surprisingly involved, as well as making it difficult to terminate the loop
itself.
Contrast with the this similar (but importantly different) code:
int func(int i) {
while (1) {
if (i == 1)
continue;
else if (i == 2)
break;
return 2;
}
/* Point of interest. */
return 0;
}
From inside the if/else stanzas, it’s very clear how to get to the point of
interest, and break and continue behave in clear ways.
Conclusion
Historically, the most important reason to use switch statements was that
the compiler could optimize them into jump tables. These days compilers are
capable enough to perform this transformation where it’s needed - in
particular, whether a construct is expressed as if or switch doesn’t
prohibit it from being a jump table.
The other major reason I’m aware of to use a switch is for exhaustiveness
checking on enums. The theory is that the compiler can check that all
defined values for an enum are handled at any given point. While true, this
typically doesn’t matter:
- Most of the time, not all
enumvalues represent expected program states. - Compilers and static analysis tools also warn about missing
default:branch, which negates the exhaustiveness check. - There are other, more-or-less equally clear ways to write
enum-driven state machines (e.g., dispatch table, callbacks, continuation-passing style, etc.).
I’m not about to issue an ultimatum and say “don’t use switch” or anything
similar. These are just the reasons that I happen to not like it. Certainly
Python not having a close equivalent should be demonstration enough that it’s
not needed in the language.
And all that being said, when the idea of switch is generalized (into
pattern matching), I do prefer it to if. Languages like Haskell (and to a
lesser extent, Rust) provide nicer constructs, but they’re also reliant on
Algebraic Data Types (which Rust clarifies by treating them as generalized
enums).