Recently I said that checked exceptions or not really exceptional. Here is a good example about the tradeoffs.
Consider building a directed acyclic graph
(DAG), a
dependency graph for example. The Node class has a
method to add a child:
class Node {
public void addChild(Node child) { ... }
}
We must make sure that the graph stays acyclic. So the child node may not be an
ancestor of the current node. We add a check to addChild.
class Node {
public void addChild(Node child) {
if (child.hasDescendant(this)) {
/* NOT SUITABLE */
}
}
public boolean hasDescendant(Node other) { ... }
}
What shall we do at NOT SUITABLE? We have three options:
ISPARENT, to tell the caller that the
operation is not possible.The arguments:
An unchecked exception indicates a programming error, and there are two diferent situations:
If addChild() is an API method, used as part of a user
action, it is normal business to get a node which can not be made a
child.
We could require the caller to first call hasDescendant()
explicitly, which again would turn the NOT SUITABLE part into
a programming error, but addChild() has to call it again to
be sure. How silly. As is often the case, first verifying that an
operation is possible is the same effort as just running it, possibly
being told it cannot be done.
So assume we are in the API situation, not in the algorithm-guarantees-non-fuckup situation.
Throwing a checked exception would be a strong hint that the operation may not succeed on circumstances. Yet, as I argued in the article linked above: this is not exceptional. It is normal business?
Which leads us to the special-value return. I started to call those an Explainer. It encodes why the operation was not possible, with as much detail as needed. Examples:
Map.get(key) a null return is a good
explainer with as enought detail, telling us "no value for 'key'". More is
not needed.
OK, NOOP, ISPARENT for the
cases "child was added", "child was already added" and "given node is an
ancestor so not a suitable child".Did you note how I tried above to not say that an operation "failed". Not easy after being brain-washed for 40 years.😀
In languages with union types, like Python and TypeScript, it is slightly
easier to return either a result or an Explainer. In Java it
would be some Either<Stuff, Explainer> that needs to be
defined.
Is there a case for checked exceptions still? The longer I ponder it the thinner the case gets. It seems nice to ignore the explainer (exception) and let it bubble up. Lets compare:
public Either<Result, Explainer> doStuff() {
Either<String, Explainer> s = compute(...);
if (s.isRight()) {
return Either.ofRight(s.right());
}
...
}
with
public Result doStuff() throws Explainer {
String s = compute(...);
...
}
The latter is obviously more conscise in Java, though the main eye-strainer
for me is more the new Either() necessary to match the result
type. In a language with union types, like TypeScript, this is just:
public doStuff(): Result | Explainer {
const s = compute(...);
if (s instanceof Explainer) {
return s;
}
... move on with s
}
The advantage of exception forwarding amounts to the avoidance of a mere if/return combo. Yes, you say, but what if there are four for five of those in a row? Then auto-bubbling looks much better — hmm, until you have to debug at what line exactly in the 😠code the exception is raised.
What if we made explicit forwarding simpler. If we have union types, like in Python and TypeScript, imagine a syntax like:
const text: string = compute(...) or return;
The compiler would unpack this into
const text: string | Explainer = compute(...);
if (text instanceof Explainer) {
return text;
}
Easy forwarding, explainers need not come along as exceptions and it is obvious were the code did the short turn, eventually. I am dreaming.