Rust Solves The Issues With Exceptions
Table of Contents
A small topic that’s too big to fit in a larger Rust post.
Disclaimers
- Rust isn’t the only language that doesn’t have exceptions and handles errors by value. But error handling in languages like Go is flawed in its own way. Here, I don’t discuss these other implementations and use Rust as a specific successful example of exception-free error handling.
- I use Java for most examples of exceptions because it lets me discuss checked exceptions as well. For unchecked exceptions, this shouldn’t matter because the implementation is very similar in most popular languages.
- I ignore third-party libraries and discuss common, “idiomatic” mechanisms provided by the language.
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 return
s 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:
- Throwing a new checked exception from a method is always a breaking change. Because of this, libraries with a stable API might decide to throw an unchecked wrapper instead. Callers can still catch and handle it, because…
- Unchecked exceptions are recoverable. No wonder people go against the
“official”
recommendations
and use them for recoverable errors! The
try-catch
mechanism is the same for all exceptions. You’re always just oneextends RuntimeException
away from pleasing the compiler without a big refactoring. Which is needed, because… - The lack of union types and type
aliases
infamously forces the
programmer to update the
throws
clause in every method all the way up the stack (until that exception is covered by acatch
that swallows or wraps it). - Java’s type system can’t represent checked exceptions generically
3. You can’t have an interface that throws an unknown, generic
set of checked exceptions. An interface has to either:
- Throw no checked exceptions. This forces the implementors to wrap and
rethrow these as an unchecked
RuntimeException
, losing type information.Runnable.run
is an example of this. - Throw a specific, made-up list of checked exceptions that may not make
sense for all implementations.
Appendable.append throws IOException
is an example of this.
- Throw no checked exceptions. This forces the implementors to wrap and
rethrow these as an unchecked
Solutions in Rust
Rust gracefully solves these issues by having:
- A clear
separation
between:
- “Expected” return values that must be handled explicitly.
- Unrecoverable4 panics that are used in one of the two ways:
- Assertions that indicate a bug in the program when hit.
- Intentional
“
eprintln
+ cleanup +exit
” for cases where the author of the code made a judgement call that the application can’t possibly (want to) recover from the current situation. E.g., most of the Rust Standard Library APIs panic on OOM conditions because it’s geared towards application programming and treats OOM as a situation that the application won’t attempt to handle anyway. 5
- Ergonomic sum types that are used consistently in the standard library.
- A standard generic
Result
type with methods likemap_err
to help in typical scenarios like adding context to errors. - A rich type system that allows handling errors generically without losing type
information. The
E
inResult<T, E>
is the prime example of this. - A compact
?
operator to convert and propagate errors. It makes “dumb” error propagation as ergonomic as when using exceptions. But it’s also explicit and visible in a code review. In Rust, the equivalent of that incorrectf(g(x))
inside atry
block would look likef(g(x)?)?
, clearly marking both points where a jump / early return happens. - Ergonomic, exhaustive pattern
matching
,
complemented by:
- More syntax sugar like
if let
,while let
,let-else
. - The
#[non_exhaustive]
attribute to solve the API stability problem where necessary. It’s not unchecked, it still forces callers to handle unknown error variants from the future!
- More syntax sugar like
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:
- When your function can encounter multiple different errors and you want to
preserve concrete type information 6, Rust makes you manually define
a wrapper enum and implement the conversions. This is necessary because Rust
doesn’t have anonymous unions
.
But the boilerplate isn’t too bad and even built-in solutions like
?
eliminate some of it 7. - There’s also a more specific issue with those wrapper enums. Sometimes, they
tend to grow and be shared across multiple functions, where not every function
actually returns every error variant. This undermines the benefits of
exhaustive pattern matching and types-as-documentation, leading to…
hand-written docs that list the errors that the function can
return
!
Sounds familiar, huh? The experimental
terrors
library does a great job of describing this issue . - Moving around bloated return values and explicitly checking those can
sometimes hurt
performance
.
But this can be solved by boxing the error, using output parameters or
callbacks, or using global variables. Besides,
Result
and other enums are often “free” in terms of memory, due to niche optimizations . Some errors may even be represented as zero-sized types . - Even though Rust supports local (non-propagating) error handling much better than languages with exceptions, it’s still not perfect and can over-emphasize the “stop on the first error” pattern. Reporting multiple errors at once isn’t supported just as well and may require third-party libraries or verbose in-house solutions. I discuss this issue in more detail in my in-house solution which you can use as a third-party library ๐
- As mentioned in this post about .NET , exceptions usually provide good insight into the origin of an error, with tools like stack traces and debugger breakpoints. Tracing the origin of an error value in Rust is a more demanding process that may require: manually enforcing best practices around logging and adding context to errors; manually capturing backtraces where necessary; longer debugging sessions. On the other hand, debugging runtime issues comes up way less often in Rust ๐
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! ๐ฅ
Related reading
Good articles that I haven’t hyperlinked anywhere else in the post:
- Checked exceptions: Javaโs biggest mistake
- The Trouble with Checked Exceptions. A Conversation with Anders Hejlsberg, Part II
- Either vs Exception Handling
- The Error Model
Discuss
Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-intensive Systems ↩︎
Get it? Exceptions stay unchecked! ๐ฅ ↩︎
Shout-out to a reader on Reddit who has pointed this out ! Originally, I missed this point and it wasn’t in the post. ↩︎
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 . ↩︎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. ↩︎
There are “easier” alternative approaches that erase the type, like
Box<dyn Error>
. ↩︎Man, and third-party libraries are so good. But I promised not to get into detail with those… ↩︎