Pre-RFC: Catching Functions

I think that’s where the main difference lies. I look at the example and my immediate reading is that the the function delegates it’s final result to bar3. If I wanted to do something with bar3s result in a later edit, I’d expect to be dealing with a Result of some kind. Or, if the return type of foo is too far up the file, I’d at least not expect it to be an unwrapped type.

2 Likes
foo()? + bar()?

The Err case is very explicit to me too here, and I always think carefully about the exit points - failures and successes alike. The mere sight of a ? pings Result in my head, and I assure myself that the types I’m dealing with are what I expect. So I’d really like to see the Result<T, E> in the fn signature.

All that said:

  • Given that we want this feature, try and fail sound better to me than catch and throw because they don’t carry the exception baggage. Their respective meanings are also clear in relation to each other and to the Result<T, E> return type:
fn foo() -> Result<T, E> try {
    let x: T = bar()?;
    if baz() { fail e; }
    x
}
4 Likes

Thank you for this summary, it really helps organize the many things going on here!

To me, a major strength of Rust is a very simple, core feature: I can use types as footholds in reasoning about my programs. Matching on enums (reasoning by cases!) is my favorite example of this.

I found myself struck by this line:

I think it could be very useful to think of error handling as “exceptions that one must manually propagate at each level” and then, once you’ve learned about enums etc, understand how that works under the hood.

This seems out-of-order: I would teach a new Rust user about enums immediately, long before the (important!) practical matter of using Result idiomatically.

Here is a data point from another language family: in the past, before I had a need to use them, I found myself a bit confused by async function and function* in JavaScript. The first time I went to use those features, it was while writing TypeScript. It was there, seeing the “external” return type written out, that made everything really click for me.

Now, conflating a T and a Promise<T>, or a T and a Result<T, E> can be either helpful or harmful. In the JS case, after seeing the concrete, true type of an async function, I found it easy to go back elsewhere (e.g. in a plain JavaScript codebase) and conflate the types in a way that was helpful: I always felt like I was on solid ground, because I had the true type in my head.

Similarly, when I have a Rust function that returns a Result<T, E>, I really do mostly think of its return type as a Result<T, E>, not a T– even when I locally want to conflate the two. Because of this, I have found it extremely easy to write my own error handling helpers, and think about how they might be composed.

The ? operator is really great, in that framing, because it lets me “unwrap or bail” explicitly. Note that I think of it as “bail”, as in early return, and not “throw an exception, kinda”. I know exactly where I’m bailing to: historically, the end of the function, or in the case of a catch block, another explicit, function-local place.

When I write error-handling in Rust, I am not thinking of ? as marking a “happy path” (like I might with, say, a hypothetical do notation): I am always thinking of as a shorthand for a particular (tedious, verbose) type of match-and-conditional-branch. In that sense, catch blocks are nice without the exception analogy, because I can think of it as “break from this block” (as if I were in a loop), vs. “return from this function”.

I happily use TypeScript’s try/catch for promises, and I like the idea of throw. But, unlike many languages, Rust has a sturdy foundation of real enums and pattern matching. I think that should be the framing that we emphasize and prioritize when teaching and improving error handling, whatever the syntax.

14 Likes

I’d also like to give an example on where I find the explicit Result and Ok/Err notation provides more readability. In

fn generate(&self) -> Result<String, Error> {
    let mut out = String::new();
    self.render(&mut out)?;
    Ok(out)
}

If I look at function signatures, my brain separates them into Result, Option and “other” categories. I feel like that would be more complicated if there were additional ways of writing these.

Another thing is that I feel that ? and Ok support each other here. The implications of seeing ? have already been noted, but I feel like the Ok works here in the opposite way as well. I see Ok(out) and know there might be error handling involved. It makes it harder to overlook that ?.

I’d also be worried about readability when the return type is Result<Option<T>, E>. You might end up with Some and None identifiers clearly visible all throughout, but any ? would refer to a Result only showing up in the signature.

5 Likes

I really like this phrasing, so wanted to quote it for explit :+1:ing

Other things that make me sad about the difference there:

  • It’s behaving subtly differently from the other two as well, since it’s not getting the error-conversion.
  • It makes it harder to rearrange lines in the method, or to add another one after it. (The ?; here is, in a sense, sortof like the trailing , after a field or list element.)
