In a nutshell: switch
-case
makes code harder to maintain. We'll understand the reasons to eliminate it and see when its usage is justified.
Since the topic is enormous, we'll learn about the different tactics in separate posts.
When to Avoid Switch-Case
As we mentioned, the critical problem is maintainability. For companies that use high-level languages independently of the platform (mobile, desktop, frontend, backend, you name it), maintainability is one of the most crucial metrics of the code. Performance is only secondary (within reasonable bounds) since processing power is cheaper than developers. We don't want to squeeze the last bit of performance out of the code. We want to make it easy to read and modify so developers can debug and add features more effectively.
But there are circumstances when performance is primary. Usually, we use lower-level languages in these situations. Here are a few examples:
- Embedded systems, where the processing power is limited (washing machine, microwave, toys), or we need to strive for low energy consumption (sensor networks)
- Real-time systems, where a timeout has potentially catastrophic effects (cars, trains, planes, chemical plants)
- Low-level code with possibly many dependents (OS kernel, device firmware)
In such cases1, a switch
-case
statement is an excellent choice because of its performance.
Let's look under the hood to understand why it performs well.
Switch-case Under the Hood
Let's consider a simple example in C:
int connectionState;
int statusLed;
switch (connectionState) {
case CONNECTED: // CONNECTED == 0
statusLed = SOLID_GREEN;
break;
case CONNECTING: // CONNECTING == 1
statusLed = BLINKING_BLUE;
break;
case ERROR: // ERROR == 2
statusLed = BLINKING_RED;
break;
}
Since the possible values are small, consecutive numbers, the compiler could generate the following assembly-like pseudo code:
jump_forward connectionState /* skips connectionState lines */
jump connected /* value 0 */
jump connecting /* value 1 */
jump error /* value 2 */
connected:
assign statusCode, SOLID_GREEN
jump after_switch
connecting:
assign statusCode, BLINKING_BLUE
jump after_switch
error:
assign statusCode, BLINKING_RED
jump after_switch
after_switch:
/* rest of the code */
Note that it's far from valid assembly code, but it's good enough to understand what's going on:
-
jump_forward
goes to a statement relative to the current one. When its argument is0
, it goes to the next one. When it's1
, it goes to the second, and so on. - From those labels, we unconditionally jump to a specific location containing the
case
statement's body. - At the end of the
case
statement, we go after the end of theswitch
-case
.
As we can see, the number of instructions we need to get to the block that executes a case
statement's body is independent of the number of case
s. That's not only fast; it's also scalable.
What if the values aren't consecutive? Wouldn't having those jump
statements with empty instructions between them be a waste of code memory?
Fortunately, compilers are smart and can optimize to handle these situations effectively. Let's modify the constants in the previous example:
int connectionState;
int statusLed;
switch (connectionState) {
case CONNECTED: // CONNECTED == 0
statusLed = SOLID_GREEN;
break;
case CONNECTING: // CONNECTING == 2
statusLed = BLINKING_BLUE;
break;
case ERROR: // ERROR == 16
statusLed = BLINKING_RED;
break;
}
A possible compilation could be this:
jump_forward connectionState /* skips connectionState lines */
assign statusCode, SOLID_GREEN /* value 0 */
jump after_switch
assign statusCode, BLINKING_BLUE /* value 2 */
jump after_switch
after_switch: /* we have space for 13 instructions here */
/* first few lines of rest of the code */
jump continue_working
assign statusCode, BLINKING_RED /* value 6 */
jump after_switch
continue_working:
/* rest of the code */
As we can see, the spaces between case
handlers won't necessarily go to waste. We could inline a case
's body (and by that, we got rid of a few jumps) or some other code2. Compilers are tricky bastards, indeed3.
For more complicated situations (for example, when the switch
's condition is a string), the compiler has other tricks, like lookup tables. The worst-case scenario is O(n)
complexity, where n
is the number of case
statements. The compiler can analyze a switch
-case
and use the best strategy. With other techniques, it may not be as obvious what to do.
Now we know why switch
-case
is so fast to execute. Let's dive into what are its maintainability issues.
Switch-case Maintainability
So what's this nonsense that switch
-case
is hard to maintain? It's far more readable and maintainable than complex if-else structures, right? Indeed, but it doesn't mean we don't have better alternatives.
Often, we use switch
-case
to differentiate between different values of the same thing and perform operations depending on the value. For example, a person's blood type (A, B, AB, 0) or the species of a pet (dog, cat). We call a property type-code or discriminator if we use it to distinguish different kinds of an object.
In the former case, the type could be the person object's attribute, while the latter is the pet object's class.
Let's see an example of the second scenario in Java (17+):
Pet pet;
switch (pet) {
case Dog:
log("woof");
break;
case Cat:
log("meow");
break;
}
It looks simple enough, but there are multiple problems with this approach.
First, when we introduce a switch
-case
statement, it tends to appear in multiple places. Either because we want to do different things based on the type-code (like giving the proper food to the pet) or make a behavior conditional based on a different type-code (treat a dog differently whether they are a good boi).
Second, the possible value set of type-codes tend to change. For example, we may want to handle hamsters and fish as pets. Then we need to remember to update all the switch
-case
statements we have. And most probably, we'll have many of them because of the previous point4. And it's hard to remember all the places we need to update, which leads to mysterious bugs.
Conclusion
We understood why switch
-case
is good and when to use it. We also saw why switch
es get stitches5 are hard to maintain.
In the following parts, we'll see different scenarios where we can do better than switch
-case
from a maintainability perspective. Each of them has different optimal solutions. We'll see their characteristics and get rules of thumb about the solutions' usability.
-
Pun intended ↩
-
They may introduce more
jump
s we didn't need previously. Alternatively, subroutines (aka, functions) could fit in and need jumps anyway. Or we could leave them blank if we have more than enough memory. ↩ -
Not to mention their developers. ↩
-
We'll see this effect in the next part of the series. ↩
-
Sorry, I couldn't resist. ↩
Top comments (0)