Improving Checked Exceptions

posted by Craig Gidney on January 15, 2013

The only mainstream programming language with checked exceptions (that I am aware of) is Java. Unfortunately, Java’s checked exceptions are not… ideal. Encapsulation is verbose. Doing the wrong thing is succinct. In this post I’ll discuss the problems, a few hypothetical changes that could make the situation much better, and some looming issues.

Disclaimer: handling failures is one of the many controversial subjects amongst programmers. Opinions about it are many and varied, with a correspondingly large amount of available content. The tools exposed by programming languages to handle failure conditions are also varied, ranging from error codes to exceptions to conditions to monads (and plenty of mixing and matching). I could write a lot on this subject, but I will try not to stray too much.

The Pit of Success Failure

In Java, doing the wrong thing with checked exceptions takes less code than doing the right thing.

Suppose you’re writing a function. Inside your function you do something that might throw a SAXException. The compiler, helpfully, warns you about this terrible state of affairs:

void createNewProject() {
    ...
    doSomeXMLStuff(); // compile error: SAXException not handled
    ...
}

Fortunately, there are a lot of ways to make this compiler error go away. Unfortunately, the easiest fixes are wrong. The two simplest are adding a throws declaration, or swallowing the exception.

First, let’s consider adding a ‘throws’ declaration. This is by far the smallest change you can make:

// "easy" idea #1: declare throws
void createNewProject() throws SAXException {
    ...
    doSomeXMLStuff();
    ...
}

To be fair, this change is actually alright in small doses. The problem is that propagating exceptions without changing their type has a nasty tendency to break encapsulation, without providing any benefit, and the negative effects compound over time.

In the example, having ‘createNewProject’ throw an exception related to XML is totally useless to a caller. The information “I failed to create the project and the error is related to XML” is not more useful than just “I failed to create the project”. Creating a project is likely to do many different things related to XML (reading config files, writing project files, etc), and these things may change from version to version, so the information “related to XML” is not indicative or reliable. We’ve effectively exposed a volatile implementation detail to the caller, instead of encapsulating it behind a more appropriate type of exception.

Have you ever had to convert sql exceptions into xml exceptions, in order to maintain backwards compatibility, when a backend changed from files to a database? That’s because of this mistake. On the other end of the spectrum, have you ever worked with code that declared ‘throws Exception’ everywhere? That’s because of the compounded effects of this mistake blending all of the different exception types together into a hodgepodge of “something went wrong but I don’t know what”.

Blindly rethrowing is tempting, because it is so succinct. I am certainly not innocent in this regard. On the other hand, a lot of checked exception hate can be traced back to this very problem.

Another portion of checked exception hate comes from the second “fix” I mentioned: swallowing the exception. This is easily achieved with a small try-catch block:

// "easy" idea #2: swallow
void createNewProject() {
    ...
    try {
        doSomeXMLStuff();
    } catch (SAXException ex) {
        ex.printStackTrace(); // <-- "advanced" users log instead of silently ignoring
    }
    ...
}

This fix has basically no redeeming factors, but is probably the most common mistake I've encountered in the past. When you see code like this, set off the alarms. Why is this exception so benign that we can continue without doing anything to fix it, and yet important enough to require logging? This is not handling, it is ignoring (there are cases where it makes sense to ignore an exception, but they are rare and should be commented). Even if the current version of doSomeXMLStuff happens to only declare that it throws a SAXException, never actually throwing one, this fact may not be true of past or future versions (which your code may end up interacting with).

Exceptions that are believed to be impossible should be rethrown as an unchecked RuntimeException. Exceptions that are known to be benign should be documented. Do not blindly swallow exceptions, assuming everything will go right. If you want to assume everything will go right, then that's asserting the exception is impossible (and as I said, you should rethrow it as a RuntimeException).

The Mountain of Encapsulation

Both of the misguided fixes from the previous section make the same mistake, but in two different ways. They both fail to encapsulate properly. Good methods expose their underlying failures as cases that callers will want to act on. Furthermore, callers should want to act on different cases in different ways. If callers are going to act on two different error cases in the same way, those error cases should be combined. If callers are going to try to pick apart an exception in order to decide what to do, then the error case it represents should be split.

