Type-safe Exhaustiveness Checking in Java
Disclaimer: This post is an exploration of Java’s language features and is intended to be satire. You should obviously never do this, nor should you ever need to (there are better tools/ways).
Background
A language feature receiving a bit of buzz recently due to its inclusion in a few trendy new languages is exhaustiveness checking.
For example, using the when
expression in Kotlin with a sealed class:
sealed class Expr
data class Const(val number: Double) : Expr()
data class Sum(val e1: Expr, val e2: Expr) : Expr()
fun eval(expr: Expr): Double = when(expr) {
is Const -> expr.number
is Sum -> eval(expr.e1) + eval(expr.e2)
}
Source: Link
If I were to add an additional member to Expr
without extending the function eval
, I would receive a compile-time error! Pretty neat. This applies to enum
as well. Swift has this feature too:
enum State {
case Walking
case Sitting
case Running
}
let s = State.Walking
switch s {
case .Walking: print(true)
case .Sitting: print(false)
}
In Swift, that code wouldn’t compile because I haven’t handled the Running
case.
This feature is in Rust, Kotlin, Swift, Haskell, etc. All the cool kids have it nowadays.
It’s a pretty handy feature when you’re refactoring, as you can imagine. You can introduce a new State
and get told all the places you need to handle it by the compiler. You could introduce a new child class in Kotlin and get the same thing. This isn’t anything new, but it’s pretty new to imperative languages and to environments outside of those ivory towers. shakes fist at cloud
The Setting – In Java
Imagine we have a message queue, where we receive some strings that look like this:
"job:rm -rf /*"
"play:/music/rick_astley-never_gonna_give_you_up.mp3"
"dance:leave your friends behind"
To handle the messages, we have a few classes that describe the instructions: Play
and Job
:
private static class Job extends Instruction {
final String task;
Job(String task) {
this.task = task;
}
}
private static class Play extends Instruction {
final File mp3ToPlay;
Play(File mp3ToPlay) {
this.mp3ToPlay = mp3ToPlay;
}
}
With a parent class Instruction
that provides a static parseInstruction
method, and just looks something like this:
private static abstract class Instruction {
static Instruction parseInstruction(String serialisedInstruction) {
String[] messageParts = serialisedInstruction.split(":");
String command = messageParts[0];
String content = messageParts[1];
switch (command) {
case "job":
return new Job(content);
case "play":
return new Play(new File(content));
default:
return null;
}
}
}
And finally we are currently processing our instructions like so:
for (String message : messages) {
Instruction instruction = Instruction.parseInstruction(message);
if (instruction instanceof Job) {
jobsQueue.add((Job) instruction);
} else if (instruction instanceof Play) {
playMp3(((Play) instruction).mp3ToPlay, systemSpeaker);
} else {
System.out.println("Unknown Instruction: " + instruction.toString());
}
}
As you can see, there are different variables here, so having a single method on the Instruction
class like #perform
, wouldn’t really work as it would require a consistent interface.
Seems pretty serviceable though. So let’s run it!
Oh no! We got a null pointer exception from the dance
instruction 🤦 because we’re not handling it properly! Silly us. Easy fix, though; we can just add a Dance
instruction type for that case so it won’t be null anymore.
private static class Dance extends Instruction { }
private static abstract class Instruction {
static Instruction parseInstruction(String serialisedInstruction) {
String[] messageParts = serialisedInstruction.split(":");
String command = messageParts[0];
String content = messageParts[1];
switch (command) {
case "print":
return new Print(content);
case "play":
return new Play(new File(content));
case "dance":
return new Dance();
}
}
}
Perfect. We’re done!
But wait… Our behaviour isn’t quite right 🤔.
Because we don’t have exhaustiveness checks, we forgot about that obscure bit of code off in the corner of our ~70 line codebase where we process the incoming instructions. It doesn’t actually handle the Dance
instruction type properly at all!
for (String message : messages) {
Instruction instruction = Instruction.parseInstruction(message);
if (instruction instanceof Job) {
jobsQueue.add((Job) instruction);
} else if (instruction instanceof Play) {
playMp3(((Play) instruction).mp3ToPlay);
} else if (instruction instanceof Dance) {
mrRobot.dance((Dance) instruction);
}
}
Muuuch better. But how can we avoid this happening again? Our huge Java enterprise codebase of ~70 lines would take millions of dollars of effort to convert to a language with exhaustive checking features… but wait, isn’t there a way to support exhaustive checks in Java? You’re damn right there is!
But what if Java had this feature all along?
As it turns out, there is a way to ensure that we handle all the possible return cases for the parseInstruction
method. It’s not quite the same as what Kotlin and Swift offer, but it covers our current use case and just relies on stock-standard Java.
Check this out:
private static abstract class Instruction extends Exception {
switch (command) {
case "print":
throw new Print(content);
case "play":
throw new Play(new File(content));
case "dance":
throw new Dance();
}
As I replaced each return
with throw
IntelliJ gleefully added the checked exceptions to my method header for me.
...(String serialisedInstruction) throws Print, Play, Dance {
Best of all, once I was done I had a compile-time errors coming from my main
method…
Unhandled exceptions!
public static void main(String[] args) {
for (String message : messages) {
try {
Instruction.parseInstruction(message);
} catch (Dance dance) {
mrRobot.dance(dance);
} catch (Job job) {
jobsQueue.add(job);
} catch (Play play) {
playMp3(play.mp3ToPlay);
}
}
}
How elegant. How beautiful. Not only does it let you know when you’ve forgotten to handle one of the possible return types of #parseInstruction
, it even casts and scopes your variable for you 😍. Now when I add new instructions I get a compile-time error about an unhandled ‘exception’. It casts the types for me, the code is safer, more concise, more readable. Who knew Java was secretly so modern.
Conclusion
Of course, you should never do this. There are better ways of staying safe around changing enums/class hierarchies (that I’d like to talk about some other time).
Exceptions are for errors, and they’re slow, and anyone reading this code without reading this blog post (or some other blog post that beat me to the punch?) will probably be at least slightly puzzled. Plus, the parseInstruction
method not throwing an exception would now probably constitute a bug. The checks here are also sadly limited to the results of #parseInstruction
, rather than the all possible implementers of Instruction
, which could be good or bad depending on what you want (but isn’t really guaranteeing exhaustiveness).
Also, sadly, since you can’t extends Exception
on an enum
in Java you won’t actually be able to use this pattern with enums (does that mean I lied?). I guess you could just create a class hierarchy for every enum type instead, but the list of perversions is really starting to stack up at that point.
I think the main point is that Java does have a construct for exhaustiveness and auto-casting (and thereby acknowledges the value of such a construct), we just don’t use it very broadly.
Stay safe and have fun ✌️
Header image courtesy of Simon Migaj.