Pre-RFC: Catching Functions

I said in the original post that each subheading can be separated and this is what I meant. This thread isn't an RFC (not even formatted like one) and there's no reason these have to go in together.

1 Like

Ah! Sorry, I missed that. I was under the impression this was intended to be a big pre-RFC :stuck_out_tongue:

I am sort of repeating what you wrote, but: I too feel pretty skeptical about a "pass mode". It feels like it defeats the whole point of this proposal -- put another way, being able to write pass 3 means you have a way to return return Ok(3) that (a) is mildly shorter and (b) doesn't require you to be specific about what the "ok-constructor" is for your particular function, but it still requires you to shift into "another mode".

In my opinion, what makes this proposal valuable is being able to return "normally" -- basically being able to treat error handling as a "side channel" and let the ordinary part of the function be the successful path.

That said, I still feel skeptical of syntaxes that "separate out" Result from the return type. I do agree with the criticisms that -> catch R { ... } feels like an odd syntax, though, and -- for some reason I cannot quite put my finger on -- I don't find -> R catch { .. } particularly elegant.

I was thinking of something. If we changed the (existing) catch syntax to be catch R { ... } and introduced Ok-wrapping, then the following would be a (not particularly ergonomic, nor satisfactory) equivalent to -> catch R { ... }:

fn foo() -> R {
    catch _ {
        ...
    }
}

From here, you can certainly see where the idea of -> catch R { ... } comes as a kind of shorthand.

Have to stew on it.

One other thing to think about: we initially chose catch to avoid confusion with try!. However, it's my impression that try! is pretty well supplanted now with the ? operator. It may be worth revisiting the idea of try { .. } as the syntax for a catch block (and maybe that leads us somewhere at the fn signature level?). My one hesitation is that try R { .. } doesn't seem to make as much sense as catch R { .. } (which reads like "catch the result").

8 Likes

There are so many valuable ideas in this thread, however, I would like to add a new idea that no one has mentioned.

Why not add an automatic “wrapping” ability for all enum types? I presume we use box keyword to do this. (box can generate different types as already designed, in different contexts)

It works as below:

enum E { 
  L(i32),
  R(String),
}

let var : E = box 1; // wrapping i32 using E::L(i32)
let var : E = if cond { box 1 } else { box s };

fn foo() -> Result<P, Q>
{
    return box p; // if typeof(p) == P, then it will converted to Ok(p)
}

fn foo() -> Result<N, N>
{
  return box n; //  if typeof(n) == N, compiler can't determine whether to use Ok(n) or Err(n). This is compile error
}

My main problem with the catch R syntax is that I think its harder to spot when its being used. I think T catch E is more visible, and this seems valuable to me.

I’m not particularly concerned about not seeing Result in the return type, any more than I am concerned about not seeing impl Generator in the return type of an async function. Users will learn this once & I don’t see it seriously hindering productivity before learning it.


fn foo<F: Fn(&File) -> bool>(&mut self, f: F) -> Result<i32, io::Error> {
    ...
}


fn foo<F: Fn(&File) -> bool>(&mut self, f: F) -> catch Result<i32, io::Error> {
    ...
}

fn foo<F: Fn(&File) -> bool>(&mut self, f: F) -> i32 catch io::Error {
    ...
}
1 Like

My biggest complaint with it is that it means return Some(4) and return None do drastically-different things in a function that's -> Option<Option<i32>>.

See rfcs/text/0000-ok-wrapping.md at ok-wrapping · scottmcm/rfcs · GitHub for more.