Swallowing exceptions is a poor encapsulation because it prevents callers from knowing about problems in the first place. The caller will be forced to pick apart the eventual RuntimeException caused by the program being in an unexpected state, if they can even recover at all. Blindly rethrowing exceptions is a poor encapsulation because it creates muddled error cases. Do callers actually treat an SQLException differently from a SAXException? If not, then why are you telling them more than they need to know?

Java has several exception types to cover common failure cases, but it is often ideal to define your own method-specific exceptions. For example, you may want to disambiguate the different ways a FileNotFoundException may have been thrown. The way you recover from a missing configuration file is different from how you recover from a missing cache file.

Suppose we've decided to create a ReadConfigValue function that, given a file path, opens the file at that location and returns the number written into both of the first two lines. Our users happen to care about four unique conditions (not counting 'unexpected bug' and 'worked successfully'): the file is missing, the file is present but unreadable, the file is present and readable but the data is corrupted, or the file is present and readable and not corrupted but the two lines have inconsistent numbers. We want to expose each of these failure cases as a checked exception. This... is going to take a lot of code:

@SuppressWarnings("serial")
public class UnreadableException extends IOException {
    public UnreadableException(String message) {
        super(message);
    }
    public UnreadableException(Throwable cause) {
        super(cause);
    }
    public UnreadableException(String message, Throwable cause) {
        super(message, cause);
    }
}
@SuppressWarnings("serial")
public class CorruptedException extends IOException {
    public CorruptedException(String message) {
        super(message);
    }
    public CorruptedException(Throwable cause) {
        super(cause);
    }
    public CorruptedException(String message, Throwable cause) {
        super(message, cause);
    }
}
@SuppressWarnings("serial")
public class InconsistentException extends IOException {
    public InconsistentException(String message) {
        super(message);
    }
    public InconsistentException(Throwable cause) {
        super(cause);
    }
    public InconsistentException(String message, Throwable cause) {
        super(message, cause);
    }
}
public static ConfigData ReadConfigValue(String configFilePath) 
        throws FileNotFoundException, 
               UnreadableException, 
               CorruptedException,
               InconsistentException {
    if (configFilePath == null) throw new IllegalArgumentException("configFilePath == null");
    
    try (BufferedReader x = new BufferedReader(new FileReader(configFilePath))) {
        try {
            String line1 = x.readLine();
            String line2 = x.readLine();
            if (line1 == null || line2 == null) throw new CorruptedException("Not enough lines");
            int v1 = Integer.parseInt(line1);
            int v2 = Integer.parseInt(line2);
            if (v1 != v2) throw new InconsistentException("Line values don't match.");
            return new ConfigData(v1);
        } catch (IOException readFailedEx) {
            throw new UnreadableException(readFailedEx);
        } catch (NumberFormatException parseFailedEx) {
            throw new CorruptedException(parseFailedEx);
        }
    } catch (IOException closeFailedEx) {
        // should never happen: we're not writing so there's no unflushed buffer
        throw new RuntimeException(closeFailedEx);
    }
}

Notice that this code hides the fact that the configuration file contains plain numbers, as opposed to key value pairs or XML. Changing the format of the configuration file does not require changing the method's signature. This is good. What's not so good is the sheer amount of code.

Since three of the exceptions are non-standard, we had to define all of them. These definitions make up the majority of the code. Afterwards, the next biggest contributor of boilerplate is the wrap-and-rethrow logic. Both of these cases (defining and wrapping) are extremely common to encounter when you want to encapsulate, but Java makes them relatively verbose. So much so that I'm not sure I'd recommend actually bothering to encapsulate!

A few hypothetical changes improve the situation dramatically:

