DEV Community

Cover image for The Dark Side of Switch-Case
Attila Fejér
Attila Fejér

Posted on • Originally published at rockit.zone

The Dark Side of Switch-Case

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;
}
Enter fullscreen mode Exit fullscreen mode

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 */
Enter fullscreen mode Exit fullscreen mode

Note that it's far from valid assembly code, but it's good enough to understand what's going on:

  1. jump_forward goes to a statement relative to the current one. When its argument is 0, it goes to the next one. When it's 1, it goes to the second, and so on.
  2. From those labels, we unconditionally jump to a specific location containing the case statement's body.
  3. At the end of the case statement, we go after the end of the switch-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 cases. 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;
}
Enter fullscreen mode Exit fullscreen mode

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 */
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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


  1. Pun intended 

  2. They may introduce more jumps 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. 

  3. Not to mention their developers. 

  4. We'll see this effect in the next part of the series. 

  5. Sorry, I couldn't resist. 

Top comments (0)