Make std's Result a type alias with default type parameters

This is an idea that’s been in my head for a while, and I’d like to get some feedback on it. This is something that I started thinking about again given Niko’s recent post about the return value of main. I really only dabble in Rust, so there might be valid reasons to shoot this idea down immediately, but I thought I’d post it here for critique anyway.

Summary

Make libstd reexport Result with the error case type parameter defaulted to Box<Error> or Box<Error+Send>.

Motivation

In my opinion it’s a huge stumbling block what the error type in a Result is supposed to be. As a newcomer to Rust, I found this very frustrating, and that was at a time when the situation was far simpler than it is today. Imagine all the things one needs to learn to define a proper error type today, it’s quite complicated. Yet there is an error type which solves this problem, especially for small applications which might not need very sophisticated error handling: Box<Error>.

The problem with this error type is that we barely advertise to newcomers that it exists. There’s a section about it at the end of the book, but I think that’s a disservice, this should be the first way that Rust newcomers learn to handle errors, and they can learn about the more sophisticated types of error handling later.

One barrier in using Box<Error> is the actual type name. It requires one to understand trait objects, virtual dispatch, the limits thereof (e.g. can’t downcast to a trait), and so on.

Detailed design

Since Box<T> is defined in liballoc, we can’t simply change the definition of Result in libcore. Instead libstd will reexport a type alias like this instead:

pub type Result<T, E=Box<::std::error::Error+Send> = ::core::result::Result;

Currently this is not backwards compatibility, since you can’t do Result::Ok through a type alias, but there’s an open issue for this which has been determined to be a bug and that being able to construct a variant through a type alias should be possible. If this issue is fixed then this change should, in theory, be backward-compatible.

This would make it possible to write functions like this:

fn something() -> Result<Baz> {
  foo()?.bar()?.baz()
}

I think this is very attractive, and would make a hypothetical change to main's signature much more attractive:

fn main() -> Result<()> {
  foo()?.bar()?.baz();
  Ok(());
}

How We Teach This

The whole advantage to this is that we do not teach this. We can simply present the Result type without even mentioning what the default error type even is, and explain this in more detail in the error handling chapter. The attractive part about this is that we encourage new users into (reasonably) good practices by avoiding unwrapping, without having to go into all the details of error handling, defining error types, trait objects, the From trait and all of that.

Result plus ? plus unwrap/expect together form a pretty solid, and much simpler error handling story for beginners.

Drawbacks

I am unsure if this is really backwards compatible (the issue above aside), I’d like to hear opinions on this.

If a more comprehensive solution to error handling is adopted, such as checked exception via throws as suggested at some point, then this solution might become obsolete.

It is yet another thing to learn, though as explained above, I hope that this improved the learning experience, rather than adding additional burdens.

It adds a difference between libcore and libstd, but I think this is not a huge problem, since those working with libcore will usually be more advanced Rust developers anyway, for whom this won’t be a huge addition to cognitive load.

EDIT: Another drawback is that is somewhat blurs the line between Result and Option.

Alternatives

Wait for a more comprehensive solution, such as checked exceptions.

Do nothing.

Be even more radical and default the first type parameter to (), this makes the signature of main even nicer, but I find this confusing, since there’s no way to construct a valid return value for such a function without writing out an empty tuple anyway, so it seems more confusing than helpful.

Unresolved questions

Should there be a Send bound on the error?

3 Likes

I’m not a fan of promoting stuff to the heap without explicit opt-in, it’s not the Rust way. But I’d like to hear others opinions.

4 Likes

-1

I consider the fact that one has to explicitly define an error type a feature of Rust. It forces you to think about the failure path of an API just as much as you have to think about the success path. I'd go as far as to call one-click error types an anti-pattern (in Rust and elsewhere).

Also type-erasing the failure paths of your execution graph defeats the whole point of introducing type safety in the first place. Swift does this and it's a huge p.i.t.a.

No, please no.

This.

1 Like

I agree that typed errors are better than virtually dispatched errors, and I think that library authors should definitely use typed errors.

But this suggestion is about improving the teachability of Rust. Right now we have a situation where Rust beginners use unwrap for error handling, pretty much exclusively, because it is the only error handling tool available which is reasonably easy to learn and explain. This change is about improving Rust's learning curve, one of the stated goals in the roadmap.

One could see this as an improvement upon the status quo, which is that many newcomers will use panics for error handling. Imo between panics, and boxed errors plus virtual dispatch, the latter is preferable by far.

I am not advocating checked exceptions, I merely listed it as one of the alternatives. Also, just to clarify, what I'm talking about is something like @glaebhoerl's propsal, which I think is quite reasonable and quite different from checked exceptions in Java, and would have some overlap with this proposal.

There are some suggestions in this neighboring thread to use a type alias for the "Result with defaults" case. For example,

