I was following the article and nodding my head all the way up to the Rust section, at which point I lost track because I'm not too familiar with Rust :)
It's also not clear to me how `if let , while let , let-else` help (and how you'd use them), or what an "ergonomic sum type" is and how it helps with exceptions.
Would be really cool if there was a code snippet in Rust comparing/explaining the advantage over Java for each benefit.
The basic idea is that an exception is just data, so you can just treat it as part of the return type. Most of the rest of it is constructs that make it easy to work with, which mostly means making things behave similarly to the way exceptions work. But with the option to just treat it as regular data rather than complex catch try rethrow logic.
Checked exceptions were inferior to this because they was outside of the type system; saying “This type or three possible exceptions” was so painful people stopped using. With this approach it’s just a type and has whatever name you like.
> “This type or three possible exceptions” was so painful people stopped using.
A reader on Reddit has also pointed out [1] that it's literally impossible to represent "n possible exceptions" generically. Java makes you manually write out exactly 3 exceptions in the signature, or exactly 1 exception in the signature, or exactly 0 exceptions in the signature...
This isn't the only pattern that `if let` is suitable for. It's often used without an `else`, as a syntax sugar for
match value {
the_only_case_that_we_care_about => {
// Do something with the bound value.
},
_ => {}, // Don't do anything, we don't care about the other cases at all.
}
> Would be really cool if there was a code snippet in Rust comparing/explaining the advantage over Java for each benefit.
Yeah, I thought about that. But the post kept and kept expanding in scope so I decided to instead hyperlink where I could.
> It's also not clear to me how `if let , while let , let-else` help (and how you'd use them)
All three of those are hyperlinks in the post.
> what an "ergonomic sum type" is
Here, I mean language support for defining and using sum types[1] without verbose code that doesn't reflect the programmer's intent well.
A typical sum type implementation in Java uses a sealed interface. You can't implement interfaces on "foreign" types that you don't control, like `boolean`. You often have to define extra wrapper classes that distract you when navigating the codebase and also hurt performance:
public sealed interface MutuallyExclusiveFlags permits FlagA, FlagB {}
public record FlagA(boolean value) implements MutuallyExclusiveFlags {}
public record FlagB(boolean value) implements MutuallyExclusiveFlags {}
No boilerplate, no extra types, no unnecessary performance cost. This whole object takes only 2 bytes of memory on the stack! [2]
I didn't go into detail on this, because the post isn't about introducing sum types.
> and how it helps with exceptions
The part about "exceptional flow" describes some cases where Result-based error handling is preferable to using exceptions. Language support for sum types is necessary to properly express Result values and the sets of possible error values inside.
I know all about Result, it's great for errors that are recoverable. But there's a lot of errors that aren't really recoverable inside a library, but you may not want to cause the code using that library to crash - perhaps your buggy component can be worked around.
Ocaml has both algebraic data types and exceptions, and works really well.
> But there's a lot of errors that aren't really recoverable inside a library
Aren't all propagated `Result`s examples of errors that the library itself doesn't recover from and lets the caller decide what to do? IMO, panics in libraries are for cases that are either "impossible" (`panic` as `assert`) or the library author made a judgement call that the application is also helpless in this situation (`panic` as `exit`, like OOM in most of `std`).
> you may not want to cause the code using that library to crash - perhaps your buggy component can be worked around.
That's a question of isolation! Rust libraries operate within the same process and address space as the application code. A buggy library could arbitrarily modify the application data or the process state and violate arbitrary application invariants. I'm not even talking about UB here. Just application-level invariants and assumptions.
Given this, the Rust's choice to either "abort the thread and poison held mutexes" or "abort the process" sounds very reasonable.
In other discussions, I was pointed to Erlang's "let it fail" error handling with multiple isolated processes. [The Error Model](https://joeduffyblog.com/2016/02/07/the-error-model/), which I reference in my post, also touches on this topic.
Panics in rust threads do not crash the process. Only panics in the main thread crash the process. So if you run nothing in the main thread, you can take advantage of this.
> Panics in rust threads do not crash the process.
They do on several occasions, as I mentioned in the post:
> the process still crashes if the target doesn’t support unwinding or the project is built with panic = "abort" setting.
The linked guide to error handling in Rust [1] mentions another such case:
> Even in the default unwind-on-panic configuration, causing a panic while the thread is already panicking will cause the whole program to abort. You must therefore be very careful that destructors cannot panic under any circumstance. You can check if the current thread is panicking by using the std::thread::panicking function.
Rust doesn't support that, but there's an RFC trying to figure out how that could be done (hasn't gone anywhere after more than 10 years of discussions): https://github.com/rust-lang/rfcs/issues/294
But Rust supports macros, just like Lisp, so of course someone wrote a library that provides something similar:
Yeah, but there's always a cultural struggle to prevent everyone from using their own incomprehensible DSL. See "The Lisp Curse" [1]. Even vanilla Haskell (without macros, i.e. without Template Haskell) takes it too far by allowing to define arbitrary custom operators with arbirtary associativity and precedence. Many Haskell libraries are an incomprehensible soup of symbols instead of regular functions with readable names. Rust does pretty well so far. Macros are used appropriately in the ecosystem.
The Lisp Curse was written by someone who never worked on a Lisp project, let alone with other people.
It's a good example of what hallucination looked like before AI took it over.
Macros are super helpful in communicating ideas to future maintainers. The alternative to writing a macro and using it seventeen times is to just open code some similar logic in seventeen places.
The macro calls can all be found by name, and if something is wrong, it can be fixed in one place: the macro definition.
Macros run in your development system, and so do not have to be debugged in the live target. Most macris are functional and have only one input: the unexpanded macro call. They can be debugged simply by inspecting the output, and iterating on it.
That's the thing! In this example, `f` doesn't expect valid data, by design. Some functions are responsible for figuring out what to do in case of unsuccessful operations.
Maybe `f` would be easier to imagine as a simple mechanical refactoring where you extract a large `try-catch` block into a separate generic helper function. It's more convenient and abstract to make it accept a single `Result` value, rather than the entire list of arguments to call `g`. Sometimes `f` is not supposed to be responsible for calling `g` or even know about `g` and its arguments.
> isn't 'the error' just a case of valid data that shouldn't be thrown as exception at all?
That's the thing! Errors are just a case of return data! To me, it just makes more sense to model them like that rather than like checked exceptions. Checked exceptions are simply unnecessary if the language has sum types, Result and some syntax like Rust's `?` for propagating errors easily like exceptions. Checked exceptions are a weird, special and sometimes poorly supported [1] way to "augment the return type". It's easier to just have a single, all-encompassing, composable return type.
They aren't necessarily mixed together in an unmaintainable way. In a Rust-like functional paradigm, fallible functions typically return Result<T, E> and you can extract the T out of it and proceed to work on just the valid T when you've already handled the errors. Just like you would do in a language with exceptions.
It's equivalent to having to "catch or specify" [1]. IMO, it's a lesser evil than unchecked exceptions [2]. Explicit and mandatory handling is desirable for most errors. The caller has the responsibility to decide whether it needs to handle the error. If it simply wants to propagate it, there's the `?` operator. If it doesn't care about defining a wrapper type and/or preserving the error details, there's `Box<dyn Error>` (equivalent to `throws Exception`). Explicit and reliable error handling usually doesn't come at the cost of writing full `match` statements everywhere.
"Explicit and mandatory handling is desirable for most errors." -- that depends on the domain. Quite often all the caller needs is to cancel whatever it is doing and propagate the error. In this case why do we need a detour into the f() function in your example?
From the part of your essay that you linked to:
"Potentially leaving your data in an inconsistent, half-way modified state." -- this is just not the case with RAII.
"No one wraps every line in a try-catch." -- no one needs to.
> Quite often all the caller needs is to cancel whatever it is doing and propagate the error.
Yes, this is the most common case. That's why Rust has `?` to support it as well as exceptions do. My take is that:
- The caller should make this decision explicitly. This avoids propagating (or catching) the error accidentally and makes the code easier to understand and review (see the example with `f(g(x))` vs `f(g(x)?)?`).
- Exceptions make the other error handling patterns too unergonomic, and they're not uncommon enough to justify this cost. My post does a bad job at providing more examples to demonstate that they aren't uncommon. But e.g. `.map_err(Wrapper)?` is way more convenient than catching and throwing a wrapper, which isn't so uncommon in Java.
> "Potentially leaving your data in an inconsistent, half-way modified state." -- this is just not the case with RAII.
RAII solves this only when you have locally-owned data or some locally-owned "scope guard" [1] that acts as a "defer", like std::sync::MutexGuard.
This doesn't help when you hold a non-owned &mut to some private data structure, you're in the middle of modifying it and currently it doesn't hold its own invariants because you haven't finished restoring those yet. Now I understand that the post lacks a concrete example to demonstate this. I'm sorry. If you're interested in this topic, you can learn more by googling about exception safery (aka panic safety) in Rust and C++. Both are languages with RAII that doesn't solve the issue completely.
Isn't it too low-level? Why not define instead cleanup/recovery through RAII and occasional try/catch for maintaining tricky invariants, and have no need to manually propagate errors?
>`.map_err(Wrapper)?` is way more convenient than catching and throwing a wrapper
If you only wrap exceptions on the API boundary it might be more ergonomic than having to manually propagate errors inside the API implementation and map them anyway.
>you're in the middle of modifying it and currently it doesn't hold its own invariants because you haven't finished restoring those yet
Sometimes it's better to call throwing functions and get needed data before modifying the private data, sometimes it's time to use try/catch. Is it happening often?
>Exceptions make the other error handling patterns too unergonomic
But so does packing valid data and errors together. Imagine you need to call f(h1()) and f(h2()) where h1() and h2() return the same data type for valid data but different types of errors. What do you do?
>the post lacks a concrete example
Yes, having a specific example seems to me to be the best way to discuss design tradeoffs, but you did the second best thing -- you started with declaring your assumptions (exceptions are disguised return values and optimizing ergonomics of passing errors through f(g(x)) is important) with both of which I happen to disagree)
> Sometimes it's better to call throwing functions and get needed data before modifying the private data
Haha! Here we go back to the topic of unchecked exceptions and "the lesser evil". With unchecked exceptions, every function is throwing! Even if it shouldn't throw today according to the docs:
1. The docs could be simply wrong.
2. It isn't a semver breaking change to start throwing in a future version. The compiler won't warn you when this happens. You won't manually audit all your dependencies when updating them [1].
We come back to the need to properly reflect errors in the type system. Without returning them by value, the only popular alternative is checked exceptions. But, as I mentioned in the updated version of the post [2], checked exceptions in Java are unfriendly to generic code. This is important and will come up right now:
> Imagine you need to call f(h1()) and f(h2()) where h1() and h2() return the same data type for valid data but different types of errors. What do you do?
This depends on the context. A general answer: `f` can be generic over the error type.
fn f<E>(result: Result<_, E>)
If it needs to do something more specific with E, like logging it, it can also have a more specific bound on E:
use std::fmt::Display;
fn f<E: Display>(result: Result<_, E>)
If it has to be a concrete function, it can use runtime polymorphism instead:
use std::fmt::Display;
fn f(result: Result<_, Box<dyn Display>>)
// in this case, the caller has to explicitly box the error
f(h1().map_err(Box::new))
---
[1] Unless you operate on a really serious level where you should strive to use a more reliable language anyway.
It was a poor choice of words. You can replace 'throwing functions' with 'throwing code'. There is plenty of code that is guaranteed not to throw -- starting from operations on basic types and ending with noexcept functions if we are talking about C++.
>The compiler won't warn you when this happens.
It will if you write an asserting in the code that relies on a function not throwing:
static_assert(noexcept(f()));
>`f` can be generic over the error type
Isn't it too much clutter? What if a function has three arguments? You'd need three generic parameters for their errors.
I have never seen, other than like, methods on Result itself, Rust code that accepts a Result type as an argument. You are absolutely right that would lead to a crazy amount of clutter.
> Imagine you need to call f(h1()) and f(h2()) where h1() and h2() return the same data type for valid data but different types of errors. What do you do?
It sort of depends on the specific different error types. Most of the code I've been writing is application level code, so I'd just be returning the errors upstream. The anyhow library makes it easy to return a "trait object" which is sort of like a virtual base class in C++, at least for these purposes. With that I could just do
f(h1()?);
f(h2()?);
The ? operator lets you return the error half of the Result to the parent. If I had to do the conversions myself, if I implemented some traits, I could do this too. That said, I would probably use a temporary for clarify:
let x = h1()?;
f(x);
let x = h2()?;
f(x);
For some cases, I may not want to implement the traits, but maybe there's some sort of common error that I'd want to return to the caller. In that case, I may
let x = h1().map_err(|e| /* do whatever conversion here */ )?;
f(x);
let x = h2().map_err(|e| /* do whatever conversion here */ )?;
f(x);
These three represent the vast majority of this kind of code I write and see in the wild.
When I looked at anyhow, I remember thinking that it's just a way to emulate in Rust the kind of error propagation that C++ has with exceptions, but manually with ?.
I think that it's reasonable to look at two features designed to solve similar problems, and think they look similar. And yeah, anyhow feels closer to an exception. There's some differences though; you don't have to use anyhow, so in some senses, Rust's system here has more flexibility overall. Another is that anyhow gives you a more "semantic" backtrace by default. Here's what I mean: Consider this program:
> There is plenty of code that is guaranteed not to throw -- starting from operations on basic types
This is fair, although this places some mental load on the programmer because they need to be careful when dealing with generics / operator overloading or when extracting helper functions.
> noexcept functions if we are talking about C++
This is a good C++-specific guarantee. But it's probably outweighted by the C++-specific lack of guarantees. "Operations on basic types" can now invoke UB, if we are talking about C++.
> Isn't it too much clutter?
In cases with one generic parameter, like the ones I demonstrated, I think it looks ok. Normal generic code. Rust generics are only going to require less ceremony in the future, because the team keeps making progress on implied bounds, lifetime elision, 2024 lifetime capture rules.
> What if a function has three arguments? You'd need three generic parameters for their errors.
This really depends on the context and what the code actually does, and needs a concrete example to discuss. I don't remember seeing a real world example with three Result parameters, nevermind having generic but distinct errors in them.
>careful when dealing with generics / operator overloading
In C++, you can't overload operators for built-in types and template parameters are normally constrained:
auto f(std::integral auto x) noexcept
{
return x+x;
}
But yes, writing C++ templates that work with wide range of types takes some thinking and operator overloading adds to the mental load (or reduces it, when used sparingly and properly).
>"Operations on basic types" can now invoke UB
They always could. I don't want this conversation to become about Rust vs. C++, I'd gladly use Rust when where is appropriate task.
>needs a concrete example to discuss
Write a new post when you come up with a bunch of good examples)
Will people keep writing these annoying posts with no insight just to complain about Exceptions (and Java in general)?
I've done a lot of Java. And quite a bit of Rust too. The least thing (or close to it) that annoys me with Java is Java's error handling. It only really makes me upset when a lambda needs to call some method that has a checked Exception in its signature, usually because it does IO... yeah that's annoying... but I still get the job done without losing more than 30 seconds. Otherwise, I just make a decision on whether to handle errors locally or propagate them, and that's all there's to it.
Rust is nice and all. But the kind of annoyance I get with Result values are probably worse than is the case in Java with Exceptions. For example, as mentioned in the article, when you end up having multiple incompatible errors and you have to manually hack something, usually losing context, to make the compiler happy. Almost every project just adds yet another dependency on one of the error handling libraries (because this problem and similar ones, like losing the stack trace entirely, are all too common so there's lots of libraries to fix that) and you probably will end up with all of them in your project once you've written anything non-trivial.
The problem with API changes in Java are real, but the same exact issue exists in Rust: you inevitably have to end up adding new error scenarios as you add features to a library. Instead of a new Exception, you just end up with a new Error variant in Result.
Anyway, even though in the beginning I also got all euphoric with Rust, thinking it fixed every issue with every language, today I think it just shifted the sorts of problems you have to deal with. It definitely went in the right direction (specially with handling of closable resources and concurrency) and it's a better language in almost every way than Java and most languages. But sorry, with regards to error handling, it's a minor improvement at best.
> it's a better language in almost every way than Java
> with regards to error handling, it's a minor improvement
Then let's keep pouring out these articles until we stop using a language that's worse in almost every way than Rust (and error handling that's a little bit worse).
It shouldn't be an objective to stop using any language. There's lots of people who like Java (or whatever language you don't like) and they're happy with that. It does the job, it gets them what they want. Rust perhaps does not and never will. It's not our job to tell people they need to stop doing whatever they're doing unless what they're doing is causing trouble for everyone else, which you'll have a hard time arguing is the case here (a better case, I think, could be to convince people to stop using C and C++ because of the real dangers of lacking memory safety?! But I am sure lots of C user would respond the same way I am doing here about Java :D).
> Will people keep writing these annoying posts with no insight just to complain about Exceptions (and Java in general)?
I'm sorry if the post comes off like that. This wasn't intended. Originally, I was writing a post explaining why I like using Rust as a high-level language for non-demanding tasks. I'm really excited about its error handling, so the section about error hanling got so large that I had to extract it into a separate post. And then also had to artificially limit its scope to exceptions and first-party solutions. And then hyperlinked everything to prevent bloat from inlined code examples for every single point. The post wasn't even intended to be about exceptions :)
I brought in Java specifically to cover checked exceptions and avoid responses like "all of this in solved with checked exceptions, Result is the same thing". Now, I think that the part where I define a Result in Java was a bit unfair and too-specific, because other languages with exceptions sometimes provide a standard Result type.
> the kind of annoyance I get with Result values are probably worse than is the case in Java with Exceptions
In the end, this is a subjective judgement call that everyone makes based on their own experience. My experience doesn't match yours here. But I tried my best to be objective and list these disadvantages so that readers can make their own conclusions based on the information.
> The problem with API changes in Java are real, but the same exact issue exists in Rust: you inevitably have to end up adding new error scenarios as you add features to a library. Instead of a new Exception, you just end up with a new Error variant in Result.
It exists, but Rust allows you to solve it my marking this error #[non-exhaustive] early in the evolution of the library. I don't know an equivalent in Java that simultaneously:
1. preserves concrete types
2. allows to evolve the API
3. forces the caller to handle unknown future variants.
> Anyway, even though in the beginning I also got all euphoric with Rust, thinking it fixed every issue with every language, today I think it just shifted the sorts of problems you have to deal with.
Sure. I did my best to describe this shift. And then made a subjective judgement about it. I respect yours too.
Thanks for the reply. I know my comment was very blunt, and didn't mean to cause offence - hope you didn't take any.
The reason I responded like that was that I really disagree with many Rust users who keep saying Rust error handling is so much better in every way and that Exceptions are stupid and stuff like that... and I was responding to those kind of people, not really you as your post was well balanced.
> In the end, this is a subjective judgement call that everyone makes based on their own experience. My experience doesn't match yours here.
Absolutely. Subjective topics can still be discussed, but yeah we're very unlikely to all agree, ever.
> I don't know an equivalent in Java that simultaneously...
Well, the way you do it in Java is using OOP: basically, have a single Exception type at the root of the Exception type hierarchy and make your library only throw sub-types of that. Then you may handle specializations of that, but you need to also handle the root type in case an unknown variant comes along. That leaves your last point unaddressed, but you can either make the root type a sealed interface, or have an enum describing all cases, so that the caller can opt-in to have their code break if they fail to handle one of the cases in the future.
> have a single Exception type at the root of the Exception type hierarchy and make your library only throw sub-types of that. Then you may handle specializations of that, but you need to also handle the root type in case an unknown variant comes along.
Hm, I haven't considered this solution. It works exactly like a #[non-exhaustive] enum. It's a wrapper type that provides type info about specific known variants (subclasses) but also still forces the caller to `catch MyBaseException` (match `_ => {..}`). I may need to update the post. Now it seems that #[non-exhaustive] error handling is equivalent in both cases and the main issue with Java lies in generic code [1]
It's also not clear to me how `if let , while let , let-else` help (and how you'd use them), or what an "ergonomic sum type" is and how it helps with exceptions.
Would be really cool if there was a code snippet in Rust comparing/explaining the advantage over Java for each benefit.