4 Likes

This is a pretty heavy thread, and I’ve gone through and I don’t think I’ve seen this exact point made, so I’m going to try to voice and justify it here:

I agree with @chriskrycho and am broadly skeptical of this proposal, I also would be a lot more comfortable with this proposal if it were implemented not with new syntax, but with some kind of macro or compiler plugin. In fact, I’d go so far as to say that I’d have a more positive reaction to this proposal if it were implemented with keywords that just looked like macros, even if there was still compiler-specific magic used to implement it!

A few points about why:

  1. It’s pretty clear even to Rust learners that names that end with ! are sort-of-special but always desugar down to something else. I can look at a macro like println! or vec! and know, “This can’t necessarily be expressed as a function for whatever reason, but under this surface it’s still built of the same Rust building blocks.” This is one way to address learnability concerns: with throw and catch, there’s the danger (described by @chriskrycho and @regexident above) that learners will think that Rust has some weird special-cased errors system where you have to manually propagate exceptions, but names like throw! and #[catch] emphasize that the error system is still built of Rust values and expressions, and that these constructs are here to ease the burden of writing repetitive code, not because they’re compiler-sanctioned magic.

  2. Implementing these features as macros would also clearly signify the degree to which they’re experimental: people expect macros, especially ones that might require a feature flag to import, to be more changeable and less stable than new built-in syntax, which means there’d be more freedom to notice problems and change them. A lot of the bikeshedding that’s happening in this thread could be resolved by saying, “Well, let’s just implement them all and then squint around the punctuation for a bit, and see what people like best.” I for one would be far more comfortable with an experimental macro than with syntax that could change on a new nightly!

  3. Implementing these features as macros won’t introduce new syntactic features: this is good for people’s intuitions about Rust, as it simply says, “Ah, and these mark that something special happens to this declaration, or this expression.” As a nice side effect, the fact that totally new syntax is not introduced would also would play nicely with existing syntax highlighters or other tooling that would otherwise need to be updated to support new keywords and constructs.

  4. There’s precedent for doing this kind of thing in a macro-like way: the async syntax is already written as #[async] and await!, which also has the advantages I’ve described here: it can be easily experimented with and iterated on, it doesn’t break existing non-rustc tooling (which is aware of macro-like items and where they can appear) and it’s also a way of communicating to the user that async code isn’t “magical”, it’s simply built out of tedious-to-write-and-read assemblages of Rust expressions.

  5. With the try! macro in particular, we also have a precedent for building an abstraction as a macro, nailing down the exact semantics, and after it has become clear that the macro has desirable semantics but undue syntactic burden blessing it with built-in syntax. I would suspect (and probably prefer, for the reasons I describe in the first point above) that the abstractions proposed here could remain as macros indefinitely without too much burden, but it’s possible that I’m wrong, and that these features are indeed so useful and widespread that the !s and #[]s are too heavyweight: in that case, you’d still derive advantages from building these out as macros first, because it would give the entire community time to experiment with the semantics and bikeshed keywords and whatnot before settling on a concrete syntax that’s more pleasing to people.

I say all that with the caveat that I still don’t feel sold on the underlying idea: but, to the extent that I and others aren’t sold on them, I feel like building these features as macros would help sell me on them by demonstrating in a clear-but-not-committed way the advantages we’d get from this proposal.

11 Likes

A couple of empirical observations.

One compelling ergonomic advantage of this proposal which has intrigued me is the ability to turn an infallible function into a fallible one with a minimum of editing (see this post), so I attempted to find out how often the situation arises.

From a global alphabetical search on crates.io I filtered the first 1000 crates published in the previous year with at least five versions and a valid publicly accessible repository. Then I cloned each repository and searched all commits in the default branch for a combination of an added fn returning Result and a removed fn of the same name without a Result in the return type.

About 15% of repositories had at least one matching commit.

I don’t have a definitive analysis, but I suspect that my method produces a lot of false positives. I checked about 20 repos by hand, and found that matching commits in less than a half of them actually represented the transition to a fallible function. Some matches covered the use of type aliases; some caught refactorings which removed an infallible function and introduced unrelated fallible functions of the same name, etc. I believe that 7-10% is a more realistic result.

