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 withdefault: 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
:
- it is the only thing in that function
- ends with
default: throw ...
- 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. 🙂