Pre-RFC: flexible `try fn`

This approach is a bit extreme, but, I think, worth exploring.

Relevant topics:

UPD: If you don’t want to read the whole discussion I recommend to check this @Centril’s comment.

Summary

Introduce try fn and throw/fail/etc. keyword (throw will be used here for now), which will allow us to write the following code:

try fn foo() -> Result<u64, MyError> {
    // returns Ok(10), i.e. happy path is Ok autowrapped
    if early_return() { return 10; }
    // returns `Err(MyError)`
    if is_error() { throw MyError; }
    // `?` works as well
    check_another_error()?;
    1 
}

try fn bar() -> Result<(), MyError> {
    if is_error() { throw MyError; }
    // Good buy `Ok(())`!
}

Additionally try fn will work with Option and even custom types which will implement TryOk and TryThrow.

How it will work

Instead of Try trait we will have the following two traits:

trait TryOk<T> {
    fn from_ok(val: T) -> Self;
}

trait TryThrow<T> {
    fn from_throw(val: T) -> Self;
}

And Result will have the following implementations:

impl<T, E> TryOk<T> for Result<T, E> {
    fn from_ok(val: T) -> Self { Ok(val) }
}

impl<T, E, V: Into<E>> TryThrow<V> for Result<T, E> {
    fn from_throw(val: V) -> Self { Err(val.into()) }
}

Now this code:

try fn foo() -> Result<u64, MyError> {
    if is_error() { throw MyError; }
    1 
}

Will be desugared into:

fn foo() -> Result<u64, MyError> {
    if is_error() { return TryThrow::from_throw(MyError); }
    TryOk::from_ok(1)
}

For Option trait implementations will look like:

impl<T> TryOk<T> for Option<T> {
    fn from_ok(val: T) -> Self { Some(val) }
}

impl<T> TryThrow<()> for Option<T> {
    fn from_throw(_val: ()) -> Self { None }
}

Why generic type in traits instead of the associated one?

For Result and Option generic type in the Try traits is clearly redundant, but with custom types it can lead to boilerplate reduction and ergonomic improvements. For example this can be in a crate:

enum Record {
    Int(i64),
    Str(String),
    Error(MyErr),
    None,
}

impl TryOk<i64> for Record {
    fn from_ok(val: i64) -> Self { Record::Int(val) }
}

impl TryOk<String> for Record {
    fn from_ok(val: String) -> Self { Record::Str(val) }
}

impl TryOk<i64> for Record {
    fn from_ok(val: i64) -> Self { Record::Int(val) }
}

impl<E, T: Into<E>> TryThrow<E> for Record {
    fn from_throw(val: T) -> Self { Record::Err(val.into()) }
}

impl TryThrow<()> for Record {
    fn from_throw(_val: ()) -> Self { Record::None }
}

And this in user code:

try fn foo(data: &[u8]) -> Record {
    if data.len() == 0 { throw; } // returns `Record::None`
    if let Err(e1) = check(data) { throw e1; } // e1 converted to MyErr
    if let Some(s) = parse_str(data) { return s; } // returns `Record::Str(s)`
    if let Some(n) = parse_int(data) { return n; } // returns `Record::Int(n)`
    throw "failure message";
}

There is (initially unintended) side-effect: type can implement only TryOk trait, e.g. like this:

impl<T: Into<Foo>> TryOk<T> for Foo {
    fn from_ok(val: T) -> Self { val.into() }
}
// no `TryThrow` implementations for `Foo`

Which will make try fns returning Foo “auto-into” functions:

// converts `1` and `"ok"` into `Foo` automatically
try fn foo1() -> Foo {
    if true { return 1; }
    return "ok";
}

try fn foo2() -> Foo {
    throw; // compilation error: `Foo` does not implement `TryThrow` trait
}

Drawbacks

  • Overly-flexibile for the main use-cases (Result and Option).
  • Can lead to surprising conversions behind the scenes.
  • “Auto-into” variant is a bit surprising usage of try fn.

Unresolved questions

Bikeshed of trait and keyword names

This feature is closely related to the throw RFC and has the same choices for keywords. Subjectively throw MyError; looks more natural than fail MyError;, thus I think it’s a slightly better option, even considering unwanted relation to exceptions.

Special keyword for “happy” return

It was proposed to add pass keyword to the language, instead of doing autowrapping returns. The big disadvantages are:

  • Additional keyword
  • Return of Ok(())

