Hacker News new | ask | show | jobs
by ddek 2020 days ago
As you say, the API feels similar. However, result types have many benefits over exceptions, with a few drawbacks.

With a result type, in most languages, you can't just ignore failure. You must handle it, if you want to access the value. This prevents the common Java bug where a checked exception is caught but not handled. In rust, you must handle the error, propagate the error, or panic and fail fatally.

Results don't abuse a runtime either. The exception process is slow - stack traces must be generated and the runtime itself needs to manually find the catch block responsible. Returning a result doesn't find this problem.

Checked exceptions are fine for exceptional cases. At risk of sounding facetious, that's why they're called exceptions.

But there's an important distinction that we need to make: logic failures are not exceptional.

If you expect an operation may produce an error, you should handle it or crash the program. You should do this without incurring a performance hit, and using reliable and transparent primitives. Result types achieve both of these criteria, checked exceptions neither.

The drawback to results is mainly the loss of data. Generating a stack trace is expensive, but often that's a worthwhile cost. Exceptions could be more versatile, in that any part of your code can throw any type of exception, but a result can only contain a specific type of error. That's not always a pro, though.

So in general, checked exceptions are pretty awful for expressing normal failure cases, such as not finding a record in a database. They aren't great for anticipated errors, such as not finding a file, because of the chance a programmer might allow this to silently fail. Runtime exceptions are highly valuable, however.

[1] - A decent SO post on the topic: https://stackoverflow.com/questions/613954/the-case-against-...

1 comments

> This prevents the common Java bug where a checked exception is caught but not handled.

How does the Rust way avoid this? You can just as easily match on an error type with a catch-all case and ignore the error.

But you still acknowledge the presence of and explicitly supply a response to the failure case.
Is that not identical to an empty catch block? I fail to see how this is materially different
I believe the difference is that you resolve the error at the same point where you resolve a successful result, making it impossible for a reader of the function to, for example, assume that you can access a value after an exception was thrown.

  MyType x = null;

  try {
    x = doRiskyThing();
    x.doThing();
  } catch(CheckedException e) { ... }

  x.doThing();
This code makes it unclear where the exception is sourced from, and makes it seem like you can use `x` even in a failure scenario. In Rust, however:

  match doRiskyThing() {
    Ok(x) => x.doThing(),
    Err(e) => ...
  }
Here, it's clear where `x` is available and valid. You could even have the error path panic and merely return `x` if you wanted to use it later on in the method (borrow rules permitted, etc.)

A model that fits this more closely in Java is 'try-with-resources'.

I don't know that I agree that you're doing an apples to apples comparison though. There's nothing to stop you from doing:

  let x = doRiskyThing();
  match x {
    Ok(x) => x.doThing(),
    Err(e) => ...
  }
  x.get().doThing();
That would be more comparable to the try catch from above, and a comparable try catch that actually is semantically approximate would be:

  try {
    MyType x = doRiskyThing();
    x.doThing();
  } catch(CheckedException e) { ... }
Putting these two together, it makes the Result type seem worse than a try catch, right? But that isn't fair to result, because I'm using it like a jackass.

In other words, you can be a jackass using either of them, but you can also use them correctly, and when you use them correctly I don't see how they're any different. I also don't feel like the likelihood of using a Result correctly (edit: originally this said _incorrectly_) is substantially higher than a try/catch, but it probably boils down to developer experience and comfort with a particular varietal and not the capabilities inherent in either. Probably. And if not, I'm looking forward to being shown why! :)

I think I see what you're getting at, but let me point out why I still think it does something more than what Java exceptions allow:

  let x = doRiskyThing();
  match x {               // Pattern match on x
    Ok(y) => y.doThing(), // Giving the result a different name clarifies what's happening
    Err(e) => ...
  }
  x.unwrap().doThing();   // You can assume it's Ok, but you explicitly risk the application panicking
Of note, Result::unwrap is approximately the following:

  match x {
    Ok(y) => y
    Err(_) => panic!()
  }
x is still a valid value for the duration, and is never uninitialized; and whether you handle each case in a match or unwrap, both success and failure are considered and responded to before moving on. The Java checked exception code does that as well, except that it is unclear whether `doRiskyThing()` is throwing the exception, or `x.doThing()`; it's hard to reason about where the error originates from, and how much work was partially completed. In Java, the best way to circumvent this is - if possible - to keep try-clauses as tight as possible.
Your example smells like a failure of the type system. If that were TypeScript, for the simplest example, you'd explicitly have to type `x` as `MyType | null`, and were that the case, the `x.doThing();` outside the try block would not type-check.
It can be encoded in the type system as well. That's really what Java's checked exceptions are: one of the only places in Java where you can specify an alternative 'return' type. But it isn't quite complete enough to use in Java; little quirks here and there make it difficult to use widely, especially since Java 8 and lambdas arrived.

You can still use Java's type system to get something approximating Rust's approach. One upshot of Result-based error handling is that you can often replicate it in any language by shaving off one or two of the biggest benefits to suit that language's constraints.