public static int ReadConfigValue(String configFilePath) 
        // 'throws X as Y' means when an X propagates out, we wrap it as a Y
        // 'forge' means create a new exception type, namespaced to the function, with the given name
        throws FileNotFoundException, 
               NumberFormatException as forge Corrupted,
               IOException as forge Unreadable,
               forge Inconsistent {
    if (configFilePath == null) throw new IllegalArgumentException("configFilePath == null");
    
    try (BufferedReader x = new BufferedReader(new FileReader(configFilePath))) {
        String line1 = x.readLine();
        String line2 = x.readLine();
        if (line1 == null || line2 == null) throw new Corrupted("Not enough lines");
        int v1 = Integer.parseInt(line1);
        int v2 = Integer.parseInt(line2);
        if (v1 != v2) throw new Inconsistent("Line values don't match.");
        return v1;
    }
}

This code makes use of two hypothetical features: "throw as" and "forge". A throws declaration of the style "throws HiddenExceptionType as WrappingExceptionType" is equivalent to wrapping the method body in a try-catch block that catches the hidden exception type, and throws the wrapping exception type (with the hidden exception as the wrapping exception's inner exception). Prefixing an exception type in a throws declaration with "forge" is equivalent to defining a whole new exception type with that name (the type would be placed in the same package, or perhaps linked to the function somehow).

Of course, these features are merely hypothetical. It's all well and good to declare "Encapsulation good! Even for exceptions!" but, in practice, the cost of trying (in Java) is significant. I hope that, at the very least, I've made a convincing case that it would be good to have ubiquitous repackaging of thrown exceptions based on what's useful to a caller instead of what the implementation happens to call.

Looming Issues

In the near future, Java will have proper lambda expressions and as a result the collections API will support functional concepts like mapping and filtering. Unfortunately, these features currently interact badly with Java's checked exceptions. Consider this code:

public static void RunStuff() throws SQLException, KeyNotFoundException {
    someUnfortunatelyGlobalCollection.filter(
        e -> getAgeFromDatabase(e) < 50 // getAgeFromDatabase throws SQLException, KeyNotFoundException
    );
}

Notice that the type of exceptions thrown by filter depend on the exceptions thrown by the lambda given to it. In this case, isInDatabase throws SQLException and KeyNotFoundException and so filter throws SQLException and KeyNotFoundException. But wait, how will the compiler determine this fact about filter? How is filter actually implemented?

There's a few possibilities, none ideal. Basically, from the outside, actual implementations choose between being used like this:

public static void RunStuff() throws SQLException, KeyNotFoundException {
    someUnfortunatelyGlobalCollection
        // filter throws the exception types provided to it
        .filter<WhateverTheItemTypeIs, SQLException, KeyNotFoundException>(
            e -> getAgeFromDatabase(e) < 50);
}

(Which requires lots of repetition of thrown types, and hits limitations created by type erasure.) or being used like this:

public static void RunStuff() throws SQLException, KeyNotFoundException {
    try {
        someUnfortunatelyGlobalCollection
            .filter(e -> getAgeFromDatabase(e) < 50); // filter throws Exception
    } catch (SQLException ex) {
        throw ex;
    } catch (KeyNotFoundException ex) {
        throw ex;
    } catch (Exception ex) {
        throw new RuntimeException(ex);
    }
}

(Which is a mess of unverified-by-the-compiler boilerplate to pull the types back out.)

Actually solving this issue will require support from the language, but I have no idea what that should look like. Functional languages would sidestep the problem with error monads, but such an approach is not idiomatic in Java.

Summary

Methods should expose encapsulated error cases that are useful to the caller. Java makes doing this very verbose. Conversely, Java makes subtly and blatantly wrong approaches to checked exceptions unfortunately succinct. This problem could be improved with the addition of succinct syntax for wrapping and declaring exceptions.

Secondarily, the introduction of lambda expressions to Java has the potential to create lots of new problems related to checked exceptions.

---

Discuss on Reddit, Hacker News

---


Twisted Oak Studios offers consulting and development on high-tech interactive projects. Check out our portfolio, or Give us a shout if you have anything you think some really rad engineers should help you with.

Archive

More interesting posts (5 of 8 articles)

Or check out our Portfolio.