Pre-RFC: Catching Functions

Yes, it creates an inconsistency in that respect. I have been wrestling with this for a bit; I am not sure whether that bothers me or not.

Let me give you some related precent. Consider async functions in C#:

async Task<int> AccessTheWebAsync()
{ 
    ...
    return urlContents.Length;
}

Note that this is not async int AccessTheWebAsync() – note also that it returns an integer.

I’d be interested in looking around for other such precedent. I could see an argument that it’s actually easier to understand this way – that is, that the consistency between the type of argument given to return and the declared return type is not that important, as compared to being able to easily see the “real” signature.

Yeah, as you pointed out in a different context earlier, we already have coercions in returns (deref coercions, but also more radical ones like reference -> pointer). So one could view a catch function to have additional coercions enabled for returned expressions.

Edit: No that doesn't work. Coercions are optional, while Ok-wrapping should not be.

What strikes me about this proposal is that the catch syntax is very close to something like an anonymous sum type. That is, this:

is very syntactically close to writing

fn foo() -> usize | io::Error { ... }

I realize that catch is semantically a bit more than that, because errors are coerced to a single value, and the type is specifically a Result with all the advantages that has, instead of being anonymous (whatever that might mean if such a thing were to appear in Rust at some point).

But I think anonymous sum types are desirable, so maybe it's worth considering if catching is really a specialization of that? Here we want to return a binary sum type, with two keywords; one for returning to either of the two branches, with the major difference being that we want one of them (throw) to coerce to a specific type, while the other (return) must give its values as-is.

The big question seems to be how such an anonymous enum might be used as a result in an easy way...

4 Likes

I think I like the overall proposal here. Now, I don't have a lot of Rust code under my belt, so I can't really speak to how much it's actually needed, but I can imagine that it is. Aside from my previous comment, my only real gripe is the naming; and I know this is bikeshed territory, but I think names like catch and throw carry enough baggage to actually put the RFC in a very poor starting position. I've certainly winced whenever someone said that work was underway on catch blocks, what with my Java background.

So if we decide that Result is special enough to warrant this sugar, then I think we should choose names that are very specific about implying failure, instead of throw and catch. This is especially important because looking at the actual english words "throw" and "catch", they seem to imply that some special control flow is happening (which is why they are an excellent fit for exceptions), when that is definitely not the case in Rust.

maybe use

fn foo() -> i32 fails Error {
   fail MyError;
}

I also think it's good that the names imply a specific usecase (for a result, in this case), to better make it clear that this is not intended as a general mechanism. Unless of course we do want it to be more general.

5 Likes

Repeating some of my concerns voiced in #rust-lang:

The only bit in this RFC I am not comfortable with is -> T catch E being sugar for Result<T, E> + some Ok-wrapping. I’d prefer if it instead was sugar for -> impl Try<Ok = T, Error = E> and from_ok-wrapping instead.

I think in general, it is better to hold the specific error monad abstract and then .into_result() at the top level of your stack of functions… I also think that this composes better.

Of course, not knowning that the type is Result means that using match directly on the result of a computation is not possible.

With respect to #[async] and “return types not being what they seem”, I don’t like seeing Result which is then converted to -> impl Future<..> this implicit conversion is not clear wrt. intent to me.

11 Likes

I’d prefer if it instead was sugar for -> impl Try<Ok = T, Error = E> and from_ok-wrapping instead.

This was exactly what I was just about to bring up, if you look back at the previous thread on this exact topic (interestingly with the opposite name) this was a point that @glaebhoerl made.

You do slightly lose out because you don't have all the Result methods available in the caller, but the majority of the time you're just going to be using ? anyway--and if you do need them you can call into_result(). I think avoiding special-casing language features towards Result is worth that though.

One thing I didn't see cleared up in your later posts is what appears to be a slight misunderstanding of what the semantics specified in the RFC for catch actually are (unless there's been an update to the RFC that I wasn't able to find after a brief search). This part of the third section is slightly independent to the extension of having a "catching function", for a large part because it does not affect return statements at all.

Taking the example from the OP but using an "inner catch" going by the currently specified semantics you would have to write:

fn foo() -> Result<i32, Error> {
    catch {
        if bar() {
            return Ok(0);
       } else if baz() {
           throw MyError;
       }
       quux()?;
       7
    }
}

Specifically, a return statement ignores the fact it's inside a catch expression, it's only the implicit block return value that gets Ok/Try::from_ok wrapped.

It's only when creating a "catching function" that return is affected, and in that case you are saying that your function returns i32 (and catches/throws Error) so it makes sense to write return 0 and have the function implicitly wrap that into Ok(0)/Try::from_ok(0) when retruning to the caller

1 Like

While I appreciate the attempt to be generic, I don’t think that you would want to “desugar” to -> impl Try<..>. I don’t even think it makes sense, actually. Consider:

-> impl Try implies that the callee chooses the type that is returned. But on what basis are we making this decision? Consider this simple example:

fn foo() -> u32 catches Foo {
    22
}

If this were to desugar to -> impl Try<u32, Foo>, there would be no constraint on what type we use. The source only specifies the ok and error types, but not the “carrier”.

One might imagine desugaring to a generic type parameter T: Try<u32, Foo>, in which case you could sort of desugar to:

fn foo<T: Try<u32, Foo>>() -> T {
    T::from_ok(22)
}

But this seems like a poor choice. It’s not clear that the caller wants to be involved in this choice, to begin with, but also this will result in extra monomorphiations and complexity.

I would much rather we just specify a concrete type. I still feel like there is some meaning to that choice, in any case – e.g., returning Result vs Option carries (to me) a subtle distinction in intended usage. (Of course we can return T catches NoneError or whatever, but that’s kind of verbose for no purpose.)

The more I think about it, the more I like something where you write -> catches Result<T, E> – that is, you write the actual type the caller will receive. I’m not sure if the catches keyword is the right one, but it’s not the worst one, I suppose.

4 Likes

Can we use “T throws E” or “T throw E” instead of “catch” like Java and C++ do?

This proposal is equivalent to Java exceptions, except that you need to write “?” after function calls that throw (otherwise it’s equivalent to wrapping in Try() in Scala) and you can declare a single exception type (although it can be “polymorphic”), so using the same syntax makes sense.

Syntax to support declaring multiple error types (after adding anonymous sum types to Rust) could be nice as well.

And also rename “catch” blocks to “try”, which is the normal syntax, since the feature is still unstable if I’m not mistaken.

2 Likes

I was assuming that the function would internally use an anonymous function specific enum isomorphic to result to actually contain the data, similar to the generated per-closure types. That type would only guarantee that it implements Try, again similar to closure types only really guaranteeing that they implement Fn* traits. Since we now (almost) have impl Trait syntax though, we can actually hide that generated type from any external users of the function behind an impl Try instead of having a generated type appear in error messages.

If impl Try isn’t compatible with match (and presumably Result methods, and composability with Result-based functionality such as collect::<Result<>>()) that seems like a huge cost. What is the benefit of using Try?

Well, not compatible directly with match, but it is via .into_result()… But honestly, I tend not to use match on Result until I actually need to inspect what sort of error it is, and that usually happens high up the call stack. The benefit of using Try would be that T catch E works for other types as well - but I guess I can live with the syntax even if it only works for Result.

I guess I change my mind: T catch E only working for Result is an annoyance and nit, not a deal breaker.

I don't think I've seen a syntax proposal that uses return for that, so I don't know for sure. Off the top of my head, that bothers me a little less. Trying to introspect on the reason why, I would say it's because the function actually does, semantically, return that type at the end of its generating, and yields another type in the middle.

My apologies, in hindsight I can easily see that that was not a good way for me to say that. I meant it as a serious question. If you feel that it's appropriate to write the type as -> Result<T, E> in rustdoc, because that's the actual type it desugars to, then why is it any less important to write the type that way in the code? If anything, I feel that it's more important to write the type that way in code.

I can understand that. But on the other hand, that's the same problem I have with it: I don't want that to have a small edit distance; I want to carefully look at every single return value, and have the compiler tell me if I've missed fixing any to return the correct type.

9 Likes

I see. We could do that, I suppose, but I really don't know what the advantage is. I would rather just allow the user to specify the type they want, which also lets us support Option. =)

This still assumes that the “caught type” is Try, right? If we can make this sufficiently terse, I am all for it.

fn foo() -> catches io::Result<()>

Is it right to say that "foo catches an io::Result<()>"? It seems a bit strange to me - Is not () returned and io::Error caught (or am I thinking of throws?)? I think @withoutboats original -> T catch(es) E reads better with this perspective.

Is there a better word than catches perhaps?

Should we just use an attribute on the function instead to opt into Ok-wrapping ? What are the disadvantages to using an attribute? It does not seem to me that catches is a property of the type being returned but rather that certain expressions (like return) should translate into the return type in a certain way, and that seems to me is a property of the function at large. Is this reasoning agreeable to you?

@withoutboats Another nit I just thought of… Can we change the catch with type ascription syntax into the following?

catch : io::Error { // note the colon..
    // stuff
}

To me, it feels less special and more like normal type ascription, which I think is a good thing…

1 Like

More bikeshedding ideas:

Perhaps the property of Ok-wrapping really belongs to the arrow ->, so it’s a different arrow type. So how about (one of these):

fn foo() ?-> io::Result<()> { /* stuff */ }

fn foo() -?> io::Result<()> { /* stuff */ }

fn foo() ->? io::Result<()> { /* stuff */ }

fn foo() -?-> io::Result<()> { /* stuff */ }

Perhaps this is a cray cray idea… but let’s entertain it?

2 Likes

Having the function signature remain fn whatever(args) -> Result<T,E> is definitely a plus (I’d even says a must) for its didactic value. I think something the following could be conceptually clearer, using the constructor name (Ok / Some) instead of catch.

fn end_walk_in_cafe() {}
fn take_walk_avoiding_slip_and_falls() -> Result<(), WeatherError>

fn go_for_a_walk(args) -> Result<(), WeatherError>
  Ok { // look'ma, no parens
     if weather_looks_bad()
     {
           fail WeatherError::ItIsRainyOutside // throws outside the outside Ok
     }
     else
     {
          take_walk_avoiding_slip_and_falls()? // likewise
     }
     end_walk_in_cafe(); // look 'ma, no Ok()
  }

It generalizes nicely to Option, and presumably other types

fn pick_mushrooms(args) -> Option<Vec<Mushrooms>>
  Some { // look'ma, no parens
      let current_place = random_walk(300);
      if has_forgotten_basket() { fail NoneError } // or maybe just fail None, fail (), fail;
      mushrooms = look_for_mushrooms(current_place)?;
      return mushrooms.filter(is_edible) // look 'ma, no Some
  }

Lastly, along the errors-as-values theme, it does (somewhat) nest:

fn careful_mushroom_walk() -> Result<Option<Vec<Mushrooms>>, WeatherError>
  'weather: Ok {
     'mush: Some {
        if weather_looks_bad()
        {
           fail<'weather> WeatherError::ItIsRainyOutside
        }
        else
        {
          take_walk_avoiding_slip_and_falls()?; // ? goes to the outermost Ok / Some
          let current_place = where_am_i();
          if has_forgotten_basket() { fail<'mush> NoneError }; // ends up yielding Ok(None)
          let mushrooms = look_for_mushrooms(current_place) ?<'mush>; // not sure if we want to allow that?
          return mushrooms; // bon appétit!
        }
  }
}
4 Likes

That is quite clever indeed - however, I fear that trading one type of bracket Some( .. ) for another Some { .. } is too similar, and not distinct enough to convey the changed rules of return. If you can change the syntax to make this a bit more distinct that would be nice.

The similarity to break is nice tho.

1 Like