(And there definitely shouldn't be a From::from in there, since that keeps things like return 4; from working normally.)

But is that function-level catch or a catch expression? It matters if there's a return in the closure.

It needs to be a bit magic, though, so code is more similar between fallible and infallible functions.

I think counting characters is absolutely the wrong measurement here. The big advantage I want out of this is that it becomes normal to always type the ? after something you know can fail as the first pass, allowing the first pass at the method to be written just like it was infallible.

Reminds me of this paper...

https://www.microsoft.com/en-us/research/publication/exceptional-syntax/

I think this is partially due to a lack of keyword highlighting. Changing a letter to get the highlighting helps it stand out.

fn foo<F: Fn(&File) -> bool>(&mut self, f: F) -> Result<i32, io::Error> {
fn foo<F: Fn(&File) -> bool>(&mut self, f: F) -> match Result<i32, io::Error> {
fn foo<F: Fn(&File) -> bool>(&mut self, f: F) -> i32 match io::Error {

And one thing I like about having the type there is that it allows aliases, as are common today:

fn foo<F: Fn(&File) -> bool>(&mut self, f: F) -> match io::Result<i32> {
                                                // no `Error` anywhere

(To be super-explicit just in case: I'm definitely not suggesting match as the keyword :grin:)

2 Likes

This seems like the kind of argument that was leveled against ? as a replacement for try!.

Also, whatever the new keyword ends up being would not be a shorthand for return Ok(x); it would use Try, just like ? does.

This is exactly how I and others feel about the function-level catch syntax and hiding away the Result type.

6 Likes

@vorner IIUC, you’re trying to say that shorter edit-distance maybe a liability? So if I wanted to start using error handling for a function, it shouldn’t be possible to partially make those edits and still compile. Is that what you are getting at?

I guess that's part of it. Not the whole (the above already had error handling, just tried to rewrite it to the „new“ system), but yes, Rust is generally good at being clear about your intention (that Err(Base) being success isn't) and about „when it compiles, it's almost certainly correct“.

It may be worth revisiting the idea of try { .. }

Yes, please :-). I still don't feel persuaded there's a problem in explicitly marking „I terminated successfully“, but at least, try doesn't have that congitive dissonance between what is written and happens, like catch.

Furthermore, a little bit of syntax bike shedding from me: could we place the keyword directly in front of the brace? eg:

fn foo<T>(t: T) -> Result<usize, Error>
where
  T: Display
try {

}

My reasons for that:

  • It is clearer this is about the block of code than about the interaction with caller (eg. the block of code feels like a parameter to the try keyword, or like the try is flow control for it, similar to how else is just before its block.
  • There's a case for this in C++ ‒ eg int main() try { ... } catch(...) { ... }
  • Feels much more consistent, both with the stand-alone try (catch) block and with other flow controls.

I'm not sure about the next part, but maybe it could even be that:

  • Any block whatsoever could be prefixed by try
  • If a type ascription of the result is needed, it could be done with the turbofish (just to feel the same and make it optional when not needed).
while let Some(value) = iter.next() try::<Result<(), Error>> {
   
}

Why not add an automatic “wrapping” ability for all enum types? I presume we use box keyword to do this.

Please, no. First, the Try trait seems more flexible and picking just on enums seems a bit like a hack. Second, the word Box has a very very different meaning in Rust and reusing it could be confusing.

8 Likes

The advantages i see in the original proposal is not that you use less characters, but that treating the happy path as the normal return path has both pedagogical and ergonomic advantages which I have tried to articulate.

I would be a lot more sympathetic for the proposal if it would really feel like an abstraction and not like syntactic sugar for just 'Result'.

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

4 Likes

Just regarding the syntax, I think there’s somehow a nice symmetry between:

fn foo() -> R = catch { … }

let r = catch { … }

3 Likes

I’m not particularly concerned about not seeing Result in the return type, any more than I am concerned about not seeing impl Generator in the return type of an async function.

I can't say that I like the bahaviour of async functions either.

1 Like

That makes sense. I don't really like -> catch R { either -- but I do like the R bit of it still, though the next part gives me a bit of pause...

...hmm, so I think we should probably aim for consistency here, in any case. That is, either we want to show the "external" type or the "internal" type, but not sometimes one and sometimes the other. This seems to imply:

async fn foo() -> impl Future<T, E> { ... }

or plausibly impl Generator, but hopefully we could make either work.

I think the real question (that I am still wrestling with) is whether having the user write the full return type will help with learnability or not. I could see an argument either way.

4 Likes

It's worth writing out bigger examples.

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

}

where does the catch go here?

(Another idea: catch fn, analogous to async fn -- both ways to transform the body.)

1 Like

The more I think about this, the less I like it, sorry. :slight_smile:

First of all, I am used to think of a function as 2 parts: signature and behaviour. This proposal moves part of the behaviour into the signature, making it harder to reason about. The proposed fn(params) -> Result<T,E> catch { ... } mitigates it a bit, as it is effectively the same as fn(params) -> Result<T,E> { catch { ... } }, but still offers very little in terms of clarity.

The other problem is that if we are trying to optimize the positive path by saying return n; automatically wraps it into Ok(n) and fail/throw/err "error" returns Err("error"), we are effectively introducing exceptions, so just call them exceptions and use the standard throws and throw, if not even try and catch.

I honestly cannot see any difference. I am pretty sure that with time everyone will write functions with the catch block, effectively bubbling the errors all the way to main(), like exceptions.

I hugely prefer the explicit return Ok(n) and return Err("error") forms.

8 Likes

People already bubble it up to main, with ? and crates like failure. I think that's OK.

The big difference is the annotation with ?. The huge pain of C++ is exception safety, because an exception can fall just out of everywhere, so you need to be careful about where you can exit the function. If you have a block of code containing no ?, you can be pretty sure it won't exit there, so you can have temporarily inconsistent data there. Have you ever participated in a heated discussion of three senior C++ developers, arguing if this function provides correct exception guarantees and which these actually are?

Another difference is, you can do .map() or unwrap_or_else on results, but not on exceptions. I like the flexibility of using what is more appropriate at the time.

I think when considering making changes of this scale, not only should we consider the possible benefit to completely new users, we should also consider how it affects existing language users.

  • Does this change add more confusions in deciding what to use?
  • Does this add a “this language is still in flux” impression to potential serious users in the industry?
  • Does this change focus on removing superficial complexity like the number of characters typed while adding internal complexity like additional reasoning around special cases?

In the meantime, we should also keep in mind that we already have a large portion of resources out there in places like StackOverflow. Thus we should ask whether there is negative impact to new users as well:

  • Does this change increase the need to update existing resources to avoid confusion to new users?
  • Does this change add confusion to new users already starting to get used to simple idioms?

As we should break compatibility if we are fixing bugs and soundness issues, if we are adding real expressive power to the language, or making language significantly more complete or consistent, we should consider changes of such scale. Otherwise we should have a second thought.

6 Likes

It’s worth writing out bigger examples.

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

}

where does the catch go here?

It certainly isn't as nice like in the case without 'where':

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

(Another idea: catch fn, analogous to async fn – both ways to transform the body.)

It feels more focused on the whole 'fn' instead of just the result type, but perhaps it's just a matter of getting used to it.

2 Likes

Exception safety in C++ is both more difficult and easier. Yes, you must assume that most functions might throw, but functions in rust might panic. In C++ there at least is the noexcept operator that statically checks that the expression (function) cannot throw, and neither can its callees.

I think we should not use the terminology of exceptions in rust because in rust these things are not the same – return values and panics are separated. We might one day add a keyword or some other static guarantee for expressions that will not panic, but no keyword is needed for an expression that cannot fail – you know that by simply looking at its type.

If the type of an expression is Result<T, E> then you know it can fail. I don’t like the idea of a special syntax for describing the type of expressions that are also functions (i.e. -> T catch E). Expressions are expressions and types are types – functions are not so special, imo.

5 Likes

+1 on that. That's why I advocate for (if we even need it) placing the try (or catch or whatever) or whatever in front of the function block, so it acts the same as on any other block.

Furthermore, does it make sense to be able to put it in front of (non-block) expression? Like:

try Box::new(operation()?)
2 Likes
let x: Result<T, E> = wrap { Box::new(operation()?) };

Sure.

Sorry, missed the part about no braces. Yeah, why not? It works with box.