Hacker News new | ask | show | jobs
by tyleo 237 days ago
I always think more languages should support Result… but only to handle expected error states. For example, you may expect that some functions may time out or that a user may attempt an action with an invalid configuration (e.g., malformed JSON).

Exceptions should be reserved for developer errors like edge cases that haven’t been considered or invalid bounds which mistakenly went unchecked.

3 comments

I find it kind of funny that this is almost exactly how Java's much-maligned "checked exceptions" work. Everything old is new again.

In Java, when you declare a function that returns type T but might also throw exceptions of type A or B, the language treats it as though the function returned a Result<T, A|B>. And it forces the caller to either handle all possible cases, or declare that you're rethrowing the exception, in which case the behavior is the same as Rust's ? operator. (Except better, because you get stack traces for free.)

Java's distinction between Runtime and Checked Exception makes sense, and is pretty much the same panic vs Result distinction Rust makes. But Java's execution of the concept is terrible.

1. Checked exception don't integrate well with the type-system (especially generics) and functional programming. It's also incompatible with creating convenient helper functions, like Rust offers on Result.

2. Converting checked exceptions into runtime exception is extremely verbose, because Java made the assumption that the type of error distinguishes between these cases. While in reality errors usually start as expected in low-level functions, but become unexpected at a higher level. In Rust that's a simple `unwrap`/`expect`. Similarly converting a low level error type to a higher level error type is a simple `map_err`.

3. Propagation of checked exception is implicit, unlike `?` in Rust

Though Rust's implementation does have its weaknesses as well. I'd love the ability to use `Result<T, A | B>` instead of needing to define a new enum type.

I wish I could upvote this more. I can totally understand GP's sentiment, but we need to dispel the myth that results are just checked exceptions with better PR.

I think the first issue is the most important one, and this is not just an implementation issue. Java eschewed generics on its first few versions. This is understandable, because generics were quite a new concept back then, with the only mainstream languages implementing them then being Ada and C++ - and the C++ implementation was brand new (in 1991), and quite problematic - it wouldn't work for Java. That being said, this was a mistake in hindsight, and it contributed to a lot of pain down the road. In this case, Java wanted to have exception safety, but the only way they could implement this was as another language feature that cannot interact with anything else.

Without the composability provided by the type system, dealing with checked exceptions was always a pain, so most Java programmers just ended up wrapping them with runtime exceptions. Using checked exceptions "correctly" meant extremely verbose error handling with a crushing signal-to-noise ratio. Rust just does this more ergonomically (especially with crates like anyhow and thiserror).

You know, I’ve also found this funny.

I like the declaration side. I think part of where it misses the mark is the syntax on the caller side.

I feel like standard conditionals are enough to handle user errors while the heavy machinery of try-catch feels appropriately reserved for unexpected errors.

Probably, the problem with Java's `try-catch` is it's not composable and has an abundance of unchecked exceptions (could mess up `catch` type). In Rust, you could just `?` to short-circuit return or do another chained method call `result.map(...).and_then(...).unwrap_or(...)`.

And more importantly, I don't think there's any JEP trying to improve checked exception handling.

While Java gets the blame, the concept was already present in CLU, Modula-3 and C++ before Java was even an idea.

I also find a certain irony that forced checked results are exactly the same idea from CS type theory point of view, even if the implementation path is a different one.

Java's checked exceptions experiment was very painful in various ways that directly exposing an error state as part of the return value is not so I wouldn't quite characterize this as "Everything old is new again."

The first big thing is that Java, especially in the days of when checked exceptions were a really big thing and less so in modern Java, was really into a certain kind of inheritance and interface design that didn't play well with error states and focused on the happy path. It is very difficult to make Java-esque interfaces that play well with checked exceptions because they like to abstract across network calls, in-memory structures, filesystem operations, and other side effectful tasks that have very different exception structures. An interface might have a single `writeData` method that might be backed by alternatively a write into an in-memory dictionary, a filesystem key-value store, a stateless REST API, or a bidirectional WebSocket channel which all have wildly different exceptions that can occur.

The second thing is that because checked exceptions were not actual return values but rather had their own special channel, they often did not play well with other Java API decisions such as e.g. streams or anything with `Runnable` that involved essentially the equivalent of a higher-order function (a function that takes as an argument another function). If e.g. you had something you wanted to call in a `Stream.map` that threw a checked exception, you couldn't use it, even if you notated in the enclosing method that you were throwing a checked exception because there was no way of telling `Stream.map` "if the function being `map`ed throws an exception rethrow it" which arose because checked exceptions weren't actual return values and therefore couldn't be manipulated the same way. You could get around it, but would have to resort to some shenanigans that would need to be repeated every time this issue came up for another API.

