The Case Against Exceptions

Goto statements went out of style in the 60s, relegated today to be the prototypical example of bad coding. Yet hardly anyone seems to be bothered by exceptions, which do basically the same thing. Used improperly, exceptions behave like goto statements and can be just as bad.

Exceptions essentially allow you to move error handling code out to a dedicated location. They have the added benefit that they can propagate up the stack so you can consolidate error handling into sensible modules. This allows certain subsystems to not care about exceptions because they can be handled by a caller. Gotos on the other hand generally would require every function have its own error handling section, either because the language doesn’t support gotos to a different scope, or the fact that it would be a terrible idea even if it was supported.

As a corollary, without exceptions you end up having to check every method call to ensure it succeeded, whereas exceptions optimize for the common case and make for much cleaner looking code.

Unfortunately, these benefits are mostly illusory, if you aren’t careful. You end up needing to write much more exception handling code to handle the different cases, and it’s incredibly easy to introduce subtle and hard-to-spot bugs. For example, suppose you have the following (loosely based on a snippet by Raymond Chen):

try {
    Package.Install();
} catch (Exception e) {
    // error handling
}

...

class Package {
    void Install() {
        UpdatePermissions();
        CopyFiles();
        CreateDatabase();
    }
    ...
}

Notice the subtle bug here: if CreateDatabase throws, then the catch statement needs to know that CopyFiles() and UpdatePermissions() have already been run, but we’ve already lost that important context because we returned from those functions already. To be correct, the catch clause needs to know exactly how the install method works, what it can throw, and in what order it performs its operations (in this case, a cleanup method needs to know that permissions should be reverted and copied files removed. And depending on how far CreateDatabase got, it may have to clean up database files as well). This introduces tight coupling that isn’t immediately obvious because in the common-case scenario, nothing goes wrong and the bug is not exposed. However, important information about where the exception originated is lost unless additional work is done to preserve this state.

More generally, exceptions can decrease code visibility, because it’s extremely hard to tell if code is correct by looking at it. Did you forget to catch possible exceptions here, or is it handled further up the stack? Does any given method document all the exceptions it could throw? How do you know without reading the declaration?

These problems are not academic and invariably many larger projects become basically unmanageable because each subsystem introduces another layer of exceptions that must be handled. This leads to what I call whack-a-mole debugging: just run the code in production, and every time an uncaught exception causes a crash/bug, you go in and find where you let the exception leak and plug the hole, then repeat this forever.

But wait! What about finally statements? Finally can help improve the atomicity of methods by cleaning up, freeing resources, and generally making state consistent again. But they are once again tightly coupled to exactly what the throwing method was doing: what needs to be cleaned up? What is the order of operations of the function that threw? More importantly, will this break if the function changes later down the line? The catch / finally blocks might not even be in the same file or class, which means the code locality is now far enough away that there is a brittle sort-of-contract here that’s probably fuzzily documented, if at all.

But wait! What about checked exceptions like in Java? Doesn’t that solve a problem by explicitly declaring what the caller should expect and handle?

Even assuming they are implemented and used correctly, checked exceptions suffer from a versioning problem. Changing what exceptions can be thrown in a method can cause calling code to break or stop compiling, so checked exceptions are actually part of a method’s signature. Want to add a new throws declaration in a library? You can’t — you have to make a wrapper method to ensure backwards¬†compatibility, assuming other people use your code. So unless you are prepared to do this (or don’t care that other people depend on your code) you must never change the throws declaration of a method.

Libraries
The alternative without checked exceptions is just as bad: a call to any random library could crash you at any moment because it threw an unchecked exception you weren’t expecting. All told, it makes it a total pain to reuse code because literally any function call is a hidden minefield of invisible gotos: can you guarantee the call won’t throw, and that everything it transitively depends on won’t throw either? No? Then you better wrap it in a massive try statement. Exceptions mean any method anywhere can return at any moment, creating exponentially more return paths for every line of code.

Another subtle problem exceptions can cause is it can force you to architect your code differently, due to disagreements on the meaning of an exception: you might throw exceptions rarely, but a library might be more liberal with them, which can force you to change how your code is structured to be more correct when using this library. Because partially-written states are a real threat when programming with exceptions, you may end up having to structure a lot of code into a “commit” phase where exceptions won’t cause problems.

Some Suggestions
1. Set and enforce rules on how and where exceptions can be thrown and where they will be expected and handled. Make a guarantee about the side effects of a function when it throws, and what the catch block will do in terms of cleanup.
1a. Avoid operating under uncertainty: don’t do anything fancy in a global catch-all block. Catch the most precise type of exception and do the least amount of work possible.

2. Create and enforce boundaries to encapsulate subsystems; do not allow exceptions to cross subsystem boundaries. This is the loose coupling pattern applied to exception handling. It will help prevent return paths from exploding exponentially.
2a. You can use a stricter version of this rule by requiring your own code to never throw. This way, you only have to worry about external libraries that might use exceptions, but you can abstract this away from other modules. Google’s own C++ style guide forbids throwing exceptions.

Leave a Reply

Your email address will not be published. Required fields are marked *