Pre-RFC: flexible `try fn`

It’s kinda hard to write artificial example for this, and on small examples it does not look impressive. Good isolated examples will be:

fn do checks() {
    if !check1() { panic!("check1") }
    if early_return() { return; }
    if !check2() { panic!("check2") }
    // etc..
}
//lets make it fallable
fn do checks() -> Result<(), MyError> {
    if !check1() { Err(MyError::Check1)? } // ugh, strange pattern
    if early_return() { return Ok(()); } // I don't care about `Ok`, just end!
    if !check2() { return Err(MyError::Check1); } // too verbose...
    // etc..
    Ok(()) // argh, always forget to add this line...
}
// with try fn
try fn do checks() -> Result<(), MyError> {
    if !check1() { throw MyError::Check1; } // intention is crystal clear
    if early_return() { return; } // with minimal changes
    if !check2() { throw MyError::Check2; } // and easy to read
    // etc..
    // no weird (especially for beginners) `Ok(())` at the end
}
fn foo(val: Foo) -> Bar {
    match val {
        // several match arms
        Foo::K => {
            // do stuff
            if condition() { return Bar::D; }
            // do stuff
            Bar::K
        },
        // more match arms
       _ => panic!("unexpected variant"),
    }
}
// Again lets make it fallible
fn foo(val: Foo) -> Result<Bar, MyError> {
    // ehm, maybe I should write `let bar = match val { .. }; Ok(bar)`...
    Ok(match val {
        // several match arms
        Foo::K => {
            // do stuff
             // again, always forget those returns buried  in a middle
             // of the function body, esp. if block was folded
            // even worse if there is many such returns, repeating Ok(..)
            // becomes quite annoying
            if condition() { return Ok(Bar::D); }
            // do stuff
            Bar::K
        },
        // even more match arms
       _ => Err(MyError::UnexpectedVariant)?,
    })
}
// with `try fn`
try fn foo(val: Foo) -> Result<Bar, MyError> {
    // no changes required, no `Ok` noise
    match val {
        // several match arms
        Foo::K => {
            // do stuff
             // everything stays as is
            if condition() { return Bar::D; }
            // do stuff
            Bar::K
        },
        // even more match arms
       _ => throw MyError::UnexpectedVariant,
    }
}

Isolated it does not seem like much, but when writing code I often get bitten by such papercuts, and it is not fun.

4 Likes

