The Clean Switch Rules

From all the syntax constructs of modern programming languages, the switch construct is the most prone to degenerate into an unmaintainable mess unless we take aggressive actions. This article introduces the rules to follow to keep your switches clean and pleasant.

Contents

Switches in Legacy Code

I asked hundreds of developers attending my classes if they love switches. The developers just starting their careers are usually inclined to answer “yes”, while those older in our branch all tend towards a firm “no”, especially if they got to work on a 5+ years-old legacy codebase. Here, the term “legacy” stands for massive code written in an extremely procedural, monolithic style, with some functions having over 1000 lines of code.

Many of the monster legacy functions contain switches that span 4-5 screens, sometimes up to hundreds of lines. Those more unfortunate of us even saw switches nested inside other switches. That’s something you can never forget, once you see it:

switch(y) {
case a:
  ...
  switch (x) {
    case xa:
      ...
      break;
    ...
  }
  // more pain
case b:
  ...
}

You would never write such code, I know. The clean code principles are far more popular today than 15 years ago, but it’s interesting to ask ourselves: why? What makes switches grow this big? What’s unique about this particular syntax construct?

The reason is human psychology.

Let me explain.

When you create a switch, it always starts small and cute:

switch (...) {
  case STUFF: 
    // one line of code
    break;
  case FLUFF: 
    // just a bit more code
    break;
}

But then, sometime after, a colleague (or maybe yourself) comes around with 3 lines of FLUFF logic to add somewhere. And since the switch already has 10 lines of code, adding 3 more doesn’t harm, right? So now it’s 13 lines. Several weeks after another fellow comes by, carrying 5 lines of STUFF logic. And of course, where 13 lines fit, 5 more won’t make any difference. 18 lines. And so, the switch starts growing.

The theory behind this phenomenon is called The Broken Windows Theory: “a criminological theory that states that visible signs of crime, anti-social behavior, and disorder create an environment that encourages further crime and disorder, including serious crimes”.

In other words, abandonment attracts more abandonment.

What’s unique about a switch is that it is by definition a ‘lengthy’ syntax: even a simple switch can easily span dozens of lines, leading people to feel it’s ok to add more code to it.

Switch Statement Weaknesses

Besides attracting more code, the switch statement has a series of technical limitations, both in Java and C/C++;

Break!

You need to remember to break; at the end of each case block. But it’s worse: sometimes you need to fall-through from one case to the next one because multiple values are handled in the same way. So you can’t demand a break; after each case.

However, if you ever encounter code like

case X:
  // code1
case Y:
  // code2
  break;

That’s almost always a bug – a break; is missing after // code1. However, the code could be correct if // line1 would not be there.

Variable Leaking

Variables defined in one case are visible in the case clauses below! For example, this code doesn’t compile:

switch (x) {
  case A:
    int i = 1;
    break;
  case B:
    int i = 2; // does not compile !!
    break;
}

This variable leaking can make the code harder to trace, especially in large, gruesome legacy code. But here’s a nice trick you can use to work your way out: enclose the body of each case within an artificial block: case A: { int i = 1; break;}. This will cause the variable i to be scoped to that { } block, stoping it from leaking to the next case.

But now let’s fix the switch.

The Clean Switch Rules

I’ve brainstormed these rules with hundreds of developers, but I’ll try to summarize the main points here. Let me start with the strangest one:

Rule #1: The switch is the only thing in that function.

In other words, there must be nothing before or after the switch. No method call or return afterward. No variable definition or initial call before it. Nothing. The switch should occupy the entire body of the host function. Crisp, sharp code.

Why? To minimize the temptation to add more code in there, given the switch syntax is inherently lengthy by itself.

The only exception to this rule is an initial null guard-clause for the String you switch on: in Java there’s a bytecode performance optimization that may cause NullPointerException if s comes null:

public void f(String s) {
  if (s == null) return; // exit, avoid NPE
  switch(s) {
    ...
  }
}

But passing null Strings around is a bad idea anyway, so this case turns up very rarely in practice.

Rule #2: Every switch must end with default: throw ...

I guess that’s obvious for most developers. You want to throw an exception for any unexpected value.

Imagine switching on an enum. You carefully defined a case for each enum value, and everything goes well. Later you add a new value to that enum, but you forget to add the corresponding case to this switch. Having a default: throw in place will signal the problem at runtime, which is almost always preferable to ignore it silently.

default: throw new IllegalArgumentException(“Unsupported: “ + value);

There’s only one exception to this rule: if you are 100% certain that any unexpected value won’t ever need to be handled, you can either (a) skip the default: clause if you’re in a function returning void, or (b) default: return 0; (or any reasonable value) instead of throw new. Be extra careful as by unexpected value I mean both strings/ints that you didn’t plan for, but also enum values that will be added in the future if you switch on an enum.

But in the vast majority of cases, Rule #2 applies perfectly

And now, the most powerful and difficult rule to follow:

Rule #3: Every case must have a single line.

In other words, any logic longer than a single line of code needs to go into a separate function that you call from the case.

case X: return computeForX(...);

This rule will keep your switch devoid of logic. Deciding between multiple flows is enough for the poor switch. Don’t overload it!

But what if the switch is in a function returning void? Then, you have to break; after calling the function, like this:

case X: handleX(...); break;

Exceptions to this rule?
Very tiny switches with only 2-3 cases, that need to do 2 lines of code. Only then… Maybe…
But by any means NEVER put a for, an if or a try under a case:!

Summary

The three rules of a clean switch:

  1. it is the only thing in that function
  2. ends with default: throw ...
  3. has one-liner case clauses

PS: for the Java developers moving to Java 17 in autumn 2021, following these three rules will ease the transition to the long-awaited enhanced switch. More on that in a future blog post. 🙂

Popular Posts

4 Comments

  1. Ionut Bilica says:

    Good article. Painful topic. To add my two cents:

    1. Switch statements are static (hardcoded logic). If it tends to change (check commits), replace with dynamic (polimorfism or wherever) to make class Open Closed. If the switch is big, it will tend to change.

    2. Switch statements are imperative (side effects and higher complexity). Prefer declarative style: switch expressions (java 12+), maps, etc.

  2. For me, switch case is used when the dev needs a behavior to vary when a value changes.
    So, a switch case is another representation of a `Map`.
    You want to factor same behaviors ? Use `Map<Set, Runnable>`.
    You want to select behaviors with more complex conditions ?
    Use `Map<Set, Runnable`.
    Then, you play with the stream API (dont forget a `getOrThrow`).

    Transforming a monster switch case into such map is easy and safe enough, and it can be the first step to a better design.

    Problem : Java verbosity. I’m doing this in Groovy with closures and the syntax is much more light.

    Have you already tried this kind of maps ?

    1. A Map would be very close to a switch in all aspects with a function like Map.getOrThrow(). Unfortunately, there’s only a Map.getOrDefault but that might give rise to a null if not careful.

      While not the end of the world, you’ll see in a future blog that the compiler can help us detect missing branches in a switch if we move to a switch expression.

      However, nothing beats a Map if you load dynamically (from a .properties/.yaml) a Map. But many apps don’t need that much flexibility TBH.

  3. My comment has been cut, particularly the Map codes. The comment system didn’t like the diamond op.

Leave a Reply to t4w4n3 Cancel reply

Your email address will not be published. Required fields are marked *