Pre-RFC: Catching Functions

I think we should not forget about consistency with async stuff, using fn foo()? feels too ad-hoc for me. An alternative to using try keyword is to mark functions with #[try] attribute, but result looks a bit less appealing.

1 Like

I agree. I've been wanting to write-up something for a while now, but I've been pretty busy. My feeling is that this thread has brought up four important questions:

  1. Do we want this feature at all? Will it impact learnability?
    • Drilling into the motivation more seems important.
    • My take is that this can be a win for ergonomics and learnability (which I see as distinct, but related, goals)
    • As far as ergonomics goes, I often find the need to add Ok to be kind of a "mental impedance mismatch" -- I forget it, then get an obscure compiler error, then add it. I think this is because code like foo()? + bar()? never has to talk about Err explicitly, so in a way it's odd to have to talk about Ok
    • As far as learnability, that same mismatch means that to learn error handling one has to fully grok its corners. 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.
    • But obviously things like Swift can act as a counter example: at some point, particularly when the abstraction is too "leaky", syntactic sugar just means you have to learn both the sugar and the underlying thing, and that's not a net win for learnability.
  2. Given that we want this feature, should the source code expose the "inner type" (e.g., T catches E) or "outer type" (Result<T, E>). This applies equally to async fn
    • I don't know the answer here. I think I'd like to try it both ways.
    • I can see that using the "outer type" might have the same mental impedance problem ("I return a value of type T, but my return type is declared as Result").
    • On the other hand:
      • I can see that makes the transition to full understanding more smooth;
      • It helps when reading the signature of a fn you are about to call
      • It eliminates a potential inconsistency with rustdoc
      • It allows smooth support of Option
      • It has precedent from other TypeScript and C# for async fn at least
    • I lean towards writing the "outer type"
  3. Given that we want the feature, how should we denote that you are using it? This is not independent from the previous question.
    • T caches E (assumes internal type)
    • try fn foo() -> Result<T, E> (assumes external type)
    • catch fn foo() -> Result<T, E> (assumes external type)
    • fn foo() -> Result<T, E> try { ... }
    • fn foo() -> Result<T, E> catch { ... }
    • There are also blocks to consider:
      • catch R { ... }
      • try as R { ... } ?
    • I think consistency with async fn is probably valuable here
      • note that async { ... } blocks are also a thing
  4. Given that we want this feature, is using the trappings of exception handling helpful or harmful?
    • There is after a key difference in the semantics of this feature: errors do not automatically unwind through multiple frames but rather must be manually propagated with ?
    • There are also implementation differences (e.g., we don't use typical unwinding techniques) but those are less imp't to my mind; after all, exceptions can be and sometimes are implemented using hidden return flags
    • The core question is, will using "exception terminology" be helpful to people learning or confusing?
    • I tend to think it is helpful: I think having something familiar makes it easier to learn the ways that error handling is different, in short.
      • This isn't always true. The match between the syntax and the concepts have to be close enough. I think they are in this case.
    • This does imo argue slightly against the keyword catch (as opposed to say try) -- using familiar keywords but in an unconventional way means that now you sort of have two things to learn. I'm not sure that's a net win.

Anyway, that's my best summary of how I see the state of the thread just now. I do hope to find the time to write out thoughts in more detail.

13 Likes

This kind of surprises me. The ? to me already explicitly implies (weird formulation, is there "explies"?) there's a propagated failure path, so there has to be a happy path. For example, fn foo() -> SomeOpaqueTypeAlias { bar()? } already looks wrong to me, because it doesn't acknowledge the happy path at all.

I'd also be interested in why you find the error for these cases to be obscure. Would improving the error message help? Maybe a message that is quicker to understand/recognize could improve the above kind of type intuition?

4 Likes

Yes, there is a happy path, what surprises me somehow is that I have to make clear via explicit syntax that the return value represents the happy path. The ? operator basically lets me think about "just the happy path" right up until the end when I still have to think about both paths.

Another way to say it: if you have a function like this:

fn foo() -> Result<..> {
    bar1()?:
    bar2()?;
    bar3()
}

there is some part of me that expects that "searching for ? should find me all the places that can error" (or perhaps also throw). But no, in fact there is a third spot -- that final return.

4 Likes

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