On the other hand if this wasn't a checked exception but was directly a part o the return value of a function, it would be trivial to handle this through the usual generics that Java has. And that is what something like `Result` accomplishes.

IMHO the mapping issue comes from functions not being first class, so all types require Functor-like interfaces which are needlessly verbose. Splitting these is not semantically different than a function that returns a value vs a function that returns a Result.

I have little love for Java, but explicitly typed checked exceptions are something I miss frequently in other languages.

No I think it's a deeper issue than that. In particular because exceptions aren't a return value, you can't make a function generic over both values and exceptions at the same time. This would persist even with first class functions.

If you want to be generic over exceptions, you have to throw an exception. It would be nice to have e.g. a single `map` method that appropriately throws an exception when the function that is called throws a function and one that doesn't throw an exception when the function that is called throws a function. But as it stands, if you want to be able to throw a checked exception at all, you have to mark that your higher order function throws checked exceptions, even if you would prefer to be more generic so that e.g. you only throw a checked exception if your function that is called throws a checked exception.

Thr only reason this works in other languages is because they’ve made the choice to return objects that represent success+value or error and then added explicit syntax to support those types. That means function signatures put the error in the return type instead of a separate exception channel, but that’s really only a syntactic difference. It’s otherwise isomorphic.
Only it is not considered by the type checker. Result brings errors into the realm of properly typed code that you can reason about. Checked exceptions are a bad idea that did not work out (makes writing functional code tedious, messes with control flow, exceptions are not in the type system).
The only difference between a `fun doThing: Result<X, SomeError>` and a `fun doThing: X throws SomeError` is that with the checked exception, unpacking of the result is mandatory.

You're still free to wrap the X or SomeError into a tuple after you get one or other other. There is no loss of type specificity. It is no harder to "write functional code" - anything that would go in the left() gets chained off the function call result, and anything that would go in the right() goes into the appropriate catch block.

I also don’t understand the argument that Result is anything other than a syntactic difference between these ideas.

    final Foo x;
    try {
        x = foo().bar().baz().car();
    } catch (Exception e) {
        x = null;
    }
    return Optional.of(x);

vs.

    let x = foo()?.bar()?.baz()?.car()?;
    Some(x)
Both eat the error. Both wrap the value. The rust is more terse, but the meaning is the same.
You've discarded the error type, which trivialised the example. Rust's error propagation keeps the error value (or converts it to the target type).

The difference is that Result is a value, which can be stored and operated on like any other value. Exceptions aren't, and need to be propagated separately. This is more apparent in generic code, which can work with Result without knowing it's a Result. For example, if you have a helper that calls a callback in parallel on every element of an array, the callback can return Result, and the parallel executor doesn't need to care (and returns you an array of results, which you can inspect however you want). OTOH with exceptions, the executor would need to catch the exception and store it somehow in the returned array.

Try:

    Either<Foo, SomeException> x;
    try {
        x = Either.left(foo().bar().baz().car());
    } catch (SomeException e) {
        x = Either.right(e);
    }
I have rewritten the parent code to preserve the exception without semantic difference or loss of type safety.

If there are multiple types of exception that can be thrown, the right can be a union type.

I usually divide things in "errors" (which are really "invariant violations") and "exceptions". "exceptions", as the name implies, are exceptional, few and when they happen, they're usually my fault, "errors" on the other hand, depending on the user, happens a lot and usually surfaced to the user.
why not divide things into errors and bugs (programming errors)?
That's subtly different. It's secondary whose fault is this, what primarily matters is whether you should continue with the rest of the process.

There is always a cleanup layer, the trick is to choose well between 1 and 2:

  1. Some code in the same OS process is able to bring data back to order.

  2. OS can kill the process and thus kill any corruption that was in its address space.

  3. Hardware on/off button can kill the entire RAM content and thus kill any corruption that spilled over it.
Take for example an unexpected divide by zero, that's a classic invariant violation. The entire process should blow up right there because:

- it knows that the data in memory is currently corrupted,

- it has no code to gently handle the corruption,

- and it knows the worst scenario that can happen: some "graceful stop", etc., routine might decide to save the corrupted data to disk/database/third-party. Unrecoverable panic (uncatchable exception) is a very good generic idea, because persistently-corrupted-data bug is a hundred times worse than any died-with-ugly-message bug as far as users are concerned.

> Take for example an unexpected divide by zero, that's a classic invariant violation. The entire process should blow up right there because:

> - it knows that the data in memory is currently corrupted,

> - it has no code to gently handle the corruption,

