Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

Checked exceptions (like Java) are isomorphic with Rust's Result type. You could convert a checked exceptions syntax into Result exception-carriers on each return with a mechanical transformation, of a Rust+exceptions program into Rust-today program.


Suppose you have

    fn foo(i: i32) -> string throws IOException
    let x: Vec<i32> = ...
    let y = x.iter().map(foo).collect()
What's the type of y ? There are no good answers here: if it's Vec<string> then where did the errors go? If it's Vec<Result<IOExecption, string>> then the user is pretty confused about where the Result came from. You can't propagate the IOException through the map() call in the general case (it might be in a third-party compiled library etc.). In Java this is a compilation error and the reason checked exceptions are useless in practice.


This should be a type error, because there's no further information available to resolve which implementation of FromIterator<> should be used. (Also you'd need to use filterMap to ever arrive at Vec<string> since you can't simply discard the errors.)

Using magic language syntax like checked exceptions in lieu of type system expressiveness is not great, which is why Java's checked exceptions are a bit on the unpleasant side. You can't have one map() that handles functions with and without exceptions, because exceptions aren't part of the type system.

In Rust, exceptions are part of the type system, so you only need one map(). If you also add syntactic sugar for the types such as:

  1: fn foo(i: i32) -> string throws IOException
  2: throw SomeIOException
  3: try a catch IOException b
To mean, respectively and approximately:

  1: fn foo(i: i32) -> Result<string, IOException>
  2: return Err(SomeIOException)
  3: match a {
       None => b
       Some(_) => {}
     }
... you can keep the simpler program code of exceptions and keep the ability to express exceptions first class in the type system.

The Rust implementation (syntax I use might vary from reality) of async/await is just this sort of syntactic sugar over a type: "async fn foo() -> X" is sugar for "fn foo() -> Future<X>", and "await!foo()" is (kind of) sugar for "foo().then(rest of the function)".

The only really kind of disappointing thing about this is that exceptions and futures don't get unified: we get ? for exceptions and await! for futures. Both of them ultimately are continuations: exceptions bypass the continuation and return an error immediately, futures call the continuation only when their value becomes available. The value of using a unified interface to continuations (or monads, if you prefer) is that you can use ones that don't have a magic blessed syntax: parsers with automatic backtracking written as simple imperative blocks, non-determinism via iterators where the continuation is a flatten operation, etc.


> This should be a type error, because there's no further information available to resolve which implementation of FromIterator<> should be used. (Also you'd need to use filterMap to ever arrive at Vec<string> since you can't simply discard the errors.)

I don't want to have to worry about whether the function "throws" or not at the point where I'm calling map - that's the whole problem of doing this in Java. What if the function that calls map is itself a generic higher-order function? Result's great benefit over exceptions is that it isn't a special case; generic functions work with Result just as they work with string.

> In Rust, exceptions are part of the type system, so you only need one map().

So what's the answer to the question? If there's only one map() I should be able to use it to map with foo; when I do, what do I get back?

> The Rust implementation (syntax I use might vary from reality) of async/await is just this sort of syntactic sugar over a type: "async fn foo() -> X" is sugar for "fn foo() -> Future<X>", and "await!foo()" is (kind of) sugar for "foo().then(rest of the function)".

Sure, and try!foo() does a pretty similar thing for Result. I think that's a better approach than exceptions, because you can see what's going on at the call site - having functions that "throw" and don't "throw" look exactly the same at the point of use is too magic/confusing IME.

> The value of using a unified interface to continuations (or monads, if you prefer) is that you can use ones that don't have a magic blessed syntax: parsers with automatic backtracking written as simple imperative blocks, non-determinism via iterators where the continuation is a flatten operation, etc.

Yeah, I find continuations too confusing to reason about but I really wish Rust would adopt some general-purpose syntax for monads ("do notation"). That would require HKT though, because you can't even form the type of a monad in the general case without that.


In Java, you'd expect map() to throw, and the type of y to be Vec<string>, in Rust parlance. If one of the conversions failed, you'd expect the stack to unwind as the exception propagates, and you'd expect that the use of map() means you're not particularly interested in which item failed, you just want to abort (roll back) on failure. This is the 99.9% use case for exception in Java, or C#, or Delphi; you almost never catch exceptions.

If you're interested in which item failed, ideally you'd be using a method that doesn't throw at all, and instead returns an error condition. I like how C# makes this clear, with Parse and TryParse on Int32. You use Parse when you want unwinding and abort behaviour including stack unwinding, and TryParse when you want to handle errors, and you don't use exceptions for expected errors, since expected errors are not exceptional.

(If the map is lazy, you'd expect the materialization in collect() to throw. But the return type wouldn't change.)

IMO, the kind of code you write on a daily basis is the primary determinant of your preference for error codes vs exceptions. If you need to handle error conditions frequently, you'll prefer error codes, because they're data, like all other data, and you can use the general compositional tools at your disposal to manipulate them. If you handle error conditions exceedingly rarely, and mostly just want to abort, unwind, roll back, go back to the main loop and log them, then exceptions are your friend.

I do not think there is a fundamental superiority either way. I think there are tools for purposes. I do think that Java's checked exceptions are half-baked; they're half-way into the compile time type system in a language whose applications are generally better suited to run time exceptions.


> In Java, you'd expect map() to throw

You'd have to use unchecked exceptions to achieve that though. There's no way to propagate the checked exception across that call, because there's no way to make map() generic over exception-or-not in a Java/Rust-like type system.

> If you're interested in which item failed, ideally you'd be using a method that doesn't throw at all, and instead returns an error condition. I like how C# makes this clear, with Parse and TryParse on Int32. You use Parse when you want unwinding and abort behaviour including stack unwinding, and TryParse when you want to handle errors, and you don't use exceptions for expected errors, since expected errors are not exceptional.

