Dmitrii Aleksandrov

Rust Solves The Issues With Exceptions

Posted on Last edited 10 mins

A small topic that’s too big to fit in a larger Rust post.

Disclaimers

Issues with exceptions

Exceptional flow

Just like a return value, an exception is a value.

Just like a return value, an exception is “a result of calling the function”.

Despite this, exceptions introduce a special try-catch flow which is separate from normal returns and assignments. It prevents ergonomic function composition . You can’t pass the “result” of a throwing function directly into another function, because the return value is passed only when the first function completes successfully. Thrown exceptions aren’t passed in and propagate out instead. This is a very common error-handling pattern, and I get why people want to automate it. But when you need to pass the error data into the second function, Java makes you write a verbose try-catch block and cook a home-made Result type:

// We're given `GReturned g(X x) throws GException` and `f(/* result of g??? */)` which we control.
// Let's generously assume that we also control the definitions of `GReturned` and `GException`.
// This allows us to avoid extra wrapper objects and implement a sealed interface directly.
// As far as I know, this is the most simple and performant implementation of sum types in Java.
sealed interface GResult permits GReturned, GException {}
class GReturned implements GResult { /* ... */ }
class GException extends Exception implements GResult { /* ... */ }

GResult gResult;
try {
    gResult = g(x);
} catch (GException gException) {
    gResult = gException;
}
f(gResult);

Compare the snippet above to our original idea, which works as intended if g can’t throw:

f(g(x));

Exceptions hurt most error handling patterns. Even very common ones, like rethrowing a wrapper exception. As you’ll see in the next section, this pain isn’t even necessary to have convenient propagation. Exceptions aren’t worth the cost.

Can you guess why I used an intermediate variable instead of calling f(g(x)) and f(gException) in try-catch?
try {
    f(g(x));                       // <- If `f` also happens to throw `GException` and does this when `g` didn't...
} catch (GException gException) {  // <- then this will catch `GException` from `f`...
    f(gException);                 // <- and then call `f` the second time! 💣
}

This is a great example of why automatic error propagation is tricky and may lead to bugs. In Rust, the equivalent code would look like f(g(x)?)?, clearly marking both points where a jump / early return happens and making the bug easier to notice.

Even “typical” error wrapping is unergonomic

Exceptions always force you to write a whole special try-catch block that can’t be abstracted away:

try {
    h();
} catch (InnerException e) {
    throw new WrapperException(e);
}

Meanwhile, Rust’s error propagation is abstracted into a ? operator that can perform the wrapping for you:

h()?;

This automatic conversion only works when WrapperException implements From<InnerException>. But this is the most common case. And even in other cases, this is still just a regular data transformation that can be expressed concisely using regular functions:

h().map_err(WrapperException)?;

That’s all it is. Error wrapping doesn’t have to be more complicated than that.

Unchecked exceptions

Traditional unchecked exceptions are dynamically typed “result” values. Any function can throw (or not throw) any exception. This makes programs unpredictable. Especially, the control flow. Almost any line of code can throw an exception, interrupt the current function, and start unwinding the stack. Potentially leaving your data in an inconsistent, half-way modified state. But programmers can’t always keep exception safety in mind and get it right on their first try. No one wraps every line in a try-catch. The result is unpredictable, unreliable programs with poor error handling. “Unhappy paths” are more important than they seem:

Almost all (92%) of the catastrophic system failures are the result of incorrect handling of non-fatal errors explicitly signaled in software.1

Unchecked exceptions aren’t reflected in the type system, but they are still part of a function’s contract. Many style guides recommend manually documenting the exceptions that each public function throws. But soon these docs will get out-of-date because the compiler doesn’t check2 the docs for you. Callers can’t rely on these docs’ accuracy. If callers want to avoid surprise crashes, they always have to remember to manually catch Exception. And you know how that goes

Checked exceptions in Java

Checked exceptions seem like a reasonable reaction to the issues with unchecked exceptions. But the implementation in Java is very flawed. There are entire discussions on using only unchecked exceptions, as does every other language with exceptions. I found the following root causes:

Solutions in Rust

Rust gracefully solves these issues by having:

Rust’s own issues

It would be unfair to end the post here and declare that Rust has the best error handling because it solves all issues found in another language. Rust’s approach inevitably brings in some new, different issues:

In my opinion, these issues aren’t nearly as fundamental and annoying as the issues with exceptions. Now I can conclude that Rust has the best error handling! 💥


Good articles that I haven’t hyperlinked anywhere else in the post:

Discuss


  1. Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-intensive Systems  ↩︎

  2. Get it? Exceptions stay unchecked! 🥁 ↩︎

  3. Shout-out to u/WormRabbit who has pointed this out ! Originally, I missed this point and it wasn’t in the post. ↩︎

  4. Actually, there are some workarounds, like using std::panic::catch_unwind or doing the work on a separate thread . That’s what all popular web frameworks do to avoid crashing the entire process when one of the requests panics. But the process still crashes if the target doesn’t support unwinding or the project is built with panic = "abort" setting. It also crashes when a destructor panics in an already-panicking thread↩︎

  5. This is controversial because it doesn’t always provide equivalent non-panicking APIs for other use cases. It should accommodate low-level use cases better. But the existence of a convenient panicking API is OK. It’s more appropriate for most applications. Most applications don’t attempt to recover from OOM. A typical modern system with memory overcommitment will never report OOM on allocation anyway. ↩︎

  6. To be fair, the same result can be achieved in Java if you plan in advance and wrap all exceptions. You can throw exactly one checked exception, some base class. When you need to throw a new exception, you inherit a new child from that base class. This won’t break the callers, because they are already forced to handle the parent. And the callers also have type information and nice autogenerated docs about the possible children. Thanks to brabel for pointing this out .

    Although, the Rust solution still seems cleaner and produces fewer types/objects/boilerplate, considering that defining and rethrowing wrapper exceptions is pretty verbose in Java. Also, when you decide to make a breaking change and mark an enum as #[non_exhaustive], it doesn’t cause as much refactoring, compared to changing anything in the throws clause. ↩︎

  7. There are “easier” alternative approaches that erase the type, like Box<dyn Error>↩︎

  8. Man, and third-party libraries are so good. But I promised not to get into detail with those… ↩︎

Comments