Dmitrii Aleksandrov

Rust Solves The Issues With Exceptions

Posted on Last edited 9 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 make this (and similar) patterns suffer so badly. They’re not “uncommon” enough to justify the pain. And, as we’ll see later, “common” error propagation can still be ergonomic without exceptions! They’re not 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! ๐Ÿ’ฃ
}

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 a reader on Reddit 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. There are “easier” alternative approaches that erase the type, like Box<dyn Error>↩︎

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