Java has compile-time typechecked sum types with exhaustive matching built into the base language.
“Of course it doesn't” you say? “Their absence is one of Java's most glaring deficiencies”? Well, kind of. Read on to see what I mean.
I'll spoil the trick right upfront: the return value of functions with
declared checked exceptions is a sum type of the declared return value
and each declared exception type. (Mostly. Subtyping presents a
wrinkle. But if we don't do that, it won't cause problems.) The
compiler will make sure that, at the call site, we write a
catch
block for each checked exception declared in the
method signature. This gives us both multiple exclusive types, and
compile-time checking that we've handled all of them.
Of course, there's an issue. (Well. Many issues. But let's start with this one.) Our type here is the return type of a function with declared checked exceptions, which we have no way to directly manipulate. So we're stuck wrapping the “type” with a class containing a method with the desired signature. Let's start sketching out an example of what we want:
public class LeftOrRight {
public void get() throws LeftValue, RightValue {
…
}
}
We'll have it return void just for uniformity, so all return values are thrown. (Java doesn't make us do anything with the actual return value, so this will improve exhaustiveness as well.)
Okay. Now we need this class to contain the values. Because Java doesn't actually give us sum types, we'll need a field for each possible value, and mutually-exclusive constructors or factory methods to populate them:
public class LeftOrRight {
private LeftValue left;
private RightValue right;
public LeftOrRight(LeftValue left) {
this.left = left;
}
public LeftOrRight(RightValue right) {
this.right = right;
}
public void get() throws LeftValue, RightValue {
…
}
}
And then get()
just needs to throw whichever value is
populated. We'll need to actually set left
and
right
to something, as well, so we don't have
“variable might not have been initialized” errors.
null
is probably our best option here, though we're sort
of subverting our original goal.
public class LeftOrRight {
private LeftValue left = null;
private RightValue right = null;
public LeftOrRight(LeftValue left) {
this.left = left;
}
public LeftOrRight(RightValue right) {
this.right = right;
}
public void get() throws LeftValue, RightValue {
if (left != null) {
throw left;
} else {
throw right;
}
}
}
(Fun fact: it turns out that we can't implement get()
with the ternary operator (as e.g. throw left != null ? left :
right
) because that whole expression has a single type, which
is the nearest common superclass of LeftValue
and
RightValue
, which we didn't declare. Whee!)
Now let's think about what LeftValue
and
RightValue
should look like. We basically just want them
to extend Throwable
(so they're checked and can be
thrown) and contain a variable. Actually, they'll be nearly the same,
so we should probably implement a superclass.
private class ThrowableValue extends Throwable {
private Object value;
public ThrowableValue(Object value) {
this.value = value;
}
public Object getValue() {
return this.value;
}
}
public class LeftValue extends ThrowableValue {
public LeftValue(Object value) {
super(value);
}
}
public class RightValue extends ThrowableValue {
public RightValue(Object value) {
super(value);
}
}
(Aah, I love the smell of boilerplate in the morning.)
Okay, cool. Let's try it out.
public class Example {
public static void main(String[] args) {
LeftOrRight leftOrRight = new LeftOrRight(new LeftValue("Hello, world!"));
try {
leftOrRight.get();
} catch (LeftValue l) {
System.out.println("Got left: " + l.getValue());
}
}
}
$ javac Example.java
Example.java:6: error: unreported exception RightValue; must be caught or declared to be thrown
leftOrRight.get();
^
1 error
Neat.
public class Example {
public static void main(String[] args) {
LeftOrRight leftOrRight = new LeftOrRight(new LeftValue("Hello, world!"));
try {
leftOrRight.get();
} catch (LeftValue l) {
System.out.println("Got left: " + l.getValue());
} catch (RightValue r) {
System.out.println("Got right: " + r.getValue());
}
}
}
$ javac Example.java
$ java Example
Got left: Hello, world!
Neat-o.
A few immediate concerns come to mind.
- “Aren't exceptions really slow?” If performance is your biggest concern here, I really don't know what to say. Uh, yeah, I guess it's probably slow compared to implementing this in a runtime-checked manner.
- “Generics?” Unfortunately, no. Generics that
extend
Throwable
are explicitly forbidden, because after generic erasure, the runtime can't tell which generic type we tried to throw or whichcatch
block is supposed to catch it. So… - “This is a lot of boilerplate.” Well, it is Java.
- “This is hardly safe. There's all sorts of room for
illegal states to sneak in (like a
LeftOrRight
with both fields set or unset) that the compiler can't catch.” Yeah, this is all true; we're dependant onLeftOrRight
being implemented correctly. However, we do get all the benefits I specified in the intro: the type is declared (asthrows LeftValue, RightValue
) and the call site is statically checked to handle every possible return type (“checked exception”).
So, that was fun. Would I recommend using this? Probably not. (Definitely not if anyone else ever needs to read the code.) But the capability is interesting, if nothing else, and it's been present in the language basically forever.
Example code is available in Example.java, lightly modified to all go in one file.