are these conditions or implications?

- corruption isn't necessary: it could be the consequence of a bug

- corruption can be handled sometimes: fetch the data from the source again, apply data correction algorithms

- you know and control what the graceful stop does; maybe not save the corrupted data to disk? or maybe save it to disk and ask the user to send it to you

I took several assumptions to build an argument why some errors should result in process-level termination.

By "corruption" I mean much more than merely a hardware bit error. I mean any situation when data no longer has a meaning for a user.

By "unexpected" I mean that the program has not been prepared to deal with the situation: there is no code there to "fetch the data from the source again", etc.

> you know and control what the graceful stop does

No, in fact I'm exploring here situations when I don't know this with certainty.

Both bugs and exceptions can be reasonably thought of as programmer error, but they are not the same kind of programmer error. Exceptions are flaws in the code — conditions that could have been caught at compile time with a sufficiently advanced language/compiler. Whereas bugs are conditions that are programatically sound, but violate human expectations.
A little nuance: bugs are not just conditions that are programmatically sound. They can encompass exceptions.

If a bug triggers an exception then with a strong compiler that is sufficiently advanced then these bugs can be found by the compiler.

Bugs require execution so a compiler cannot find bugs.

Exceptions also require execution, but that does not suggest that they are encompassed by bugs. The lack of a third term tells that there is no overlap. If "bug" covered both exceptions and where human expectations are violated, there would necessarily be another term just for the case where human expectations are violated. But there is no such term...

...that I've ever heard. If it is that I've been living under a rock, do tell.

No you haven’t been living under a rock your definitions are just off and you didn’t read carefully what I wrote. Or you’re just overly pedantic.

I wrote bugs can cover exceptions. Think hard about what that sentence means in English. If I can do something it means I can both do something and not do something. So that means there are exceptions that are bugs and exceptions that are not bugs.

The reason why it’s important to illustrate this difference is because a large, large number of exceptions occur as a bug.

I'm currently working on something that requires a GPU with CUDA at runtime. If something went wrong while initializing the GPU, then that'd be an exceptuion/bug/"programming error" most likely. If the user somehow ended up sending data to the GPU that isn't compatible/couldn't be converted or whatever, then that'd be an user error, they could probably fix that themselves.

But then for example if there is no GPU at all on the system, it's neither a "programming error" nor something the user could really do something about, but it is exceptional, and requires us to stop and not continue.

> If something went wrong while initializing the GPU, then that'd be an exceptuion/bug/"programming error" most likely.

That depends if it is due to the programmer making a mistake in the code or an environmental condition (e.g. failing hardware). The former is exceptional if detected, a bug if not detected (i.e. the program errantly carries on as if nothing happened, much the dismay of the user), while the latter is a regular error.

> But then for example if there is no GPU at all on the system, it's neither a "programming error" nor something the user could really do something about, but it is exceptional

Not having a GPU isn't exceptional in any sense of the word. It is very much an expected condition. Normally the programmer will probe the system to detect if there is one and if there isn't, fall back to some other option (e.g. CPU processing or, at very least, gracefully exiting with feedback on how to resolve).

The programmer failing to do that is exceptional, though. Exceptions are "runtime compiler errors". A theoretical compiler could detect that you forgot to check for the presence of a GPU before your program is ever run.

The grey area is malfunctioning CPU/memory. That isn't programmer error, but we also don't have a good way to think about it as a regular error either. This is what "bug" was originally intended to refer to, but that usage moved on long ago and there is seemingly no replacement.

That’s interesting. I’d actually consider this a user error because it’s only in the user’s power to fix it.

For example:

1. You’d want to display a message that they need a GPU.

2. Call stack information isn’t helpful in diagnosing the issue.

Not all exceptional circumstances are bugs
> Exceptions should be reserved for developer errors like edge cases that haven’t been considered or invalid bounds which mistakenly went unchecked.

Isn't this what assertions are for? How would a user even know what exceptions they are supposed to catch?

IMO exceptions are for errors that the caller can handle in a meaningful way. Random programmer errors are not that.

In practice, exceptions are not very different from Result types, they are just a different style of programming. For example, C++ got std::expected because many people either can't or don't want to use exceptions; the use case, however, is pretty much the same.

I’ve often seen assertions throw exceptions when violated. Users don’t catch exceptions, developers do. Users interact with the software through things like failure pop ups. You’d need to check that there’s a failure to show one, hence returning a Result to represent the success/fail state.
With users I meant library users, not end users.

> You’d need to check that there’s a failure to show one

You can do this with either exceptions or Result types. At the end of the day, it is a matter of culture and personal preference.