On the face of it, that’s not much. However, I’m aware that a search of this kind simply can’t detect things like exploratory integration and refactoring, where the ergonomic burden of switching between fallible and infallible versions exists as well. So, take with a grain of salt.

Another, and much easier, search was for a bare Ok(()) somewhere in the source, excluding tests and examples. About 46% of repositories had at least one.


As for my opinion on the syntax, I’d prefer to see Result verbatim in the return type. If bikeshedding is permitted, my preference would be for something like fn x() -> wrap Result<_, _> { ... } which would avoid the connotations of catch and fit nicely with Option. I know that catch is already reserved, and I’m not opposed to it in general.

4 Likes

It’s actually not, so now is the time if we’re going to switch to a different keyword.

Hi, first of all this is my first post. I have been an internals and Reddit lurker for about 6 months now… I also have read the Learning Rust With Entirely Too Many Linked Lists book. Other than that I have basically no rust experience, so take that into consideration when deciding how to value this post.

Maybe, if the syntax would be async fn foo() -> io::Result<i32> and calling it would require prepending either await or impl to retrieve the Result or Future respectively than I might be ok with that.

For fallible functions however, the idea of not seeing Result in the signature feels wrong somehow. Most of the time that is. And so far I haven’t been able to pinpoint why because I keep going back and forth between “I would be ok with it IFF” and “Result seems like a very essential building block to me, we should not hide it”.

Only me not liking the the idea of introducing the catch and throw keywords in Rust has remained a constant.

