Pre-prefatory comments: this is long, and unedited. I don’t have time to edit it down, but it’s rambly and a great deal less coherently argued that I wish it were, because I think this is really important, and I’m sorry that it’s not sharper. I basically just brain-dumped before work this morning!
Prefatory comments
The first thing I’d like to say is: Please do not assume that a bunch of folks with time to comment in this thread this particular week means you have broader consensus. I’m deeply interested in this question, but I’m also particularly busy with other things right now – both professionally and personally.[1] I’m blocking out time I normally allocate to other things to drop in on this discussion now, and I probably won’t have time to come back to it! If I hadn’t happened to peek in on internals.rust-lang.org and caught @manishearth and @withoutboats chatting a bit on Twitter, I’d have had no idea this was an ongoing discussion at all!
The second thing I’d like to say is closely related: some of us have not been able to follow along with all the previous discussion of these ideas. I skimmed the prior RFCs in the last couple days, once I became aware of them, but I do not have the bandwidth to read every RFC out there. And this thread simply assumes the motivation is clear to everyone. But as I’ll note below, the motivation isn’t especially clear to me! So if we could see our way to adding some of that context for other folks just now coming to the discussion, that would be enormously helpful for someone like me!
1. Boilerplate
As far as I can tell, the motivation primarily seems to be interest in a reduction of boilerplate, and in particular for handling of scenarios where it’s felt to be inconvenient.
I’d like to show by example why the proposals raised here don’t actually significantly reduce the boilerplate, though!
Function signatures
Let’s start with function definitions for these, with the status quo at the end:[2]
fn do_a_fallible_thing() -> T throw E {}
fn do_a_fallible_thing() -> T throws E {}
fn do_a_fallible_thing() -> T catch E {}
fn do_a_fallible_thing() -> T catches E {}
fn do_a_fallible_thing() -> Result<T, E> {}
You’re looking at a change that yields at most 3 characters, and it doesn’t come for free. (You could get it further down with fail
or error
, but the point stands in my view.)
Function bodies
Internally, you get the nice benefits (but also, see below on the costs for reasoning) of not needing to wrap things in Ok()
or Err()?
. But that seems entirely orthogonal to the idea of adding this special syntax that really doesn’t gain you a lot, but does add complexity and mental budget. When you write -> T throws E
, you’re still just returning a Result<T, E>
, but now you have to internalize that reality and learn that even though this looks different from -> MyEnumType
it isn’t. It’s complexity budget that doesn’t actually help you.
Again, let’s compare the proposal (setting aside bikeshedding) with what you’d write today:
fn can_fail(some_param: Option<String>) -> i32 throws String {
let almost_done = if do_a_fallible_thing(some_param) {
0
} else {
throw String::from("😱");
};
almost_done + 3
}
Today:
fn can_fail(some_param: Option<String>) -> Result<i32, String> {
let almost_done = if do_a_fallible_thing(some_param) {
0
} else {
return Err(String::from("😱"));
};
Ok(almost_done + 3)
}
Net characters: twelve fewer in the proposed syntax (2 from the function signature change) – a 5% reduction here, but it would be much smaller in most real-world functions, because there’s not much reason to write this kind of thing here as opposed to in a 30-line function instead of a <10-line function.
The main place I can see it being helpful to some degree is if you want to make an existing function fallible, in which case you change -> i32
to -> i32 throws String
and add throw String::from("😱")
in whatever spot. And that’s not trivial, but it still requires you to go update every place where can_fail
is called to use either ?
or throw
itself.
In sum: In my view, this does not meaningfully reduce the boilerplate at the function level.
2. Teachability/Learnability
So what about the other side of this discussion: how does this work in the process of learning the language?
I believe it is a significant step backward for learnability of the language. (I recognize this opinion is not popular or widely shared in this thread, though a few people have given voice to it.) This is because it substantially increases the complexity of the language in one of its most central areas, and it does so both by special-casing Result
(and in principle other Try
types) and by adding syntax.
One of the things I’ve found most helpful in teaching Rust to people is that relative to its language complexity, it has a relatively small syntax, and its
more complex functionality is nearly always built on simpler functionality. (The emphasized qualifier is important because Rust is by no means a small language! It’s not even a Python, much less a Lisp.) This means that it’s relatively straightforward to develop a clear mental model of the various pieces, and precisely because the more complex pieces are built on top of the less complex pieces.
In the context of error-handling specifically, I’ve been able to explain it extremely effectively both in person and on the podcast[3] by showing the problems with exceptions and the utility of having things like Option
and Return
be just another enum. The steps are very clear for building a mental model here:
- Functions have return types, signified by the thing on the right hand side of
->
.
- Rust gives you
enum
types when you need to be able to represent data which represent alternatives, and you can carry data with it.
- When you have a function which might involve a failure of some kind, you can simply return a type which encapsulates that as an
enum
: a type to represent the extremely common alternative of success and failure.[4]
And historically, that was it!
Now, I note that we already have syntactical sugar for handling things that implement Try
, and to be clear I’m (largely) on board with ?
that way. So today that adds in:
- Discuss traits.
- Introduce the
Try
trait and explain that you can use ?
as shorthand for early return-or-safe-unwrap for any type which implements it.
That wasn’t so bad, because it was easy to describe: ?
(and before that, try!
) is just a convenience tool for returning an error early and unwrapping the value – it’s sugar for match
.
To that, we would now need to add:
- We also have syntax that indicates that a function returns a type which implements
Try
.
- Describe the behavior of the
throw
keyword in throw $expr
position as sugar for return Err($expr)
, and T throws E
in function return position as sugar for Result<T, E>
.
The complexity – both syntactical and semantic – is substantially larger just to be able to understand what you’re seeing when you’re reading idiomatic Rust code. It’s fair to assume, I think, that if a proposal like this were accepted, most code in the next few years would look something like this:
fn can_fail(some_param: Option<String>) -> i32 throws String {
if do_a_fallible_thing(some_param) {
0
} else {
throw String::from("😱");
}
}
- Most starting Rust users will (quite reasonably) thing this means it’s throwing an exception. This means that what-will-become-the-normal-syntax now requires extra explanation: “This may look like exception-throwing in languages like you’re used to, but it’s actually return an enumerated type, usually
Result
, which you need to handle not by catching it but by.” That’s compared to today where the function body return is just exactly the type it returns.
- Again, it’s not actually much of a gain in terms of typed characters!
3. On Ok
wrapping in general
I get that some people feel that wrapping final values in Ok
or using Err()?
feels heavy to people, and it’s especially burdensome-seeming when your wrapped return type is ()
. So much to I understand this that I went out of my way when implementing Maybe
and Result
types in TypeScript that I made Ok()
sugar for Ok(Unit)
/Ok({})
.
Insofar as we want to do something here to reduce (at least a perceived) boilerplate-y-ness, the analogy to C# and its handling with Task
seems most relevant, not least since we expect to be doing something similar with async
. In our own async/await story, though, we’re (delightfully, in my view!) using existing Rust concepts rather than adding totally new syntax, at least if my understanding is right (#[async]
and await!
are still the order of the day, right?).
4. Summary
In sum, my view of this feature as proposed is:
- It adds complexity to the language, in a way that’s actively harmful to teaching it.
- It doesn’t meaningfully reduce the boilerplate/number of characters typed/etc.
- I’m still not really clear on what the motivation is for not needing to type
Ok()
or Err()
in some spots (granted the qualification that Ok(())
is slightly annoying because OMG SO MANY PARENS).
Footnotes
-
We’re closing on a newly built house in less than a week, and I’m kicking off the largest and one of the most important projects we’ve ever done with our UIs at work this month. Just a little busy!
-
Some of the other proposals floated in the thread (
->?
etc.) increase brevity substantially, but have significant challenges for searchability, etc.
-
I have consistently gotten extremely positive feedback on that particular episode, especially from programmers with no experience with union types before, e.g. Java or C# programmers.
-
And the same for
Option
, of course, though I tend to teach that not just via function return but as a way of modeling data.