Missing try keyword. (This is in the proposal. I haven't read the thread yet)

Thank you for noticing! Fixed.

1 Like

I believe the opposite of what you are saying is the case.

I think that return Err(..) together with return Ok(..) in fact obscures the uhappy path! With fail expr and return expr, I think any unhappy path is quite clearly and distinctly marked as "other" syntactically.

It also becomes easier for a syntax highlighter to render the keyword fail differently (with a different color, an underline, etc.) than return. While you could do this with a sufficiently smart editor plugin for return Ok(..), it becomes highly contextual when you have more error carrier types (some sort of specialized Result for example).

I could see that you want that in some cases, but often, especially with network code, you deal with unhappiness by logging with a specific message, or throwing a specific enum variant, but it is not super interesting what exactly the message is.

I want to reiterate however that fail expr highlights, in my view, better than return Err(..) does, and does so generically for all R: Try types.

3 Likes

Yea, once again this is a subjective thing. I find it easy to discern things that are written, not implied by something else.

Please believe me when I say that to me the Ok/Err version is more readable.

It's not about any error message, it's about control flow clarity.

For me in VIM, Ok, Err, Some and None are specially highlighted. I would find it would obscure things if some of those markers were to go away.

3 Likes

I believe you. I'm saying that in my subjective opinion, fail expr highlights the unhappy path more. But I don't expect everyone to think the same way as I. We are unique individuals after all :slight_smile:

Not sure what is implied by fail expr here;

Perhaps you are referring to the specific type Result (that you see explicitly from Err(..))? If so, I don't think the particular error carrier is that important; focusing on it seems to me to be focusing on the wrong thing.

Even so, you can still read from the return type on try fn that -> Result<T, E> is returned and what the error and success type is. But lot's of things in Rust are this way... type inference for example requires you to read other parts of the code to understand exactly what is happening here (because of traits...), so it too adds implied things. I don't see why the case of fallibility is special. To me, the plumbing of fallibility is one of the most boring forms of plumbing there is precisely because it is everywhere.

In the source code of rfcbot, it is often about reacting with error messages -- and I don't know how control flow is made unclear by return expr and fail expr. Personally I think it is perfectly clear.

You can similarly highlight try fn and fail in your vim setup.

1 Like

@scottmcm Another comparison between async/try: throw “initiates” the error path, and the hidden yield inside await “initiates” the supsension path. We might want to pay attention to how those interact as well.

For example we currently require all manual initiations of suspension to be done the manual way, which can just be wrapped up in a hand-written Future that you can await. So we may want to consider that for throw as well- instead of making it built-in, just make it a library function that always returns an Err that you can then ?. (This is basically "make a nicer name for Err(x)?".)

(ugh Discourse really has a lot of ways to accidentally send posts…)

1 Like

Sure, but when I read things classified as "noise" together with something like:

I'm feeling frustrated. We've been discussing this for months now, you know there's people that disagree. You're a language sheperd and it's disappointing me that there's not more awareness of differing viewpoints from your side. It leads to us having to have the same fight over and over.

I really don't have the energy to keep proving my existance again and again and not be "linted" out of the community, so to speak.

Skipping over the other things because I'm sure we've had that discussion a couple times at this point.

13 Likes

I apologize if you feel ignored; but I am aware, and I recognize that my preference is not shared by all, and I say so regularly.

Lints can be disabled, you don't have to be linted out of anything. I disable some lints myself. My point on linting was in response to the problem of there being many ways to do the same thing -- and I proposed a way to solve that; you don't have to agree with the solution, but it is a way.

However, as a general point, the language team can't stop evolving the language to something that the team believes is better (assuming it does, which it might not in this case...) if there is a group of people who disagrees with some perspective or don't like it. If we did that, we would never get any design work done at all. There is always some degree of opposition to any proposal -- I think it is quite hard to find a proposal that has unanimous support.

2 Likes

The thing is, we're arguing about what's right and what's wrong about absolutely subjective things. What's more readable and more quickly scannable to me isn't going to change.

I just don't want to leave every one of these threads wishing I hadn't joined it.

Lints can be disabled, you don’t have to be linted out of anything. I disable some lints myself. My point on linting was in response to the problem of there being many ways to do the same thing – and I proposed a way to solve that; you don’t have to agree with the solution, but it is a way.

They can also become hard errors at some point due to editions. They're also quite a push towards one side.

I'm generally of the opinion that style lints such as that should go into clippy, and not be opt-out parts of Rust.

But that's not what I asked for. I'm specifically talking about each of us accepting that we have differing opinions, and not litigate subjective viewpoints down to every small even more subjective detail. We turn every error handling thread into such a discussion and it's not fruitful.

The language team makes good decisions, but they can only do that because they listen to community feedback. My feedback is that I find the current way things look more readable. And I hope that there'll at least be the compromise of not turning wanting to write Ok and Err into warned behavior.

5 Likes

By discussing things, I'm hoping we can at least understand each other's subjective viewpoints better, and maybe even elevate some points to common facts if we agree.

They could; that's true. But I hope they won't, and given the churn it would cause I am not seeing much benefits to breaking old code in 2021.

Oh! by "lint" I meant a lint inside clippy! So we are in agreement here at least.

Noted :slight_smile:

2 Likes

Certainly.

Excellent, that takes some of the sting out of it. :slight_smile:

Thanks

2 Likes

I must say that I was at first very skeptical about Ok-wrapping. But, after looking at the code examples by @newpavlov above, I think I’m not so skeptical anymore. It seems more ergonomic.

What I’m not so sure about is whether the return should be reused as the keyword for this

  • It should be consistent with try blocks. Many people don’t like to use return to exist from a try block.
  • To check whether Ok-wrapping occurs you need to look at the function signature. That’s not ergonomic.
  • Edit: As @vorner mentions below it makes tail recursion harder/impossible

I propose the following:

  • succeed and fail keywords
  • Both Ok/Err wrapping according to the proposal above
  • Used in normal functions and try blocks. No try functions
  • It’s always obvious by the keyword choice whether wrapping occurs
  • Return retains its current behavior and requires wrapping
  • Open question: How to handle return via expression without semicolon? Possible solution: Don’t handle it, require to spell out succeed ...; instead.
3 Likes

I haven't seen anyone talk about such extreme proposals. As you can see, try fn will be desugared into explicit Ok and Err. The closest thing is @scottmcm's suggestions to restrict ? to try context, but:

  • It will discussed only in preparation to post-2018 epoch.
  • I am not sure if it will find enough support. (I am personally pretty much undecided on this right now)

Originally I suggested something very similar to you proposal, but this questions is the biggest stumbling block. Plus even one keyword is a heavy price to pay, nevertheless two.

I’ll join the „please no“ camp here. Not on particular details, but on the whole direction. Here are the points why I don’t like the proposal (I have bigger problem with the auto-wrapping than with throw, I still believe throw is a mis-nomer, because it hints at exceptions, which is not the case):

  • After all these discussions about error handling, I’m still not persuaded there’s anything that needs fixing deep in the language. Sure, better error types are nice (eg. with ability to downcast). But comparing to any other error handling (exceptions, special-case values (-1), two results), rust’s Result feels the most natural and robust against confusion. Both on the caller and implementer side.
  • Introducing a new keyword is a heavy price for a feature, it makes the language bigger. Introducing a new context is even heavier. Even if there was something to fix, would this really be that important to warrant this kind of artillery? It is already hard to teach Rust (yes, I know what I’m talking about), adding yet another way how to handle errors in there doesn’t help. Explaining „Well, Rust does it differently than “ is generally fine and people take it. Explaining „There are these 2 very differently looking ways, which are basically the same thing under the hood“, people tend to ask why and lose some of the will to learn more, because it sounds crazy.
  • As mentioned, some people find the proposed style more understandable, while others less. But having to read both is much worse than either one, for either side. In other words, if auto-wrapping was the only way, from the beginning, I’d probably oppose adding the explicit style now.
  • I think the editing distance is actually an argument against the feature. If I do such a big change to my function as to make it fallible, I should have to look at all places where it makes a difference, not to miss one. And the editing distance is optimising for easy writing, not reading or debugging.
  • Unlike unsafe and (if I didn’t miss something) async, the proposed try context isn’t strictly extensive. If I take existing code and wrap it inside unsafe, it’ll still compile and do the same. It only unlocks more power. However, try makes the code not compile or even changes the meaning. Having the same code mean something here and something else somewhere else is mentally taxing while reading.
  • Auto-wrapping in complex cases like Option<Option<usize>> or Result<Option<usize>, Option<usize>> will be confusing. The Ok or Some or Err annotation makes the intention explicit and clear.
  • As this is a trait method, the auto-wrapping is a hidden and unmarked conversion. I find that auto-conversions are a bigger source of bugs in C++ programs than null accesses and data races together (usually with less severe consequences, though). Sure, there are some auto-conversions in rust (Deref, trait object coercions…), but these are generally conversions to some type that is kind of view into the real object and the original object still stays unchanged somewhere in the memory. The auto-wrapping has a taste the language does something behind my back and erases some of the trust it gained over the time I use it.
  • There’s no way to express a tail-recursive call (I don’t really talk about the tail-recursive optimisation, only about „just call this function and let it decide“). In other words, with normal function, I can return three things: Err(some_error), Ok(some_value), some_result. Here, I can do either throw some_error, some_value. Note that some_result? is not the same thing. As there’s the ? and implicit auto-wrapping, this runs some arbitrary code, which might in theory be expensive, have side effects, etc.
  • It feels like the proposal tries to make error-handling non-intrusive and out of the way. But to build reliable software, error handling is something that is important and needs the attention. Making it too non-intrusive goes against that, you can forget there’s error handling. This feels in the same general direction as „Lifetimes in Rust are hard to learn at first and annoying. Let’s remove that.“ I enjoy the fact Rust makes me be crystal clear in stating my intentions ‒ make up my mind that I return successfully, in this case. The auto-wrapping is in the „Do what I mean“ direction.
19 Likes

I don't believe async is "strictly extensive" either. Going from x to async { x } changes the type of the expression from T to F: Future<Output=T>, just like try { x } changes it to Result<T, E> (modulo the Try trait).

This isn't really the same thing. The conversions that cause problems like this (in C++ and Rust) are generally coercions- that is, you hand something an expression and if it's the wrong type it will be converted. async and try are totally different- they always do the conversion, which means try is essentially just syntactic sugar for Ok. No new conversions (let alone coercions) take place.

2 Likes

On a procedural note:

If you want this open as an option in our design space, we need to reserve succeed as a keyword (or some other synonym of succeed... please consider in a potential RFC if there's a better word..). If you want to write such an RFC, feel free to use one of my reservation RFCs as a blueprint and change some parts ^,-

We have some time constraints, so such an RFC would be somewhat urgent right now.

fail is already being reserved (in PFCP to merge).

1 Like

Adding an additional behavior to return is IMO a bigger price to pay.

4 Likes

It changes the outer shell of the block, but not the code inside. Basically, if I look into the middle of a really long async block or unsafe block, and I don't see the async itself, the constructs in there still will do what I think they do even if I don't scroll up to check if by any accident the whole function might be annotated. The return type is not such a big problem, because the async and the type are both together inside the function signature, or together with whatever variable I assign the result of the block into.

Isn't it the same here? The trait is generic over what it takes and runs an arbitrary function. Maybe it'll be just wrapping in Ok in the case of Result. But I'm afraid someone will implement their own types that do something different, just as people use operator <sometype> in C++.

2 Likes

I mean, the same is true of try? Even with a return inside a try fn, it's not so much the return that's doing the wrapping but the fn itself.

It's still purely a wrapper. That arbitrary function will always run, not just when the types don't match. (For the record I would also not be happy with people abusing this mechanism to do more than just Ok(x)).