type Fallible<T=()> = Result<T, Box<Error>>;

The motivating case is that thread is the possibillity of making main return a Result, with the signature

fn main() -> Fallible
2 Likes

If this type is to be used for main it would have to be added to the prelude, and if it is added to the prelude it very likely will be used for other purposes than the return value of main. I think the problem with this is that this still fundamentally is a Result type. Default type parameters aside it acts the exact same. I think having two separate names for essentially the same thing is confusing.

I considered whether I should post this suggestion to the other thread, but it seemed sufficiently different to me to warrant its own thread.

What is "explicit opt-in"? A lot of types in Rust put things on the heap. Vec, Box, HashMap, BTreeMap, etc... Yet when we use these types we don't opt in to any heap allocation, we don't declare that the heap is being used, and without inside knowledge of these types it is impossible to tell whether it is being used or not. Even using channel implicitly allocates from what I understand.

In this case the allocation is still visible in the type signature, so it is actually more explicit than in many other types which don't communicate that they allocate at all. IMO the Rust way is to avoid allocations, not to make all of them explicit, otherwise we'd never have any higher level abstractions.

I'd argue that it's clear that Vec, Box, HashMap, BTreeMap allocate on the heap, as it's basically the only reasonable way to implement them. There is no alternative to the heap for them. At least not without changing their semantics.

As far as I understand it your whole point is to hide the Error part of Result's signature from the user. I'd argue that while it's technically "still visible in the type signature" (if you were to look up the alias) it's effectively hiding it from them in practice. And in the end you're gonna prime users to go the supposedly "easy" way of type erasure, which (imho) is inferior to the explicit proper way of doing it.

I'd also not want a heap allocation be the (hidden) default for something that's totally fine on the stack (and imho should not be dumbed down to begin with).

And then, as you already pointed out, there's this:

I'd doubt that teaching traits, dynamic dispatch, virtual function tables, etc. would be any simpler than just "declare an enum, put an error case in it and be done with it".

Just to be clear, I very much do appreciate your effort to simplify the situation for new Rustaceans. The learning curve of Rust is a hindrance for many and I'm all for leveling it out. But I have doubts that this particular solution is actually solving the problem and not making it worse in the long run. I'm not even sure if the problem at hand is actually contributing significantly to the learning curve. Yada, yada, premature optimization, yada, yada…

Instead I think we should aim to teach proper error handling in the book/etc, avoiding the use of unwrap() therein entirely.

To illustrate my point of “error handling should not give short-cuts”:

Swift’s equivalent to Rust’s Option<T> is called Optional<T>. You won’t however find it anywhere in real-world Swift code. All you’ll find is it’s short-hand alias: T?.

Just like in Rust you have to use

if let foo = bar {
    // use foo
}
guard let foo = bar else {
    return
}
// use foo
let foo = bar ?? 42 // nil-coalescing operator

or the !-operator, Swift’s equivalent of .unwrap():

let foo = bar!

to access an optional’s value.

I consider Swift’s choice of ! over something like .unwrap() a mistake. It makes ignoring the possibility of a failure way too easy and convenient. Having to write .unwrap() gives you a moment to think about “Is this right? Shouldn’t I add a check here?” and makes it stand out in your code.

You find ! all over the place in real-world (production) Swift code. Which together with the general lack of unit tests in the Swift community really, really bothers me. It’s become a culture thing.


And it gets even worse with Swift’s alternative to T?, which is T!.

T! implicitly inserts ! (or .unwrap(), if you will) for every use of a variable of type T!.

T! exists as otherwise bridging over to ObjC’s dynamic runtime features and lazy loading/setting of properties would be a huge pain. But T! makes it all to easy to misuse it whenever you’re just to lazy to properly think about your code architecture and types. Newbies love T! for its convenience (“yay, no more error handling! I’m so productive!”), but should fear it instead.

Edit: Proposal SE-0054 – “Abolish T!”


My point here being that there shouldn’t be short-cuts for basically ignoring errors. People tend to choose the path of least resistance. Thus there should only be “long”-cuts for ignoring error handling, imho. Ignoring error handling should be tedious and ugly. Such as .unwrap_because_im_lazy_and_couldnt_be_bothered_to_do_this_properly(). And one should be forced to type it out by hand. No auto-complete. Bad coder, no cookie.

4 Likes

but this is not ignoring the error. Quite the opposite, it encourages handling of errors when an example code shown in main is copy-pasted to other functions. The status quo is having examples in main abuse unwrap(), which promotes ignoring of errors when such code is copy-pasted.

In Swift terms is like main() is currently requiring use of !, and we want main to be func main() throws, so that it can properly support code with try.

When I started using rust, having both Result<T, E> and std::io::Result was a bit confusing, I feel this change could make the distinction even less clear.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.