More magic in trait implementations

There is an open question of how far we want to go with traits implementations for Result and Option, e.g. the following implementation can be added:

impl<T, E, V: Into<T>> TryOk<V> for Result<T, E> {
    fn from_ok(val: V) -> Self { Ok(val.into()) }
}

Personally I think such implementations are unnecessary, and can result in too much confusion, compared to potential ergonomic improvements.

Relation to try block

Try blocks are closely related to try fns, thus ideally we would like to be able to think about try fn foo() -> T { .. } as a short-cut for fn foo() -> T { try { .. } }.

7 Likes

What’s your reasoning for splitting TryOk/TryThrow into two traits? Are they both required for a try fn? If so, it would probably be better to just have one trait, Try<Return, Throw>, than two.

2 Likes

Take Record example, how do you see Try<Return, Throw> implemented for it? Also it allows type to implement only one trait if needed.

Ah, right, because it’s a many-many relationship between return-cases and throw-cases.

But what would implementing just one mean? Would this then be return coercion with TryReturn and throw coercion with TryThrow?

Could you have a fn -> Return where Return <: ThyThrow, !TryReturn?

Yes, it will be.

And yes, technically it will be possible to create types which implement only TryThrow. If try fn returns such type, the only way to "return" something from this function will be through throw, which seems quite useless. Ideally we would like to put restriction on TryThrow "can be implemented only if at least one TryOk implementation exists", but AFAIK we can't express it in the current Rust.

I don’t think this is “extreme” at all – I was planning on writing something very similar once the initial async fn and try {} implementations landed in the compiler.

More prior art:

My proposed direction: async and try are both effects, and should work similarly

  • You never need to use async nor try nor await nor ?; you can use combinators instead, or call methods, or just match, or implement the traits directly
  • You “undo” async with await; you “undo” try with ?
  • Both have carrier types: For async/await it’s impl Async<Output = T>; for try/? today it’s impl Try<Ok = T> (though likely to change – see below – maybe impl Bubble<Output = T>)
  • There are both “delayable” effects: you can call foo() at one point – maybe not even in async/try context – then await/? it later
    • (In comparison, const and unsafe don’t have carrier types and can’t be delayed – you of course can’t do let x = foo(); let y = unsafe { x }; the way you can do let x = foo() let y = x?; or let x = foo(); let y = await!(x);)
  • Today we have .try_from() and TryFrom naming conventions where there’s conflicts with infallible; I wouldn’t be surprised to also get .read_async() and ReadAsync naming conventions where there’s conflicts with synchronous
  • Accepted RFCs have it so that you can use an async {} block to write asynchronous code in “similar to synchronous” style, and so that you can use a try {} block to write fallible code in “similar to infallible” style, with explicit markers in both cases for the things that are actually async/try
    • (Note that “what happens with return in a try block?” was a point of contention in the try RFC, and it’s just not allowed in an async block, so one simple and can-always-be-loosened-later option would be to prohibit it in try blocks too, initially.)
  • If a whole function or closure wants that style, then it can be defined as async fn or async ||, and, I would propose try fn or try ||
    • The async RFC decided that it’s async move ||, so it should also be try move ||
  • The body of such a function, block, or closure is automatically wrapped in the appropriate carrier type – it’s async { 4 } or try { 4 }, not async { Poll::Ready(4) } or try { Some(4) } – including a return expression in a function or closure

One difference is that await is only allowed in a context that’s explicitly been marked async, whereas ? can be used with just a compatible return type. We certainly shouldn’t remove that immediately, but I’d definitely be in favour of an opt-in clippy lint to block use of ? outside of try, because today ? is more than a fallibility marker, it’s also a “I’m thinking of the code here in the success-is-continue model”. And I think that an individual call is the wrong granularity for that choice: it should be a block (including, perhaps, the block that’s the body of a function). I personally find something like return Err(foo()?) dissonant, since the ? implies that continuing on is success, but then it’s not. (I also wonder if those opposed to ? would be less so if there was try as a “warning, you should expect ?s in here” marker around any use thereof.)

Similarly, I think it would make sense to start with a throw expression that’s allowed only in try context, since that immediately gets rid of the “when should I write throw x instead of return Err(x) question”. Whether it would stay that way I don’t know, though, since @Centril pointed out that it makes it harder to use in a broadly-applicable macro.

The other big question is how to write return types. RFC 2394 calls it a “complicated question”, ending up picking -> T instead of -> impl Async<Output=T> because of lifetime elision implications and a lack of need for polymorphic return. I’ve yet to see a -> T throws E syntax I’m happy with, so prefer just -> Result<T, E>. Interestingly, I think try hits exactly the opposite arguments as async here: a try fn doesn’t need to capture all the input lifetimes, so there aren’t the same gotchas with the full type, and since ?and try{} are polymorphic today, I think it’s important for a try fn to also support non-Result return types.

As for the traits, see the try_trait tracking issue. Specifically, this comment for your TryOk and this comment for your TryThrow. They’re roughly similar, though with some different choices around associated types and bounds to achieve goals like "typeof(x?) depends only on typeof(x), not on context". (And note the mention of “try functions” in that thread as well :slightly_smiling_face:)

(Oh man, that came out way longer than I was expecting…)

12 Likes

Great comment!

I’ve called the proposed approach “a bit extreme”, due to the (usually unneeded) flexibility introduced by using generic type parameter instead of the associated type in the Try traits. Regarding return type we can’t use -> T throws E, because we would like for this feature to work at least with Option<T> and ideally with custom types as well, so this is why I think using an exterior type for try fns is the most sensible choice.

As for forbidding ? outside of the try contexts, I think it’s an interesting option, which is definitely will be worth exploring after stabilization of try language features as preparation to the post-2018 epoch.

1 Like

For Return this changes

  • return Ok(x) into return x;
  • return Err(y) into throw y;

and to be honest I don’t really think that would be an improvement at all. This would create two different ways of doing things without becoming more readable or more ergonomic. This would also allow another thing before fn with would lead to things like unsafe const async try fn where it would be very hard to remember the order these things should go in.

To be honest I am against Ok wrapping in general because it reduces local reasoning and increases the complexity of the language just to save 4 characters, including the braces.

I also think that @repax’s named block proposal is a beter option than adding the try keyword mostly because it uses an existing underused language feature instead of adding an entirely new thing. It also really fits with the way other returns allow you to use labels.

12 Likes

For the return types problem, one could have syntax like this:

async(MyFuture) fn foo() -> i32 {...}

try(MyResult) fn bar() -> i32 {...}

This is true, but I also don't believe it will become a problem in practice. It is clear to me that given the choice between fn + return Ok(x) and try fn + Ok(x), we should heavily lint in favor of try fn as the idiomatic choice. This eliminates the "analysis paralysis" / "decision fatigue".

This is debatable. I think the winnings in terms of simple syntax for being generic over Try carriers is important.

Another major benefit, from my perspective is that it removes the noise of Ok and Err, which is just pure plumbing in your computation, and instead allows you to focus on your problem domain. The focus on the problem domain will also show in the code and thus make it more readable. This is fundamentally what monads are about, they get rid of the plumbing. Since we Rust isn't a pure functional language, monads won't work well here, so we use something more tailored for Rust.

Furthermore, try scales better. It is just one annotation at one place; If you have many early return and failure paths, then it can become quite noisy.

Finally, I think this is a case of a major increase to readability globally. While you might not "save more than a few characters" at each point, try fn will (I predict) become so commonplace that the net win becomes quite large.

Here's an example using try fn and postfix macros to clean up some code from rfcbot real nicely:

impl Team {
    try fn validate(&self) -> DashResult<()> {
        use domain::schema::githubuser::dsl::*;
        let conn = &*(DB_POOL.get()?);

        for member_login in self.member_logins() {
            let test = githubuser.filter(login.eq(member_login))
                                 .first::<GitHubUser>(conn);
            test.unwrap_or! { err =>
                error!("unable to find {} in database: {:?}", member_login, why);
                throw err
            }
        }
    }
}

This is not a problem. It is easily solved by just allowing any permutation of unsafe const async try. In terms of BNF:

effect  ::= "unsafe" | "const" | "try" | "async" ;
vis     ::= "pub" | ... ;
fundecl ::= vis? effect* "fn" ... ;

To make code more uniform and readable, rustfmt will then normalize unsafe const async try into a standard form. But while writing code, it should not be a hindrance.

Would you similarly be fine with typing function instead of fn? The latter only saves 6 characters.

How does it reduce local reasoning and is fallibility-plumbing important for the reasoning? It is true that reasoning becomes function-local instead of expression local, but that hardly seems like a big problem if you don't write absurdly long functions (but then you have other reasoning problems on your hand...).

5 Likes

I just want to reiterate my position that Ok, Err, Some and None are not seen as “noise” by everyone. Personally, I find them to be important markers in code.

The rfcbot code is a good example. It took me a moment to see how and where it’s decided that things are valid. A trailing Ok(()) is a simple marker communicating “everything is ok if the function makes it to the end.”

22 Likes

With try fn, the assumption is that if not stated otherwise, everything went OK.

I find this easier to reason about, and it is also the way you reason in many mainstream languages with exception handling (but we can also get the benefits of errors as values and eat our :cake: keep it...).

The moment this took you could (you know yourself best of course...) also be a factor of being used to the current model of writing Ok(..).

4 Likes

In my opinion putting the Ok at the point of decision (in other words: the end) is the superior signal. Especially since it's a signal that works everywhere. If a match arm decides success, there's return Ok. It works the same for functions, closures, blocks, assignments, function arguments, and all block or expression macros.

Similarly, in this coding style, the absence of Ok, Err, etc. means something else makes the decision.

9 Likes

Aside from somewhat subjective notion regarding noisiness of Ok(()) (though I certainly will not miss it) and ability to focus on the problem at hand, another argument for Ok wrapping discussed in the @withoutboats’s “catching functions” thread is a shorter editing distance between fallible and infallible functions. Imagine this function:

fn foo() -> u64 {
    // N returns here
   let data = bar().unwrap();
   // M returns here
   if !check_stuff() { panic!("check failed") }
   // K returns here
}

Now we want to make this function fallible (e.g. we decided to remove unwraps after initial prototyping), today we’ll have to find all returns and do Ok wrapping manually. While with this proposal we’ll just write:

try fn foo() -> Result<u64, MyError> {
    // N returns here
   let data = bar()?;
   // M returns here
   // or Err(MyError("check failed"))? if we decide to drop `throw` keyword`
   if !check_stuff() { throw MyError("check failed"); }
   // K returns here
}

It’s clear that try fn allows much faster refactoring of the code in such cases. Arguably good IDE could help with such changes, but in my opinion it’s a bad argument.

6 Likes

What worries me somewhat about this proposal is that not only error types get converted automatically when returned via throw, but success types as well. Specifically, this example from the OP:

// converts `1` and `"ok"` into `Foo` automatically
try fn foo1() -> Foo {
    if true { return 1; }
    return "ok";
}

Because From::from() is a potentially expensive operation, I feel this goes too much against Rust’s tendencies to make such operations explicit. This is why we don’t automatically convert from &str to String, for example.

My (subjective) opinion is that at least in this example, I would prefer seeing the .into() on both returns, noisy as they may seem to others.

6 Likes

My arguments against the editing distance point would be:

  • I’ll still have to update all call sites anyway.
  • Having a review of the function is a good idea anyway when you introduce a new early return or add error handling. In your example not doing this might lead to a missed panic site.
  • I’d consider writing convenience less important than clarity when reading.
12 Likes

I think you misunderstood the proposal a bit, in Foo example author will have to explicitly opt into such conversions by implementing Try traits for Foo as he sees fit, it will not be done automatically. For Result and Option there is no automatic conversion for TryOk, and into() is used only for errors, which is equivalent to that ? currently does.

My example was about an objective advantage of try fn, as for subjective I personally think that try fn will make reading and understanding code easier, not harder, because it will allow to focus on happy path, which usually contains the most important program logic. (i.e. I agree with @Centril :slight_smile: )

3 Likes

I’ve heard this “editing distance” argument before, but I’ve never seen a post that actually demonstrated it. Since you’ve left out the “do Ok wrapping manually” case and are hiding most of the snippets behind comments, I have no way of seeing that there’s a significant benefit here unless I’ve already been convinced that try functions are a win.

Could you flesh out your example so those of us who are not convinced can see what you’re seeing?

6 Likes

Sure. I was just stating that that while having to write less is/can be a plus, when weighing it against the downsides it has for me, I'll always prefer to write out those Oks, Errs and so on.

The "happy path" argument I believe also has its downsides. While you can often simply consider the happy path, once you start hiding the unhappy path it of course becomes harder to notice it. And the points where happy and unhappy paths diverge are the most important to me. I wouldn't want to hide the points where unhappiness gets involved when I'm mutating things, for example.

3 Likes

I see, thanks for the clarification!

1 Like