Result gets you that without needing to write two implementations of every method. You call unwrap-or-panic in the cases where error is not expected/handled, and handle it in the cases where you want to handle it.


And nobody, nobody, likes checked exceptions. Perhaps with appropriate syntactic sugar they could be made ergonomic, but Java's checked exceptions at least were a failed experiment.


Java's checked exceptions are a failed experiment because of subclassing (i.e. eventually you get `throws Throwable` which doesn't give you any useful information). More precise exception tracking is essentially isomorphic to current Rust (Result etc.).


(Just adding a bit to that:)

It's not just subclassing.

It also breaks with higher-order functions, e.g. what is the throws clause of a map() method on lists? There's really no good answer in Java, so what people do in practice is to just re-wrap into a RuntimeException, but that leaves callers with a problem: Calling code cannot match on exception types using a catch clause on the checked exception -- it has to check for a RuntimeException with an embedded checked exception. This is disastrous for ergonomics and real-life use of checked exceptions.

The Result/Either method leads to its own ergonomic problems, but I think they could be solvable with support for row-types and (anonymous) union types. At least in Haskell, the Either method leads to a proliferation of FooError types whose only purpose is to wrap other error types. Well, either that or you accept that every little thing can return in a generic 'AppError'.


The "right" thing in Java would be to parameterize map() by the error type declared on the lambda passed in. In other words:

    interface Function<T,U,Err> {
       U apply(T value) throws Err;
    }

    interface List<T> {
        <U,Err> List<U> map(Function<T,U,Err> f) throws Err;
    }


That only works for exactly one exception type. Err cannot be no errors, nor can it be two or more unrelated exceptions. You end up having to have n different overloads (for 0, 1, 2, ... exceptions) for every functions, and since Java's type system is not up to "varidic generics" you have to write them all manually.


While you're correct about the two or more unrelated exceptions, Java will happily infer Err to RuntimeException and so the no errors case works just fine there. It's only work in a case where the type is only needed by inference, though.


Oh indeed, you need |, and the ability to compose the type parameters, etc. It's why I said "right" rather than right.


You could solve that with sum types (for 2+ exceptions) and never types (for 0),


It's not actually a sum, it's an inclusive union which are much harder to reason about and compromise your type system more (IMO). In any case, Java does not have any of those, and this is what makes checked exceptions impractical to work with; Rust has sum types but I don't think it has either inclusive unions or never types.


> It's not actually a sum, it's an inclusive union

Right, whoops. My bad!

> In any case, Java does not have any of those, and this is what makes checked exceptions impractical to work with

Absolutely. I was approaching it from the standpoint of "what would I change about Java's type system to make checked exceptions usable?".

> Rust has sum types but I don't think it has either inclusive unions or never types.

Rust is working on stabilizing a true never type[0], and until then you can emulate it with an empty enum.[1] There is also a RFC for unions, but that doesn't seem to have gone anywere.[2]

[0]: https://github.com/rust-lang/rust/issues/57012

[1]: Such as https://crates.io/crates/void which also provides helpers for safely coercing `Void` `Result`s

[2]: https://github.com/rust-lang/rfcs/pull/1154


My comment was actually made with many of these issues in mind -- I was trying to avoid needless nitpicking by being a bit vague. Perhaps that was a mistake.

The point wasn't that these things couldn't be solved -- they could, at least in theory[0] -- but that the current practical limitations of Java[1] are such that the interaction of higher-order functions and checked exceptions make checked exceptions a really bad idea.

[0] At least I think so... given anonymous or adhoc sum types, etc.

[1] Interestingly, this is not a JVM-wide issue. It's just the Java compiler that imposes these restrictions, for example: You cannot "catch" a checked exception which hasn't been declared at least one method in the "try" scope. This makes sense at first glance ("cannot happen"), but means that re-wrapping by higher-order functions must entail extremely error-prone inspection of any exception by calling code, etc. etc. It's a huge mess.


A bit late... but as others have pointed out:

This only works to the first order. If you have functions-that-call-functions-that-call-functions, you end up not being able to write(!) the correct throws clauses in Java.


Not all isomorphic representations are equally ergonomic. Exceptions require special additional syntax, Result is “just” another value.


Ensuring error values get all the way to the top-level error handling loop is generally more ergonomic with exceptions, not less. It's easier for values to fall by the wayside, and for errors not to propagate when they're paired (type-wise) with values.

If you do a lot of error handling close to the point of the error condition being found, the Rust / checked exceptions approach works well. For the kinds of applications that are written in Java, it's not the case. Most error conditions need to be propagated and the action in flight aborted.


> Most error conditions need to be propagated and the action in flight aborted.

That's exactly what the '?' operator does. It's just as ergonomic as Java exceptions, and it doesn't hide the places where errors can be returned and the execution flow can be diverted.


What benefits are there to checked exceptions over Rust's `Result` type?


Automatic propagation without relying on macros like try!, or match with return on Err case.

As you make it easier to propagate the error case to the caller, you asymptotically reach checked exceptions.


I guess, although I'm not convinced that's an advantage. The ? syntax is pretty convenient, and it still allows you to do manual matching if you have a good reason to.

I think the main thing you'd gain with checked exceptions is being able to list the possible failures in the function signature, instead of having to create an enum type. But I guess I'm not sure it's really worth the tradeoff.


Strack traces? I love Rust error handling, but the one thing I sometimes miss is that there is no stack traces by default on errors.


That's orthogonal to exceptions though. Errors could easily have stack traces automatically added to them (and IMO, they should).




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: