9
$\begingroup$

In Java, checked exceptions are well-known to be extremely unergonomic, to the point that many people circumvent them.

Evidently, checking exceptions, if done properly, can lead to a more robust code—which is an excellent advantage. Rust's approach, though doesn't make use of exceptions, shows this robustness improvement.

Are checked exceptions inherently painful? What designs could be used to make them more ergonomic?

$\endgroup$

5 Answers 5

6
$\begingroup$

I would argue that the main reason that Java's checked exceptions are unergonomic, is the lack of union types and type aliases. Consider this hypothetical code:

type MyException = FooException | BarException | BazException

void someMethod() throws MyException {
    // ...
}

This would solve the problem of having to write throws FooException, BarException, BazException repeatedly across multiple methods which call each other and hence can all throw the same types of exceptions. Additionally, if another type of exception has to be added to this list after a refactoring, then it only needs to be added in one place.


Another reason Java's checked exceptions are annoying is that their names are so long ─ every exception class has the word Exception as a suffix, 9 letters which don't convey much useful information since these class names are only written in throws clauses, throw statements and catch clauses, so the context already indicates that they are exception types.

I don't have my own recommendation for how to name exception types differently, but e.g. in Python the names are IndexError, TypeError, ValueError and so on instead of ArrayIndexOutOfBoundsException like in Java, so clearly it's possible to do better.

$\endgroup$
2
  • 2
    $\begingroup$ The verbosity of Java exception names really is ridiculous. If an index is wrong, what could be wrong with it other than being out of bounds? (And if such a thing were wrong, would it not be a fundamentally different problem?) If an index is out of bounds, is it really significant whether it's an array index or a string index? Does that impact on the interface to the exception object? Don't we already know that information from the context of the code? (At least the superclass IndexOutOfBoundsException exists, but still.) $\endgroup$ Commented Jul 1, 2023 at 15:18
  • $\begingroup$ @KarlKnechtel many bad decisions were made in Java’s early days pertaining to the standard library $\endgroup$
    – Seggan
    Commented Jul 7, 2023 at 0:43
5
$\begingroup$

One of the big problems with Java-style checked exceptions is that while they (arguably) improve reliability, they hurt adaptability. If a method you call throws a new type of exception, you have to change the signature of your method as well, even if you don't actually deal with this new exception.

That's where Anchored Exception Declarations come in. They allow you to declare your checked exceptions in terms of the methods you call as opposed to directly listing every single exception explicitly:

void foo(SomeClass bar, SomeOtherClass baz)
  throws like bar.quux(baz)
    propagating NotImplementedError
    blocking IOException {
  try {
    bar.quux(baz);
  }
  catch IOException {
    // Handle the exception
  }
}

Here, we are declaring that we can throw the same exceptions that bar.quux can throw when called with baz as an argument, except that we are going to handle IOException ourselves and we might also throw NotImplementedError.

So, if SomeClass.quux changes the exceptions it can throw, we don't have to change the definition of foo. Of course, someone, at some point in the call chain will have to handle these exceptions, but unlike with Java-style checked exceptions, we don't have to change every single link in the call chain.

You have to be careful to not leak private implementation details, but it has been shown that this can be done in a way where it does not violate information hiding. And if you think about it, for example the Template Method Design Pattern also requires knowledge about which methods are called internally … in fact, that's the whole point of that Pattern.

The original type checking algorithm for Anchored Exceptions required whole-program analysis and was therefore anti-modular, but that has since been fixed.

See

$\endgroup$
4
$\begingroup$

API breakage

One problem with Java exceptions, is that adding another type of exception to a function causes an API breakage if it is not a subclass to the declared exception. And of course for a function to start throwing checked exceptions is a break in itself.

For API evolution, this can sometimes be problematic, as it encourages methods to declare their exception type as general as possible, which is less nice for the user.

Nesting

Catching exceptions in Java requires nested scopes, which can be hard to read when multiple levels try-catch are used. In general, this is why you can see utility code like closeSilently(file), as the explicit try { if (file != null) file.close(); } catch (Exception e) { } often makes the code feel harder to read.

C++ suffers from the same problem, so in Herb Sutter's proposal for updated C++ exception handling, there is a tentative idea to try to remove the scoping:

try return safe_divide(i, j); 
catch {
  if (err == arithmetic_errc::divide_by_zero) return 0;
  if (err == arithmetic_errc::not_integer_division) return i / j;
  if (err == arithmetic_errc::integer_divide_overflows) return INT_MIN;
}

While not part of the proposal, it does show a way to reduce nesting.

Cumbersome to silence

While

try 
{ 
  foo(); 
} 
catch (Exception e) { }

And

int z;
try 
{ 
  z = foo(); 
} 
catch (Exception e) 
{
  throw new RuntimeException(e);
}

Both work, they lack the ease of some syntactic sugar like:

catch foo(); // Assume this silently catches the exception
int z = bar()!!; // Assume !! propagates the error as a runtime exception.

Summary

The lack of ergonomics of checked exceptions can mostly be derived from the excessive nesting and the lack of syntactic sugar when rethrowing or ignoring an exception. Because API evolution issues exceptions are often broader than useful, reducing the benefit somewhat. On the other hand, it's certainly better than unchecked exceptions in many ways, as the latter is extremely poor at documenting the actual exceptions that may occur.

$\endgroup$
1
  • $\begingroup$ Swift actually does have the things you mention, with try? and try!. The former converts foo() from throws -> T to -> T?, and the latter makes it just -> T and causes a panic on error. $\endgroup$
    – Bbrk24
    Commented Jul 1, 2023 at 12:54
3
$\begingroup$

There's an excellent answer by Daniel A.A. Pelsmaeker over on our big sister site, which I'll summarize here.

In it, he classifies the "things that can go wrong in your program" into, broadly, four categories.

  • Fatal exceptions are stop-the-world. These are "the operating system ran out of memory" or "somebody literally pulled the plug on the computer in the middle of my program". You cannot stop these. They're not your fault. You can't handle them. You probably can't even log the exception in some cases.
  • Boneheaded exceptions are bugs in your program. You accidentally accessed an array index out of bounds, or you passed a negative integer to a function that only works on positive ones. That's your fault, and its your job to prevent that.
  • Vexing exceptions are things that happen in the normal course of programming. Trying to parse an integer can inherently fail, and that throws a NumberFormatException. A NumberFormatException doesn't mean your program is flawed. It just means the data you gave it (which may have been user input or something over the network) wasn't what you expected it to be.
  • Exogenous exceptions are not bugs in your code, but you have to deal with them. The difference between exogenous and vexing is that the former is exceptional, whereas the latter is something that normally happens. If you try to access a file and it doesn't exist, that's an exogenous exception. The file should exist, but something out of your control made it not so. You have to deal with the problem, though; you can't simply read from a file that doesn't exist.

In an ideal API, vexing exceptions are not exceptions. As I described above, they're not actually exceptional. They happen all the time, and it's normal. That parseInt function should probably return an Optional or (in a language like Kotlin with checked nulls) a null on failure. If you need to provide more error information, maybe a Rust-style Result is in order.

