Pre-RFC: Catching Functions

So this is where the fundamental disagreement lies, I think. It seems (if I'm understanding you correctly) that you're wanting to eliminate the bump at the point (which I totally agree exists) by encoding roughly what they think is happening syntactically – they can basically just keep moving until at some later point they need to understand what's going on "under the hood" so to speak (where the hood is the syntactical sugar for the types). In that regard, your analogy to for makes sense, and it's certainly something we do regularly in the language.

I differ because I think it's actively helpful to hit that bump, normally relatively early, and then deeply internalize that errors are just like everything else – and accordingly have all the same machinery and tools (and a few extra niceties, as well!) as everything else. They're not a special case. I don't want to coat over that with sugar, because I think that bump is pedagogically useful! That difference has been an essential part of how I help people understand how Rust solves problems differently from (and, in many ways, better than) what they're used to.

(The existing solution (?) has the advantage of being something that clearly layers on top of those base semantics, rather than being an entirely alternative way of expressing the same concept – especially as regards return type. It analogizes clearly, and accurately, to people's experience with short-circuiting operators in other languages, whether the Elvis operator in C♯ or even further back to the old-fashioned ternary.)

Moreover, I think that syntactically encoding something that looks like what they expect from Python, Java, C♯, etc, but which has profoundly different semantics seems very, very likely to mislead – to get users thinking that errors map to a concept they already know, when in fact they map to something very different. Then you have unlearning to do.

In particular:

In my view, this is an intuitive understanding we should actively discourage, as people's assuming it will fundamentally mislead them. Error-handling in Rust is not like exception-based handling,[1] and we shouldn't encourage the idea that they're similar, even for the sake of smoothing the on-boarding process.

I also think there are really important differences between your example of for loops and Iterator and this proposal.

for loops in other languages are are also often implemented in terms of underlying iterator machinery. Similarly, the for loop in Rust, despite being implemented in terms of Iterator, has very much the same semantics as a for loop in C++ or Java or C♯ or Python. By contrast, the proposed mechanism here has no analogy I'm aware of in another language: the closest is probably Java's checked exceptions, but those certainly aren't return types, and the similarity between those and this proposal is syntactical and superficial. And unlike for, the semantics of throw as proposed here are totally unlike the semantics of throw in other languages – they have radically different implications for both control flow and what the actual value returned will be.

I want to be clear (tone in text being difficult) that despite my strong disagreement about this specific proposal, I'm with you on the ratcheting as a strategy in general, and I think we have very similar goals in the main; this may simply be a matter of preference in pedagogical strategy.

And that brings me to my closing question: are we trying to solve an ergonomics problem, a pedagogical problem, or both? To reiterate @nikomatsakis' comment, I think it's really important to be clear on exactly what the motivation is to get the solution right.

Note: I'll be watching this and may chime in briefly, but I've already spent more time than I could afford on this, so please understand that it's not personal to anyone if I don't respond in here again!


Footnotes

  1. An addendum from my own experience (granting fully that I'm a sample size of one, and I have… my own peculiarities :laughing: … so I won't lean too much on this; thus sticking it in a footnote). The idea that Result is just another type was one of the things that made me fall in love with Rust. It's literally something I had wanted for half a decade or more. I was sick of dealing with exception-based error handling, and had built my own terrible enum- and union-based solutions trying to work around it at times in the past in C; I'd experimented in Python and JavaScript with my own ad hoc class-based, non-exception-using solutions as well. I don't want other people not to get to have the joy of "OH IT'S JUST A VALUE!!!" that I got. :leftwards_arrow_with_hook:
25 Likes

I also want to add, as an elaboration on my agreement about the ratcheting/levering strategy, that even though we disagree on whether or how this proposal (or the others I've skimmed) works pedagogically, I could not agree more strongly with this paragraph. I'd like to staple it to programming language design discussions. :clap: As usual, the trick is finding the right tradeoffs for this language given all the other constraints, but I appreciate—deeply—the way you and the rest of the people in this thread and other RFCs are approaching it.

7 Likes

The first is a case where you'd want to be careful, though I think the fact that you were previously doing such an unidiomatic thing (in Rust) as returning integers to signify errors would be enough signal to you for review, and you don't really need the compiler to nudge you.

The case I was thinking of there was not "previously returning integers to signify errors", but rather, "previously handling errors internally and returning a meaningful default value, and now want to pass errors externally and let the caller do that".

For a concrete example, the function might be "is standard output a device I should write color codes to" (taking various factors and configuration into account), and you might previously have taken the approach of handling an error internally and returning "no, don't". Then you want to factor that into a library function rather than one specific to your program, so you introduce an error type and change that to return errors in those cases.

The third would just be a type error. If you go from Option<T> to T catch E, all your Nones are invalid return types.

Not if you go from Option<T> to Result<Option<T>, E> if Ok-wrapping exists.

In any event these are the sort of changes that would represent a complete refactor of the function, and my belief is that the compiler prompting you with type errors is not actually a necessary code review signal that we need to preserve highly.

Compiler-guided development is one of the things I find most precious and wonderful about Rust.

3 Likes

This is a good observation.

One thought on that: what if we improve the compiler, to recognize the case when you have return t (where t has type T) in a function with return type Result<T, E> or Option<T>, and explicitly tell you in the error message that you might want Ok(t) or Some(t) instead, respectively?

16 Likes

I'm not doing this for the sake of relitigation or repeating something over and over, but because the ? operator is highly relevant to the discussion: catch blocks are from the same RFC as the ? operator, and this proposal is very similar to the ? change as well. Putting past changes into context and reflecting on them is important.

I think this comment was poorly phrased:

By "like exceptions" I meant in terms of control flow: you early return on err with ? and then return the non-error case. It is my experience that the way I solve the "must use" on Result 90+% of the time is to call ? on it and forward it up to some larger error handling mechanism. This is the same control flow pattern encouraged in other languages.

But we put up this barrier right now where instead of just typing ? and getting on with it, you see the happy path returns are wrapped in Ok and you have to know the whole system up front.

I don't think it would be positive for users to think that there is unwinding involved - admitting my biases, my experience outside of Rust has mostly been Python and Ruby & I never had to think about how their raising of exceptions was actually implemented. When I say "like exceptions" I'm thinking about the design pattern that exceptions embody, not the implementation mechanism.

It's clear that other people very closely associate language like throw & catch with unwinding mechanisms. I wouldn't object at all to finding alternative language to throw and catch. What's much more relevant to me here is the encoding of this design pattern into a first class syntax, and I'd like if we could separate that question about the feature in its essence (which @chriskrycho for example mostly focuses on) from the question of whether or not the language should mirror exception-based languages.

I don't have another metaphor aside from throwing and catching but the components are:

  • Return on the happy path (return).
  • Return on the error path (throw).
  • Early return to open a result (?).
  • Close both return paths into a result (catch).

I'd love to see some alternative metaphors that don't also complect the question with the question of whether the happy path should use return (not saying that challenging the use of return for the happy path is invalid, just trying to flesh out the total space of possibilities).

Turning toward the question @chriskrycho raises, I think this is the quote that most succinctly expresses the idea:

A way in which the "dialectical ratchet" post was not well tuned to this discussion is that it discussed leverage which occurred over a long period of time. You can go months without having to really understand unelided lifetimes. I think the wording of that post, applied to my poor use of the phrase "like exceptions", suggests that I would think users would go months without learning about Result.

I'm imagining a much narrower period of leverage; the intuitive state gets you through your first PR, but pretty quickly you learn about the Result type. You maybe don't know what to do with Result for a couple more weeks, and keep to the safe zone of ?, but you gain this insight that this is all passing through a return value early on.

However, importantly, what I'm trying to achieve is that you can get much further in making edits to functions that return Result without fully comprehending how error-handling works in Rust.

Overall, our experience is that Rust is a beautifully designed system into which all pieces fit harmoniously, but that nothing makes sense until you learn everything, making it very hard to figure out how to begin. The goal of introducing these instances of dialectical sugar is to allow you to get started before you have understood the whole system.

I think many of us have had the experience of realizing how beautifully well all of the pieces of Rust fit together, and things like Result are a big part of that, but I think it is not urgent that these realizations precede productivity. In fact, I think it is urgent that they not precede it, that users can get things done even before they have realized that Rust is as good as we all think it is.


I actually agree with this. After all, something very similar to the proposal I'm making (but fancier) was in the original RFC by @glaebhoerl. My view is that we accepted half of a feature, and this proposal is an attempt to actualize ? into a holistic and complete system.

What I think Steve meant is that your post could be interpreted "Well, we shouldn't have accepted ?, so going further on that path would be a mistake." But we did accept ?, so even if it were a mistake that doesn't seem like a good argument to leave it half finished. And in contrast to any suggestion ? was a mistake, I've found it to be a resounding success both in my own code and with other users.

For example, I know of a major open source project adopting Rust which otherwise would want have a very conservative version restriction but has decided they absolutely must have at least the version with ?.

8 Likes

Yes, I think this hits the issue pretty well for me. Syntactically, I'm ok with the similar pattern, but it should be clear that Rust is not semantically like any form of unwinding exceptions.


I think we differ on this. I learned Result before ?. Maybe it's just confirmation bias, but I feel like that is the more intuitive order to learn. Result is far simpler and more intuitive than ? IMHO. Result leads naturally to ? once someone has written enough code to wonder about ergonomics. Moreover ...

How did you come to this conclusion? Personally, that's not the experience I had. And I don't think it's the experience of people I have worked with while they were learning rust. And I don't even think this is the approach take by The Book. Is it based on a survey or something?


I can appreciate this, but personally, I would be ok with not building out the whole system all at once. This pre-RFC is a bit too ambitious IMHO.

I really don't want implicit Ok-wrapping. But I am ok (no pun untended :stuck_out_tongue: ) with something like throw provided that it is not conflated with exceptions and doesn't reinforce that intuition (as per @chriskrycho's arguments). I tend to think of those as two distinct features that could exist without each other and still be useful, just as ? does today.

5 Likes

What do you mean by implicit? I think everyone involved in the conversation agrees that the fact that ok wrapping is happening must be locally visible in the function implementation. Some people want to go further and have it be syntactically distinct from non-ok wrapping returns.

I don’t understand. Can something be locally visible and not be syntactically different?

I really appreciate @chriskrycho’s perspective, especially on the return value syntax. Keeping -> Result<T, E> is important to keep the complexity down, to give beginners an obvious path forward for learning more, and because this sugar (however it looks in the end) is purely local to the function and thus not the only idiomatic way to generate such a return type.

However, I also think catch blocks are important as a way to localize the effect of ? to an expression. The catch block itself doing Try::from_ok is the perfect complement to ? doing Try::from_error, and makes far more sense to me than either catch { Ok(x) } or any sort of coercion-like Ok-wrapping. In that light, the -> Result<T, E> catch { syntax is a good, conservative, straightforward way to extend the benefits of Try::from_ok to functions as a whole.

I might even go a little further and introduce a more general fn f() -> T = ... syntax to make function-level catch less of a special case. This resolves the catch E syntax question in favor of just sticking to using type annotations (-> Result<T, E> = catch {, let x: Result<T, E> = catch {), extends nicely to other situations (e.g. other function-level wrappers, private-fn type inference), and has precedent in both C# and Kotlin.

I’m less convinced of throw e and return t. throw because it’s too similar to (labeled-)break-with-value to merit an entirely new syntax, and return because it doesn’t have a counterpart for expression-level catch. Further, if we used the fn f() -> T = syntax above, return wouldn’t even make sense as the catch block would be one layer inside the function body itself. I would prefer whatever solution we come up with here both a) align well with (labeled-)break-with-value and b) apply equally to function-level and expression-level catch. Perhaps simply some kind of break catch?

4 Likes

Thanks for the clarification =)

Yep - it is the same proposal. I was going to take it further and introduce 2 new traits Fail (sorry, your failure::Fail - Rust trait will have to be renamed =D) and Wrap or Pass with just the one operation and then Try will extend those 2 traits.

Not true - you can end the function with pass (). To me, annotating the function signature to get OK-wrapping feels too non-local.

The advantages of a trait is that arbitrary code can be executed in its methods, so it is usable for things like Future.

You can get rid of String::from(, so it would just be throw "😱"

I think it is also not just about reducing boilerplate, but rather to make the success and fail paths more clearly annotated.

@withoutboats Can we extract the type ascription thingy on catch out of this RFC? I think it is sufficiently decoupled from the rest to be discussed independently.

1 Like

I'm trying to draw the distinction between making a marker at the function header and making a marker at each return site.

2 Likes

I see, thanks for explaining.

Just some ideas.

// err prefix means - this is our failure type
// it isn't part of an external signature
fn write_it(&self) -> Result<usize, err usize> { 

	if self.size > TOO_BIG_SIZE {
		fail self.size; // autowrap in Err
	}
	self.write(&self.data)?;
	Ok(self.size)
}
// fail, err - up to debate; could be the same keyword, could be different


// Could be even pushed more into both cases,
// for convenient autowrapping
fn write_it(&self) -> Result<ok usize, err usize> { 

	if self.size > TOO_BIG_SIZE {
		fail self.size; // autowrap in Err
	}
	self.write(&self.data)?;
	ok self.size // autowrap
}

// This seem to work with other `Try`
fn write_it(&self) -> impl Future<Item = i32, err Error = io::Error> { 
	// ...
	fail e;
	// ...
}

// or maybe
fn write_it(&self) -> impl Future<Item = i32,  Error = err io::Error> { 
    // ...
}

I’d also like to add that I find catch and throw confusing. try and fail seem less meaning-charged from other languages with actual exceptions.

Since it’s not been linked yet, here’s my stab at this from last August (Rendered):

I’m by no means wedded to the syntax in there, but I still believe in its rationale. Also, I think I’m more desiring of it working with non-Result types than many in this thread. To reuse an example, I really don’t want to see something like this

fn foo() -> String throws Async<io::Error> as Poll<String, io::Error>

I haven’t seen closures discussed yet here. I assume whatever marker is chosen here should also work for them so I can do, say, |x|? v.get(x)?.to_string().

I would certainly be in favor of the throw sugar. My thought there was that unlike ? it wouldn’t do a From::from conversion on the value, so that inference can flow into it, since one can always do throw e.into() if needed. What were you expecting there?

Along the same lines, I think its a serious mistake to substitute “easy to explain how it works” for “easy to understand how to use it.”

That's certainly a good point, the question is if this proposal makes the understanding even harder.

Lets say that this propsal makes it easier for newcomers to write their first functions with error handling, because they could somehow use their knowledge of exceptions in other languages.

Now they have build some kind of intuition for the error handling in Rust, but if they go deeper they will see that their first intuition was completely wrong.

So there will be some kind of bump in the learning, with this syntactic sugar or without it, but this propsal feels more like adding another artificial bump, without removing/reducing the bump that's already there.

3 Likes

How would you feel about generalising this idea to auto-wrapping blocks?

let x: Result<i32, Error> = wrap {
    if bar() {
        break 0;
    } else if baz() {
        break MyError;
    }

    quux()?;

    7
}

Wrapping a function:

fn foo() -> Result<i32, Error> = wrap {
    if bar() {
        break 0;
    } else if baz() {
        break MyError;
    }

    quux()?;

    7
}

These blocks would of course only be applicable to enums where the variants are distinct in terms of the values being wrapped. Option<T> can be supported unambiguously by using () for None.

2 Likes

Probably this is the deeper point of disagreement. I think their intuition will be completely right. The intuitions I'm talking about have less to do with literal runtime representation than with a conception of use. I think the intuition users will gain from this is:

  • In functions with -> T catch E, you can throw errors with ? or throw.
  • You can return the happy path case with return
  • Functions with that signature unify these two paths into a type called Result, which can be rethrown with ? or manipulated like a value.

This maps perfectly to the understanding they need to use these tools effectively.

What they will not gain immediately is an understanding that throw, ? and catch all desugar into something you could build from match, return, Ok and Err.

I think if you ask a new user, today, how ? works, they are likely to have no idea. This is by a wide margin the most complex desugaring of any of these syntaxes, since it both branches and early returns. But they can still use it correctly, despite not knowing what it desugars to - just as they can use for loops correctly without knowing their desugaring.

3 Likes

This maps perfectly to the understanding they need to use these tools effectively.

Newcomers most likely will also look at foreign code to learn from it. If they see there a 'Result' return type, and even if they're able to easily unify 'T catch E' with 'Result<T, E>', then the error handling in the implementations will look quite a bit different.

So I have a hard time to see how this syntactic sugar should make an effective usage of the language in any way faster.

It might even hinder the learning of enums, because 'Result' might be one of the first a newcomer sees and they might get the idea that 'catch' just works with any kind of enum.

I think if 'catch' unifies to 'Result', then it's IMHO also the most intuitive that this unification is directly visible in the syntax. So instead of 'T catch E' having something like 'Result<T, E> catch'.

I somehow like the ideas of @dpc in this thread, having 'ok 3' as sugar for 'Ok(3)' or even 'ok' for 'Ok(())', and 'err "argh;"' for 'return Err("argh".into());'.

I would call it local sugar, there's no need for more context information to understand it, you just have to understand the sugar at hand.

'catch' feels more like non-local sugar with the need for more context information to understand the code inside of it.