Pre-RFC: Catching Functions

I’m very much in favour of finding some way to hide the unnecessary Ok's in functions, so I’ve been looking through my repositories trying to find some really good example of code that could be improved by this feature. Unfortunately the majority of code I’ve written either has extra complications because of being futures based, or is just a trivial removal of a final Ok(()) line.

One example I found is slightly interesting since it’s made more complex by using futures-await to implement a futures::Stream, this may be a good example to show because it constrains any potential syntax much more if it needs to work with Generators that return Result (and what about Generators that yield Result, is there any way those could be improved by this feature as well?).

Even for this example the “catching function” change boils down to removing a Ok(()) line at the end, the only other change used that was proposed in this thread is to use throw x instead of return Err(x).


#[async]
#[catching]
fn stream_impl(flag: Flag, incoming: mpsc::Receiver<Message>) -> Box<Stream<Item=Bytes, Error=io::Error>> {
    #[async]
    for msg in incoming.map_err(unknown) {
        if msg.flag == flag {
            stream_yield!(msg.data)
        } else if msg.flag == Flag::Close {
            break
        } else {
            let msg = format!("Unexpected flag value {:?}", flag);
            throw io::Error::new(io::ErrorKind::InvalidData, msg)
        }
    }
    println!("Unexpected stream closure");
}
1 Like

panic! isn't really comparable to C++ exceptions as far as exception safety goes, because the real danger in C++ comes only once you catch the exception and go to access the now-inconsistent resources. Rust mitigates that via UnwindSafe.

Heya, my first post here.

Just thinking about it, ‘catch’ might even be a bit like Rust’s Haskell-do-notation.

This is what I came here to write. It's not just a bit like the do notation, AFAICS it's 1:1 isomorphic, both syntactically and semantically, to a do block in the special case of the Either monad (spelled Result or Try in Rust):

myfunc :: t -> Either e u
myfunc a = do {
    b <- fallible1 a
    c <- fallible2 b
    return nonfallible b c
}

Versus hypothetical Rust:

fn myfunc(a: T) -> Result<U, E> 
{ // the outer braces could be elided as suggested
    catch {
        let a = fallible1(42)?;
        let b = fallible2(a)?;
        pass nonfallible(a, b); // or Ok(_) or even implicit wrapping
   }
}

Of course, in Haskell lingo return means "wrap in monad" instead of an early return from the whole function; similar to what the proposed pass would mean in the special case of the Try monad. And Haskell also has the fail constructor for fallible monads, with the expected short-circuiting evaluation! Perhaps not surprisingly, the async/await syntax (as implemented in several languages and propsed for Rust) is again isomorphic:

fn myfunc(a: T) -> Future<U, E> {
    async {
        let a = await async_fn1(42);
        let b = await async_fn2(a);
        sync_fn(a, b); // automatic wrapping?
    }
}

FWIW, my two cents:

  • Result should be taught first, the sugar later. Rust is an opinionated language and "we have something better than exceptions" should be communicated as early as possible. Return values are less magic than exceptions.
  • The function signature should look like what it actually is. Result<T, E> instead of T catch E etc.
  • Inside a "catch" block, auto-wrapping values yielded (including the implicit ()) is okay to me.
  • But an explicit return should probably always have the same type as the function signature.
  • The previous two points imply that it should be possible to early-escape from (any) block, not only early return from the whole function.
  • I wonder if it's worth it adding separate adhoc syntaxes for fallibility, async, etc. instead of biting the bullet and going full monad. (It also occurs to me the for loop is yet another special syntax, for the iterator monad!)
6 Likes

Whatever the decision, do not mention the word „monad“ to someone just trying to learn the language. Haskel's do notation is comprehensible, but if you try to talk about monads, you have to explain what it is and I've yet to find and explanation that I'd understand. And I know at least 20 different programming languages, including haskell (eg. I can use monads, I just never grasped what the hell they are).

5 Likes

Ah, I see the problem... It would be nice if there was no difference between the two.

I like @vorner's proposal best, though I still lean away from function-level catch:

 fn foo<T>(
    arg1: A,
) -> T
where
    T: Debug,
    T: Send,
catch {

}

They don't unwind, which IMHO is really important and a big difference from exceptions.

Yeah, sure. I find Scala documentation does a good job at introducing the for comprehension syntax without mentioning the M word anywhere. Although I feel that if you understand that there are types that have two things in common:

  1. They can wrap a value of another type
  2. They have a function like flat_map or and_then that can chain operations
  3. (Oh and there are a couple rules but they just formalize some pretty intuitive things)

Then you already understand what monads are even if you don't yet realize it! They are way less magical than what people think. The magic is entirely in how many types turn out to be monadic even if at a first glance they don't appear to have much in common.

3 Likes

The major difference between catch/async/for/etc. and Haskell do notation is that they compose with imperative control flow and with each other; that is not true in Haskell and the number one deal-breaker to "going full monad" in Rust IMO. (Another is that Rust doesn't have HKT and so can't straightforwardly express a Monad trait anyway, let alone base its syntax on one.)

This is an excellent article with more details on this perspective: http://blog.paralleluniverse.co/2015/08/07/scoped-continuations/. The idea is that continuations are a much better building block in an imperative language like Rust, because they offer similar power with better composability.

6 Likes

yet.

Welcome!

There's a lot of skepticism that do can ever be supported in Rust for a variety of reasons. I won't get into them here, as it's sort of derailing of the topic, but doing something like this is a much, much larger project.

I’m thinking about one thing in this long discussion. I kind of see the motivation for throw stuff, that desugars to Err(stuff)? or something similar, that in turn desugars to… whatever the exact semantics with the Try trait is.

But, is there a problem statement for the rest? I kind of still struggle to see the reason for the whole catch block (or try block or wrap block) machinery. The first post here says „catch is fixed to have the semantics specified by the RFC, but doesn’t say which RFC. What exact problem does it try to solve and do we actually have that problem? Is it just to get rid of the Ok wrapping (or whatever else wrapping) is needed on the last statement of the function?

Like, my usual fallible function often has multiple early returns for errors. But has just one success result as the last value in the function - it works from top to bottom and if nothing bad happens, it is happy to claim explicitly to have succeeded. Introducing the catch(/try/wrap) block for this kind of function doesn’t seem like a huge win. It doesn’t streamline the happy path in any way, because the only difference is at the very end. And typing catch somewhere near the beginning of the function is actually longer than typing that Ok( ).

How often does it happen that a function has multiple early successful exits?

If I look at the whole rustc (including standard library) and assume that if Ok is prefixed with return, then it’s early exit, then I get this statistics (kmpsrc is just a wrapper around rg with the right parameters):

kmpsrc ' Ok\(' | wc -l
5412
kmpsrc 'return\s*Ok\(' | wc -l
589

So, it’s approx. every 10th successful exit is early. That doesn’t sound like a convincing reason to streamline it’s use with such heavy machinery, like a keyword that messes with semantics what return does. What is the real deal, then?

4 Likes

The ? RFC included both ? and catch, with the idea that catch { try_foo()? } would be equivalent to try_foo(). That is, catch limits the scope of ? to an expression and always evaluates to a Result (or, later, another Try type).

This has the benefit that you can do a bunch of fallible stuff, using ? and all its benefits, but without having to extract it into its own function. The Ok-wrapping aspect, while clearly present in the RFC, is less motivated; I like it because of the symmetry around try_foo() <-> catch { try_foo()? } and the accompanying lack of redundancy. (The block has to evaluate to a Result for ? to work, so why allow it to evaluate to anything else?)

Notably, the RFC did not include any effect on return because it only proposed catch at the expression level. Applying Ok-wrapping to return only starts to makes sense when catch is extended to to the function level, at which point it seems inconsistent not to apply it. So it’s not really “heavy machinery” in that light- it’s a unifying and simplifying change, given that we already merged an RFC for it at the expression level.

(For what it’s worth, I also really like the idea of renaming catch to try now that try! is less of a confounding factor and also now that the associated trait has been renamed to Try, even despite the overlap with exception terminology.)

6 Likes

[Moderator note: Removed a couple of comments, sorry. In the interests of keeping this thread accessible and useful, please comment only if you have specific suggestions, questions, or problems that have not already been posted. And if you feel you don’t fully understand the proposed behavior or its rationale, please carefully read the explanations given – or ask questions about the pieces you don’t understand – before posting feedback. Thanks!

I know this is a long thread and it can take quite a bit of work to digest, but each new comment adds to this problem.]

1 Like

Ok. If I understand it (and the RFC process) correctly, the fact catch was merged, but not stabilized means it still can change or maybe be even dropped. So it makes sense to design extensions to play well with it, but if it feels better, might still be tweaked itself, so the extensions have easier life.

I follow the extension to the whole function, but it seems to me the symmetry in one place makes it asymmetrical elsewhere and it doesn’t solve additional problem (the catch block is to stop the errors and collate them, but the error handling on function level is the same). So after seeing where this comes from, some options I could live with, ordered by preference:

  • Adjust the original to include Ok(T) at the end. This is IMO clearer (because I get Ok(T) on the outside). Furthermore, we probably want the try block to work for whatever implements Try (I can see it helping with a lot of Nones). The result statement is a good place to elide what Try it is, to save the annotation ‒ eg Ok(42) or Some(42). Then the function level already acts exactly the same (and I like the symmetry of that), so there’s nothing to add.
  • Leave it as it is and don’t extend to function level. It doesn’t bring a big difference there and doesn’t solve a problem, but brings new complexity.
  • Make sure the try is placed into a reasonable position, either before the whole function signature, or after, but not in the middle of the signature, because it isn’t really part of it. It’s property of the block, so it should look like that. But related to that, we probably want to see how it interplays with other things, like async, which probably needs some annotations too. Can I have a function that is async and try at the same time?

This has been mentioned before in the thread, but generators also have a function-level wrapping effect that parallels catch blocks' and these hypothetical try fns'. In their case I think it's much more clear that wrapping is what we want- both iterator-generators and async-function-generators make more sense with wrapping and are routinely written that way in languages where they exist, so treating try fns the same way has more precedent than just the ?/catch RFC.

Another way to look at this is that Result (or whatever Try impl) is a sort of context you're operating in. It's likely that your caller will also be using ?, so in that sense it won't get an Ok(T) on the outside. Instead, for as long as you remain in the Result context, you just have Ts (plus the option of breaking out of the context).


Given these two ways of looking at it, I don't believe the symmetry between catch blocks/try fns/etc and regular function-level code is one that needs to be preserved. Rather, it should be broken to clearly separate the success path from the error path, at least while in the "fallible code" context.

Incidentally, I think this also gives another way to talk about why I prefer try fn/-> Result<T, E> catch {/etc to -> T catch E. The function signature is often where you transition in and out of this context, so it's worth preserving the actual return type to keep one foot out of the context, so to speak. It could also make it possible to compose contexts (like async and try), since I have no doubt people will want to use ? and await in the same function.

6 Likes

Bingo! That made all the things click into place for me (it was either „inside“ or „context“, don't know which).

Which brings me to two things. This whole thing seems very sensitive to wording and explanation ‒ in one mental model, it is complete nonsense, while in another it is crystal clear. I think extra care should be taken to choose the naming and for documentation.

Another is, if we want it to feel the same with other such contexts (eg. async, iter, whatever) ‒ and I feel the consensus is that we do, we should make sure external crates are able to provide their own in some way. Futures (which is the result of async block or function) aren't part of stdlib, and even if it gets there eventually, other use cases might come up.

So the question is, is it possible to create the catch block without introducing language-level constructs? If it's a keyword, extern crates are out of luck. If it's let's say a proc macro, it could work ‒ and there's a precedence in futures-await that this might work (not necessarily with the same syntax).

Furthermore, let's say both Future and Iterator implement the Try. Then it implicitly allows the early exit out of the box (which we want). An idea how this could look like:

fn get_result() -> Result<usize, Error> catch! { // Hmm, unfortunately, the try! macro is already taken
  if !precondition() {
    throw Error::new();
  }
  42 // Not wrapped ‒ Ok(42) is fine, but with generators, it'd be pain
}

// Or, inside a function:

  let my_result = catch! {
    if !precondition() {
      throw Error::new(); // Could the proc-macro make sure this just exits the catch block? Maybe introducing a hidden closure, or rewriting it to something…
    }
    42
  };

And the same with async!, provided by extern crate:

fn get_future() -> impl Future<usize, Error> async! { // That thing is a generator inside, but implements Future and that's what matters
  let fut = async! {
    precondition()?;
    await!(do_computation())?; // Hmm, here the wrapping makes it less ergonomic :-(
  };
  match await!(some_other_computation().select(fut))? {
    ...
  }
}

I must admit, that does look kind of consistent.

3 Likes

The most straightforward lower-level language construct catch blocks could be built on is probably labeled-break-with-value. However, that's a different construct than the one for async and iterators, which are built on generators. Presumably people will come up with other scenarios that don't straightforwardly map to either of those.

The more general mechanism is continuations (as I linked earlier in the thread) but those are hard to expose in a way that's simultaneously raw and efficient. The closest I've seen to that is Kotlin's suspend funs (in which I share some Opinions on ergonomics), though that's still primarily the generator concept. So while it would be good to expose labeled-break-with-value and generators directly, I'm not sure we need to (or can!) go beyond that, and I still think a dedicated try or catch block is required for integration with ?.

2 Likes

Oh, I didn’t mean to be the same under the hood. Let them be whatever they need to… I just meant similar on the syntactical level, so people have the same feeling of them when writing code.

This is a hella long thread which means statistically some of these concerns were probably already mentioned and potentially even addressed. If they were, just consider this plus ones for those.

Rust is a blessing for me. I come from a scala background and before that Java. What I ended up not liking about Scala is that it’s foundation was built on Java the model. It tries really hard to be safe and pure but when your building on top of a model of thrown exceptions you are never really safe. The abstractions leak often despite their good intent.

A few ideas I saw in this proposal and some comments were to make error handing in rust more familiar to what languages others are coming from. I came to rust to specifically to escape the notion of throwing exceptions, not because it’s model looked close to it. I have strong preference for the model of: you don’t throw exceptions, you return values, not unlike golang. That’s a very simple construct for programmers to work within and comprehend. There is nothing “exceptional” about result types. They are just a value type which encodes the notion of an error. I think I saw mention of “raise” as an alternative. That is no different if you come from a ruby background.

I’m trying really hard to split my concern over langauge terms used and functionality proposed. The language used may really be masking that.

My other concern is increasing the syntax within the language when the langauge is already slightly teetering towards hard to learn on the scale most programmers have the patience for. Once syntax is added, its hard to remove. Have we thought hard enough about doing this with library design without adding new syntax to learn? Perhaps some library alternative to unwrap that does something slightly different than panicing? I had some earlier concerns with ?. With much use I’m now adjusted. My problem with ? vs try! is that if one already knows the basics of rust one can easily understand there is probably code generation to inline a coding pattern going on. And that’s exactly what try! did. ? On the other hand accomplishes the same thing but requires a programmer to understand new syntax. Increasing the learning surface area seems at odds with reducing rusts learning curve. For that reason I’m really counting on the community to help keep syntax down to a minimum unless we think the added overhead of extending the learning curve will be outweigh by the reward received by additional syntax.

I think this may have been mentioned a few times. Despite the syntax within a function body I have a strong preference for retaining existing function signatures without change -> Result<A,B> communicates just as much to me as -> A catch B. I feel like the latter actually loses I’m formation as it’s not immediately apparent that the return type actually a Result type without spinning a few extra CPU brain cycles.

Again apologies if any of this is redundant and was already discussed. It’s exciting to see the community engaging like this!

17 Likes

While I'm unfortunately not in support of this PR (so far) I'd like to point out that I find it very well thought-through and written. Great work, @withoutboats!

Same here. Thanks indeed, @chriskrycho and @H2CO3.

To give some real-life example for why hiding Option/Result behind syntactic sugar might be a bad idea for the sake of flattening the learning curve.

I have stopped counting the number of intermediate level Swift developers who managed to get used to optional unwrapping à la …

if let foo = bar { // foo: T; bar: Optional<T>
    // …
}

… but fail to grasp the whole concept behind enums and the case let syntax …

if case let .some(foo) = bar { // foo: T; bar: Optional<T>
    // …
}

For them these are completely disjoint concepts. Telling them that those two things are the same and showing them the source code for enum Optional<T> { … } never fails to blow their minds. (I consider this a bad thing. Their response should be a bored "Well, of course." instead, at this point in their education.)

One might argue that thanks to syntactic sugar they managed to get rather far with Swift without having to understand enums at all. I would argue though that by lifting Optional and the like into a bazillion of syntactic sugars the language actually makes it extremely hard to make the jump from a dev who merely manages to make their code work, to one who actually understands it. This can also be seen in the under-utilization of if case let (generalized pattern matching syntax) in Swift.

Having short-hand syntactic sugar T? for Optional<T> further enforces this misunderstanding of what an Optional actually is. Coming from C++ or Java, most people still think of T? being a nullable pointer-thingy. I would consider -> T catch E similar in nature to what T? is to Optional<T> in Swift, in that they give the wrong impression that it's something special and completely unrelated to enum.

I dislike T? and if let in Swift for the very same reason that I'd rather not see any exception-like syntax in Rust: They give a completely wrong picture of how the language actually works, that's damn hard to get rid of afterwards. I've been mentoring lots of Swift beginners and this is a constant struggle. There is a strong and negative sentiment in larger parts of the Swift community (mostly new folks) against having to explicitly unwrap Optional<T>, rather than just accessing the wrapped value as is common in Objective-C, C++, Java and the like. It comes from the wrong impression of T? being little more than an annoying nullable object reference [sic!]. None of this kind of sentiment is found in Rust afaict. Why? Because there is no wrong picture of something being drawn in the minds of the users that is to be annoyed about.

So while one manages to lead beginners to a rather advanced point of their learning curve without having to dive into enum and their peculiarities, one makes them internalize a wrong picture of how the language works, which then later on requires them to unlearn and doubt basically all the things (maybe it's syntactic sugar all the way down and nothing is what it seems?) they just managed to learn so far.

As it happens I have written a lengthy article on why I consider Swift's generous use of syntactic sugar a hindrance, rather than a help for beginners: "Syntactic Diabetes" and a rather heavy burden on the language. I'd rather not see Rust go the same path for the whole sake of instant gratification.

33 Likes

I’ll just second that my experience with Swift here is a major part of why I feel the way I do about this proposal. That kind of syntax-obscuring-the-way-things-actually-work behavior (in order to simplify the mental model) is pervasive in Swift, and there are places where I think it’s fine—but this is one of my least favorite places of it, for all of and exactly the reasons @regexident outlines.

6 Likes