So if we're talking about an ideal world, we can assume vexing exceptions don't happen. Fatal exceptions should never be checked (and, indeed, they're unchecked in Java). They're difficult (and sometimes impossible) to catch correctly, so 99.999% of programs should just let the whole thing fail. Boneheaded exceptions also shouldn't be checked. A boneheaded exception is your fault, as the user of the API. You did something wrong and violated a precondition, and the library author was nice enough to check for that and let you know. You don't catch-and-suppress a boneheaded exception; you fix the code that caused it[1].

That leaves exogenous exceptions. These make sense as checked exceptions. Bad things happened that aren't our fault. If they occur, we're in error recovery mode, and we're definitely not in the normal "happy path" of our program anymore.

So I think, broadly, the problems with Java's checked exceptions are:

  • Inconsistent treatment of vexing exceptions. Parsing an integer can throw an unchecked parse error, but parsing a URI is checked.
  • When you have exogenous exceptions, they tend to get lumped together like the infamous IOException, which is declared in the throws clause of, let's conservatively say, 40% of the Java standard library. I might be able to handle FileNotFoundException specifically, but there's no meaningful way to respond to a throws IOException other than "catch and suppress", which is always a problem.
  • This was mentioned in other answers, but exceptions make higher-order functions difficult in Java. A function that takes another function (or any vaguely callable object) as argument must decide up-front whether or not the inner function can throw checked exceptions. That means a lot of functions end up in the red camp and just assume their inner functions can throw whatever.

The first bullet point can be solved by replacing vexing exceptions with something viable in the large. The last two can be solved with a better exception hierarchy and more fine-grained control over function effects. Scala is currently experimenting with a system of effects for checked exceptions.

Now you might wonder why I consider Result different from exceptions. After all, I said "vexing exceptions shouldn't exist" immediately followed by proposing a feature very similar to them. The difference is in granularity. Exceptions, at least Java-style ones, are statement-based. That is, when an exception fires off, the entire current statement stops, and at minimum, something somewhere else (even if in the same function) catches it. Vexing exceptions are inherently normal. They happen all the time, so they shouldn't stop the world and interrupt the normal flow of your program. I don't mind statement-based exceptions for exogenous cases. Those are actually exceptional, so they should stop the world and do something extravagant.

With a Rust-style Result, I choose how to handle the problem inline, as part of the normal flow of control.

let my_number = my_string.parse().unwrap_or(0);

If we want to propagate the error, we can do so. Rust has the ? operator which passes errors up to the caller.

let my_number = my_string.parse()?;

Or we can assert that the vexing exception is in fact a boneheaded one (e.g., "the caller failed a precondition, or called some unsafe function in the wrong way"), and convert the exception to an unchecked one.

let my_number = my_string.parse().expect("You did a stupid :(");

The point is, the failure state is a real, first-class object in the language, so it can be handled as part of the normal flow of control. Languages with try ... catch expressions like Kotlin move closer to this, but they're still not quite first-class values, so that's why I still advocate for Result types over actual exceptions, at least for vexing exceptions.


[1] When I say "don't catch this", I'm naturally excluding the case of a top-level try ... catch responsible for logging. A catch-all in your main is generally fine for anything non-fatal and improves your ability to iterate on code. When I say "don't catch", what I mean is "don't catch as part of the control flow of your program".

$\endgroup$
1
  • 1
    $\begingroup$ IOException is probably 70% of the reason people hate checked exceptions $\endgroup$
    – Seggan
    Commented Jul 7, 2023 at 0:49
0
$\begingroup$

Handling unexpected and unusual conditions properly is painful in any programming language, because there are more ways for code to fail than succeed, because it is easy to forget some of the ways it can fail, and because the handling code can easily obscure the common-case code. Nevertheless, checked exceptions as in Java are one of the best mechanisms available for this purpose, when used correctly. They remind the programmer about the possible outcomes of code they are using, and they allow factoring the handling code out of the common-case code to separate handlers.

Using unchecked exceptions to reduce verbosity is an antipattern: in languages that don't have checked exceptions, developers tend to fail to document possible exceptions from code. For example, one study of C# code showed that the vast majority of exceptions were not documented, an omission that leads to unreliable code. When feasible, unchecked exceptions should be reserved for situations corresponding to true errors in the program: that is, programmer mistakes that the program should not normally try to recover from.

A type-safe alternative to checked exceptions is to wrap function results in some kind of variant (an enum or another object wrapper). This strategy also forces the programmer to reason about alternative outcomes of the computation, but in a way that is even more awkward than exceptions, since exceptional-case code must be wrapped around every function call rather than being factored into a separate handler. A less safe approach is to encode alternative results into special values (error codes) in the function's range. Error codes are error-prone because nothing signals to the programmer to remember to watch for them.

In languages that support them well, monads offer another way to handle alternative execution paths. However, they do not work as well when there are multiple different kinds of exceptional behavior, requiring the unintuitive machinery of monad transformers.

One problem with checked exceptions is that they don't interact well with higher-order functions in Java, and in this case, a wrapper or the use of unchecked exceptions may be unavoidable. One proposal for supporting checked exceptions with higher-order functions is "tunneled exceptions", described in the paper by Zhang et al.: "Accepting blame for safe tunneled exceptions", in PLDI 2016. Tunneled exceptions provide a form of safe exception polymorphism for higher-order functions, avoiding the pitfalls of unchecked exceptions. That paper also explores the philosophy of exceptions in more detail.

$\endgroup$
0

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .