Pre-RFC: flexible `try fn`

One thing I’d like to add (thanks to @Centril for helping me formulate it on IRC!) is:

With regards to Result and Option and such, there currently exists in Rust a symmetry when looking at a function:

  • -> <type>
  • let var: <type> = <expr>
  • { <expr> }
  • fn ... { <expr> }
  • || <expr>
  • obj.field = <expr>
  • *mutable_var = <expr>
  • items.push(<expr>)
  • return <expr>
  • break <expr>

When dealing with a <type> of Result<Success, Failure>, the <expr> looks (or can look) like Ok(value) or Err(value), or it is something already evaluating to the <type>. The important part is that the same expression works in every case.

The Ok and Err serve as markers for “this value is the correct result” or “this value is the error”, and they work the same in every context, on any level, like any other value.

And to me, everything that breaks that symmetry (even with a function level keyword) reduces readability/grokkability of the code. As mentioned by @vorner, nested types like Result<Option<T>, E> or Option<Result<T, E>> and even more so type like Result<Result<T, E1>, E2> or Option<Option<T>> would be hit even worse by assymetry.

I should note that <expr>? does break the symmetry a bit, since you can provide it with a wider range of expressions. But putting an <expr> of the expected <type> there works generally as well, e.g. when going from Err(e1) to Err(e2).

But even the slight asymmetry here can require additional type hinting on the error type, for example when passing a closure to something, because there’s an unneeded .into() in there. That’s when I switch back to map and and_then usually.

8 Likes

Could you clarify what you mean here? It seems that the "tail-wrapping problem" is just, what to do with a tail expression without a semi-colon. I believe, under this proposal of requiring either: return, fail, or succeed/pass, the answer is that an bare tail-expression would do what it does today, not auto-wrap. If you want auto-wrapping, use "succeed expr;" rather than "expr" (without semi-colon).

Am I misunderstanding something or overlooking some other important point? If I am, my apologies.

1 Like

We have two fundamental questions here:

  • Do we want tail-auto-wrapping in try-blocks? Do we want try-fns with tail-auto-wrapping?
  • Do we want special syntax (pass/fail) to write return Ok(...)/Err(...) in functions and break Ok(...)/Err(...) in try-blocks? (Advantages: It’s shorter, it doesn’t conflict with loop-break in try blocks) This syntax makes sense with and without tail-auto-wrapping: fail is for errors, and pass for early returns.

I believe code with you proposal will look and feel quite unidiomatic and will be less ergonomic compared to the try fn alternative:

fn check() -> Result<(), Error> {
    if do_check() { fail Error::new() }
    pass;
}

fn foo(val: Foo) -> Result<u64, Error> {
    pass match val {
        Foo::A => 1,
        Foo::B => 2,
        _ => fail Error::new()
    };
}

I think yes to both. As was written in the OP, I believe we want similar behaviour for try blocks and try fns to reduce cognitive load.

The correct clearer way to do it is:

fn foo(val: Foo) -> Result<u64, Error> {
   match val {
        Foo::A => pass 1, // Today: Ok(1)
        Foo::B => pass 2,
        _ => fail Error::new()
    };
}

I think I don't want auto-tail-wrapping because of the tails recursion problem @vorner mentioned.

Definitely

1 Like

I respectfully disagree, imagine you have 10 match arms, you'll have to repeat pass ten times in your example. It's quite common pattern (at least for me) to return happy results from match.

I personally don't see it as a big problem. Can you provide concrete example where this will hurt ergonomics? Either way, you always can use the good old fns if try fn does not fit your bill.

Centril's master plan for error handling:

Here is my current (so it's subject to change...) master plan for error handling in Rust:

  1. We have try fn and lint in clippy in favor of it over fn .. -> Result<..>.

    EDIT: Addenda to "lint in clippy":

    @scottmcm and @rpjohnst have convinced me that linting in clippy uniformly in this way would be too agressive and that it would not be consistent / uniform with async fn which we would not lint for in this way since you can't always move towards async fn. Sometimes, you might also want to lint towards fn... instead of towards try fn depending on the usage.

    As @scottmcm pointed out, we might want a more subtle:

    "you used ? and Ok( 7 times each, but literally never mention Err(; have you considered try?"

    Therefore, I'm retracting my support for such an aggressive clippy lint.

  2. try and try fn do Ok-wrapping in the tail position.

    try fn foo() -> Result<usize, ()>  { 1 }
    try { a? + b? }
    
  3. fail does Err-wrapping everywhere (fn, try fn, try, ..).

    try fn bar() -> Result<(), usize> { fail 1; }
    fn bar() -> Result<(), usize> { fail 1; }
    try { fail 1 }
    
  4. break is permitted at the top level of a function.

    This means that the following becomes legal:

    fn foo() -> usize { break 1; }
    

    Effectively, break becomes the "return to the closest scope that is a stop-gap (try, fn, loop, while, for)"

    This change makes transitioning from a try block to a try fn easier.

  5. break does Ok-wrapping inside try fn and try.

    try fn foo() -> Result<usize, ()> { break 1; }
    try { break 1; }
    
  6. return does Ok-wrapping inside try fn.

    try fn foo() -> Result<usize, ()> { return 1; }
    
  7. there is no pass / you shall not pass.

    I (think) originated the idea, but I'd like to take it back... I don't like the idea anymore.

    Why? Because it does not fit in the imperative-monadic framework of return being a sort of pure with early-return. Adding another keyword seems wasteful and creates problems for Ok-wrapping in the tail position.

  8. Optional point: adding automatic labels such as 'try.

    To enable easy disambiguation between break to loop { .. } and to try { .. }, you can use the automatic label 'try with break 'try expr. These automatic labels can be added for 'try, 'if, 'while, 'for, 'loop, 'fn, 'match, allowing users to build powerful macros if they wish using label-break-value.

While this system is not 100% consistent, it is as close as I got.

7 Likes

@Centril Is there a convenient way to break from a try block from inside a loop inside a try-block? Also, I don’t like that break sometimes wraps (try fn/block) and sometimes not (loops).

1 Like

At first glance having break be equivalent to return outside of stop-gap scopes feels surprising, but I think it can grow on me. Can you please elaborate on why you’ve decided to prefer brake over pass? Also, do you plan to extend labels to functions as well? For example:

'fn_label: fn foo() -> u64 {
    // so `return` can be viewed as an alias to `break 'fn_label`
    loop { loop { break 'fn_label 1; } }
}

If you want to make that distinction, one possibility is providing an automatic label 'try and then using break 'try expr. We can apply such automatic labels uniformly for 'try, 'if, 'while, 'for, 'loop, 'fn, 'match, allowing users to build powerful macros if they wish using label-break-value.

I think that is the fundamental question re. Ok-wrapping in general; :wink: It feels similar to fn vs. try fn but replacing fn with loop.

1 Like

@Centril I have to say that’s exactly how I’ve been imagining an end state for try fn could look as well (including having try blocks be boundaries for unnamed breaks and perform Ok-wrapping on break, which I planned to mention in some other thread but don’t think I got round to).

1 Like

Adding yet another keyword seems to be wasteful and presents all sorts of pickles if you want Ok-wrapping in tail expressions as well. It also fits less well in the monadic framework.

See 'fn as an automatic plan to get that without having to specify 'fn_label: fn manually :slight_smile:

Yeah probably. I think I just don't want auto-wrapping. I want special keywords like fail and pass that do explicit wrapping. Kinda like the ?-operator which always wraps.

Previously in this thread the comparison to async was made. I think it's not good to have another such distinct context. Otherwise I need to ask: Am I in a normal fn? A try-fn? An async fn? An async-try-fn? (Yes these will all exist if we're consistent)

I like that

2 Likes

How is allowing break to be equivalent of return with wrapping any different than adding the "succeed" keyword you earlier mentioned? Personally, I'd find that having break be the equivalent of return to be utterly surprising and mentally taxing to keep straight.

2 Likes

@gbutler The idea is that break some_value always works. Be it in a try block or try fn.

1 Like

It is not specialized to error handling and can be generalized for other "monads". This is seen in the difference between:

fn foo() -> usize { break 1; }
try fn foo() -> Result<usize, ()> { break 1; }

It would not be equivalent to return always; It would equivalent to return only when there is no block (loop, for, while, try) that capture breaks. And honestly, what interpretation is there for:

fn foo() -> usize { break 1; }

other than that it returns?

2 Likes

Yeah, I get that, but, my question would be: Why? What I mean is, why is it important for "break" to work everywhere at the expense of making it the equivalent of return? I just don't understand the motivation. I guess it all comes down to personal preference. I could live with it (hell, I can "live" with just about anything as long as it isn't completely bizarre), but, I honestly feel it muddies the water with respect to the meaning of "break".

3 Likes

A compiler error?

6 Likes

Refactoring try to try fn without having to change break to return everywhere is the motivation.

It is not so much muddied as redefined. break expr now means: "return to closest block that is interested in early-return".

I should have said: "What other interpretation that passes type checking is there?"

That seems like a trivial, mechanical process that is easily automated using refactoring tools in the IDE (or simply through regex search/replace) and the benefit of that doesn't seem like a strong argument to the readability of the code. I'm with many others that believe that ultimately, readability and the principle of least surprise when reading code, especially if you routinely work with multiple languages and Rust isn't your primary PL or you are new to Rust, is the most important thing to consider, not how easy it is to refactor or write in the first place. My argument would be that this whole notion of "try fn/fn" is less "readable/understandable" than your earlier proposed return/pass/fail/bare-expr proposal (without "try fn") is MUCH, MUCH better for the kind of readability I'm referring to.

To be clear, I understand the goal is:

  • How do we auto-wrap consistently so we don't need to explicitly state Ok(expr) vs Error(expr) vs Some(expr) vs None vs some other thing that implements the Try trait?

Then, I understand that the initial proposal is the "fn vs try fn" with auto-wrapping as you've described (though it has been refined in the discussion). But, you (or was it someone else) at some point proposed eliminating "try fn" and using keywords to indicate auto-wrapping: return (no auto-wrap), pass/fail (auto-wrap), bare expr (no auto-wrap).

My contention is that the second proposal with the keywords is superior for readability/understandability and that that is more important for motivating the solution than refactoring/writing ease. I'm sure others (and it seems like you) disagree, but, I think many agree on the writing vs reading and which is more important. If you think about it, you read code others have written (or that you've written long ago) more often that you write/re-factor code (once you get into long-term maintenance).

3 Likes