Results are so much more convenient it's not even funny, but even without that you could probably build a language with checked exceptions where they're not infuriatingly bad (Swift has something along those lines, though IIRC it doesn't statically check all the error types potentially bubbling up so you know that you have to catch something, not necessarily what).
That's Java. And I agree it is a wildly painful and incomplete implementation. I wish we'd stop conflating it with checked exceptions as a language feature.
Furthermore, results make it much less likely to "overscope" error handlers (there a try block catches unrelated exceptions from 3 different calls) as the overhead is relatively low and there's necessarily a 1:1 correspondance between calls and results; and it's also less likely to "miscatch" exceptions (e.g. have too broad or too narrow catch clauses) because you should know exactly what the call can fail with at runtime. It's still possible to make mistakes, don't get me wrong, but I think it's easier to get things right.
"Path unification" is a big one in my experience: by design exceptions completely split the path of "success" and "failure" (the biggest split being when you do nothing at all where they immediately return from the enclosing function).
This is by far the most common thing you want so in a way it makes sense as a default, but it's problematic when you don't want the default because then things get way worse e.g. if you have two functions which return a value and can fail and you need to call them both, now you need some sort of sentinel garbage for the result you don't get, and you need a bunch of shenanigans to get all the crap you need out
int a;
SomeException e_a = null;
try {
a = something();
} except (SomeException e) {
a = -1;
e_a = e;
}
int b;
SomeException e_b = null;
try {
b = something();
} except (SomeException e) {
b = -1;
e_b = e;
}
if (e_a != null or e_b != null) { // don't mess that up because both a and b are "valid" here
…
}
or you duplicate the path in both the rest of the body and the except clause (possibly creating a function to hold that), etc…By comparison, results are a reification so splitting the path is an explicit operation, but at the same time they still don't allow accessing the success in case of failure, or the failure in case of success.
let result_a = something();
let result_b = something();
if let Err(_) = result_a.and(result_b) { // or pattern matching or something else
…
}
Having a reified object also allows building abstractions on top of it much more easily e.g. if you call a library and you want to convert its exceptions into yours you need to remember to try {
externalCall()
} except (LibraryException e} {
throw MyException.from(e); // because that might want to dispatch between various sub-types
}
and if you don't remember to put this everywhere the inner exception will leak out (that's assuming you don't have checked exceptions because Java's are terrible and nobody else has them).Meanwhile with results the Result from `externalCall` is not compatible with yours so this:
return externalCall();
will fail to compile with a type mismatch, and then you can add convenience utilities to make it easy to convert between the errors of the external library and your own, and further make it easy to opt into an exception-style pattern. e.g. Rust's `?` externalCall()?
is roughly: match externalCall() {
Ok(value) => value,
Err(error) => { return Err(From::from(error))); }
}
(there's actually more that's involved into it these days an a second intermediate trait but you get the point, in case of success it just returns the success value and in case of failure it converts the failure value into whatever the enclosing function expects then directly returns from said enclosing function).