PS. offtopic: foo()...is_ok() could de-sugar to let x = await foo(); x.is_ok(), foo().?.add(42) could de-sugar to let x = await foo()?; x.add(42) and foo().:.poll() could de-sugar to let x = impl foo()?; x.poll() or something along those lines… Now I am going to ZZzzz… and I will be awaiting… replies. (are this enough hints for you guys to figure out where the ellipsis als operator came from?) PPS. Ok, I lied, not sleeping yet. PPPS. And I might have changed my mind about the async/await stuff… Sorry for wasting some of your time =[

1 Like

I want to call attention to this quote. Actually, I want to print it out in ten-foot-tall neon letters and put sparkles on it. Thank you, @ranweiler; I really appreciate your post.

This is a very precise statement of one of the biggest problems I have with this proposal, and something that strongly informs the philosophy with which I approach potential changes to Rust in general.

Rust is statically typed, and has a strong type system. I love that the Rust compiler helps me write correct code, and flags incorrect code. I regularly do type-driven programming, and type-driven reasoning. Types help structure the way I think about my code. I want the compiler forcing me to match types very precisely.

When I see proposals that try to hide away types like Result, or auto-cast between types, or conflate &T and T, or similar approaches, I have a very visceral reaction to that. I love making Rust more ergonomic; I love making it more user-friendly; I love making it more welcoming. I don’t think this does any of these things. I feel, instead, that it takes a core part of the language, papers over it, and says “don’t worry, you don’t have to think about that big scary type system”.

There are some proposals to which my reaction is “eh, I don’t think I’ll use that, but I won’t mind seeing in other people’s code”. There are even proposals for which my reaction is “ugh, I wish that hadn’t gone in, I hope I never see it, but fine, we’ll survive”. My reaction to this proposal: I don’t want to see it in my code, I don’t want to see it in other people’s code, and I think it would irreparably damage how people learn and use (this aspect of) Rust. I’ve feel like I’ve been struggling to convey that point.

Rust does not have thrown and caught exceptions. Making error-handling look superficially like that does not make Rust more user-friendly. It makes Rust less user-friendly. It lets people proceed based on a faulty analogy, and then eventually, inevitably, has to pull the rug out from under them. (And even worse, it might lead to further language changes in an attempt to stretch that analogy further.) Far better to make it clear to people that Rust has a fundamentally different approach to error-handling, error-handling is just a return of a Result type, here are the ways that’s similar to other languages, and here are the ways that’s different than other languages.

There’s a feeling in this thread that feels like inevitability, like “of course this is going to happen, we just have to sort out a few details”. I know more than one person who feels like attempting to post in this thread would be like stepping out in front of an oncoming train, so they just don’t. I also feel like, in giving previous bits of feedback, I’ve focused too much on some of the surface syntax. I’m hoping, with this post, that I can convey the depth to which I feel like there’s a fundamental semantic issue here that goes beyond the high-level details of surface syntax.

36 Likes

I think you are right, that often people will – and should – come from the other order, but I don’t think it necessarily argues against the feature (though your experience around async fn may argue for a flavor that makes the full return type explicit, e.g. try fn foo() -> Result<T, E>).

That is, I certainly understand how enums work, and I still want to not have to write Ok. I almost always forget it, and when the compiler tells me to add it, I do not feel like my code got “clearer” or “better” in any particular way.

So, it may well be that the right way to teach Rust is to start with enums, teach how to use results explicitly, then show ? and try fn as a convenient sugar. That’s fine.

But it will also happen that, for some users, they will first encounter errors. For example, I often teach people – as a starting step – to use some framework or something, and enums are just not needed yet. But they still may need to call some_str.parse::<u32>() (for example), in which case they encounter the possibility of error. And maybe then it’s nice to be able to just ‘use errors as errors’, without first diverting to enums.

4 Likes

I have a lot of sympathy for this position. I’m curious, since it doesn’t seem like you’ve addressed it directly, what your thoughts are on Ok-wrapping purely in the tail position of a catch block, as opposed to return or function level. (This is what the ?/catch RFC originally proposed.) For example:

let x: Result<T, E> = try {
    let y = f()?;
    if g() { throw e; }
    y
};

I can sympathize with not wanting to write Ok. I feel like that could be addressed by, say, a succeed!(x); or ok!(x); macro that translates to return Try::from_ok(x); or similar. That would be completely reasonable. That wouldn’t break type-based reasoning, because succeed! or ok! or similar would have a type compatible with a function that returns a Result (or, for that matter, a Future or Option).

(Also, I understand that people think about programs differently. I’m not going to say it’s wrong that you don’t feel it makes your code clearer. I will say that, as a future reader of your code, I do feel it makes your code clearer and less confusing.)

4 Likes

Not a fan, for the same reason.

1 Like

How does that help, though? You still have to write it, and it’s more opaque (and more characters, if that matters).

1 Like

I have to agree with @rpjohnst. ok! or suceed! macros don’t really help. But I completely agree with @josh that not obfuscating types is very important IMO. That’s why I would like to just stay with Ok(t).

2 Likes

I was referring to the case of return Ok(x); in the middle of a function, for which ok!(x); is shorter and doesn’t break type-based reasoning. It would be nice to have a “successful” equivalent to ?. At the end of a function, if you would just write Ok(x) without the return, then yes, ok!(x) is one character longer. You might still choose to do it for consistency or if you want the implied Try::from_ok, or you could just write Ok(x).

I don’t personally feel strongly about wanting to stop writing Ok. I suggested ok!(x) because @nikomatsakis and others do want to stop writing Ok, so I proposed something that solves that specific issue without breaking type-based reasoning. My goal in doing so is partly to solve that problem, and partly to make that problem separable from everything else.

1 Like

It’s probably worth noting that some people find returning the expression Ok(()) weird in part because of the unusual, apparently-extraneous double-parens at the end. An ok! macro could have a special case where, when invoked with zero arguments, ok!() would desugar to return Ok(()). It doesn’t address the problem of people who don’t want to write a trailing expression entirely, but it looks somewhat more normal than Ok(()) while still being clear that you’re returning something.

5 Likes

I thought nikomatsakis wanted to stop writing Ok(x) because of an initial impulse to write x on its own, based on the context- not because of length or from_ok. In that sense, ok! doesn’t solve the problem at all- it makes it worse.

For what it’s worth, I disagree that wrapping the trailing expression breaks type-based reasoning, for precisely that reason. The try block uses bare values on the inside and Results on the outside, distinctly unlike a coercion. This is probably the biggest disagreement here?

1 Like

A block that always converts from T to Result<T, E>, assuming it has some structure that implies such a type conversion (e.g. not using return) seems fine. A block that sometimes converts from T to Result<T, E> but also has other ways to exit that accept an E or a Result<T, E> breaks my mental typechecker. :slight_smile: For instance, the block you posted has a T at the end, a throw e; in the middle, and an f()?; before that. That seems extremely problematic to me.

(Edited with clarifications.)

7 Likes