Pre-RFC: Catching Functions


#7

This might seem like an very broad, fundamental and conservative reply, but I’m not convinced. (FWIW, I’m also not convinced by catch, less so by “Ok-Wrapping”)

First of all, to get it out of the way: I don’t like throw and catch, as words, as they point towards exception handling. This is amendable, though, better words can be found.

My issue is that error handling in Rust currently has a nice property that’s rarely appreciated: It works the same in any context. Just as Ok-wrapping does, this proposal puts emphasis on the case where Result is a return type. I’d like to make a case that while this case seems so dominant it merits its own ergonomics surface, it really isn’t.

Results are central to Rust, but Result flow really isn’t. I’d like to give two cases are currently very intuitive and useful in the current language, which with these proposal both don’t get better and suddenly become cases of “Rust cares about this, but not about that”.

The first one being types which aren’t Results, but have a direct result to Result. The dominant example here is Future:

trait Future {
    type Item;
    type Error;

    fn wait() -> Result<Self::Item, Self::Error>;
}

pub trait IntoFuture {
    type Future: Future<Item = Self::Item, Error = Self::Error>;
    type Item;
    type Error;
    fn into_future(self) -> Self::Future;
}

A Future can kind of be seen as a future Result and a Result as an immediate Future (and indeed, the Future API is very much built around that idea).

Let’s have a look at a function returning a leaf:

fn leaf(success: bool) -> FutureResult<String, String> {
    let res = if success {
        Ok("yeah!".into())
    } else {
        Err("ouch".into())
    };

    res.into_future()
}

(This example is a little more verbose then necessary, the ok and err functions of futures would cut that down much more)

To my understanding of the RFC, this would stay as is, as FutureResult is fundamentally a different type then Result and catch cannot express that. This would lead to the odd case where the work of making similar concepts expressible in a similar fashion would suddenly be broken. Current Rust, by making Results not all too special, very nicely maintains that you can easily use your own type without losing ergonomics.

Another case is error flow that is not stack based. This is regularly the case when talking about systems that have no useful notion of a stack from a systems view such as actors, streams and channels.

Let’s take a simple example from futures here, again:

fn main() {
    let (sender, receiver) = oneshot::channel::<Result<String, String>>();

    sender.send(Ok("foo".into()));
}

Again, even with all these proposals, I’m back to explicitly constructing errors. Neither this proposal nor other Ok-wrapping proposals help here.

Finally, making catch a keyword only possible on functions strikes me as odd here, to my knowledge, it would be the only keyword that can express a type on a function in a way that isn’t possible anywhere else.

The beauty about the current error/result story of Rust is that all the above cases are similar to handling returned results (with the small exception of ?, which I consider a modest addition).

I see a lot of reference about “introducing a way to speak about errors” into the language: we already have that. Results are values, with all the freedom that this gives you, especially them not expressing anything about flow. In my opinion, this is a feature, not a bug.

Much as with other, similar proposals, I would also appreciate extensive user research into concrete problems that the current system has. In my experience teaching Rust, the current system is easy to teach and understandable. It is verbose, but this a thing that the language has never shied away from.

My gut feeling is that this proposal will lead to a situation similar to do in Haskell: it’s there, it’s convenient, but when you get serious, you throw the sugar away and go back to do it all by hand.


#8

It’s certainly even worse if return sometimes wraps and sometimes doesn’t within the same function. But even if not, having return sometimes wrap at all, even if it consistently does so within the same function, still feels wrong. Again, you’re not returning 0, you’re returning Ok(0), and hiding that is not something I want to see when reading other people’s code.

That’s why I suggested that I’d find it much more palatable if it didn’t use return. A separate keyword that always implies that automatic coercion seems fine.


#9

The solution to this is the async/await notation, which takes functions that seem to produce Results and instead produce futures. Your leaf function, with the combination of async and catching:

#[async]
fn leaf(success: bool) -> String catch String {
    if success {
        String::from("yeah")
    } else {
        throw "ouch";
    }
}

(your function doesn’t actually do any async IO, but I guess we are to imagine some sort of await'd future call in there)

Here are some other examples in which the return type of a function is special:

  • Returning ! on stable (generalized to a first class type on nightly).
  • The impl Trait “type” means different things in return and argument position; I can’t recall if its been generalized to be allowed in arbitrary type position (e.g. as the ascription of a let) yet.
  • Omitting return types means returning the () type.
  • Generators return impl Generator, but that’s unlikely to appear in whatever syntax we decide for them.

In other words, its not uncommon for the function signature to not be literally (types) -> type.


#10

@josh Do you feel similarly about generators, which return a state machine that eventually evaluates to their return type, but in every syntax proposal I’ve seen use the return keyword and their return type to signify what the generator’s final evaluation will result in?

Is this supposed to be a cheeky way to say you don’t like this proposal or a request for something I don’t understand? I don’t know what “normalized in the code” means if isn’t just “don’t implement this feature.”


#11

async/await is relying on an explicitly experimental RFC. I’d prefer to not discuss a new, independent syntax in the context of this.

Also, this would then special case Futures in the context of Results. That building will fall over at some point.

I took futures as an example for a classic type that has a relationship to Result, but is not result. Any other type would have similar problems.

Yes, it’s just a mock.

You’re right, I wasn’t clear on that. I’m still not sure I’d like to see another addition to that list.


#12

One of the chief ergonomic advantages I failed to layout in the original post was that this proposal is designed to have a very small edit distance when introducing fallibility to a function.

In my experience, this isn’t an uncommon experience. As an example, some unit of code which previously was pure now needs to do some IO (say because its pulling a value from some sort of shared cache that it used to compute). Maybe this isn’t the best design and all IO should be segregated from logic etc etc, but in real systems these sorts of things happen, whatever.

Today, there is a significant amount of purely boilerplate edits you have to make to enable this. In addition to the actual material changes you’re making, you have to change the return type of the function and find every place it returns and wrap them all in Ok. When I have the misfortune of making these edits in a function that ends with a match statement, I’m often tempted to use a regex - to mixed results usually.

But all of this could be eliminated - you add the code you want, you add catch ErrorType to the function signature, you handle the error everywhere it is called (probably by ?). In other words, we reduce the edit distance to the material changes being made to the code.


The interaction with async/await is one of the key insights that makes this proposal work well. Though the async/await RFC was experimental, it’s extremely well motivated and its hard to imagine a world in which we don’t someday have a form of async function which appears to return a result and actually returns a future.

Which types? Result and Future are important to support because they are pervasively important in real code. The weakly supported one here is Option, its true. I’m not convinced that “some wrapping” is so important though, for these reasons:

  • Its not that common for a function that unconditionally returned T to switch to returning Option<T>, not in the same way that becoming fallible is common.
  • When I write functions that return Option<T>, in my experience the terminal expression is usually None, whereas in result functions the terminal expression is usually Ok(result). In other words, the “happy path” is less predominant in most functions I write that return an Option.

Do you have other pervasively used types that you think an ok-wrapping feature needs to be able to support to be worthwhile? Why is it important to support these types?


#13

I haven’t had time to really digest this proposal yet, but I want to put my hand up for being strongly in favour of some ergonomic improvement here. Error handling is really important, it is a fundamental area which distinguishes high quality code from low quality code. However, like unit tests and documentation, it is an area of programming which feels like taking medicine - we know we should, we know it is good, but it is not fun. Therefore, we should strive to make proper error handling in Rust as ergonomic as possible. Furthermore, although our current system has many advantages, it still feels a bit clunky to use (and especially to convert non-error handling functions into properly error-handling functions) compared to languages like Java with a built-in exception mechanism.

To put it another way, we need a really nice carrot here to bribe people away from unwrap.


#14

As I wrote above, this isn’t my experience. This is a fundamental change to a function, there is even a school of programming (which I’m comfortable with) that says such a function should be completely written anew.

I’m also very okay with boilerplate and frankly have the opinion that boilerplate is overrated. Most large systems in this world are written in very boiler-platy languages (e.g. Java, C++), and while this is certainly not something to strive for, it is something that can definitely be accepted.

It still makes for a bad argument. I’m not against seeing features in context of other potential feature and weighting their impact, but opening up a discussion on an eRFC as if it were done is not good support.

Other types include anything that moves towards a result, such as alternative implementations of Future-like concepts or any kind of operation. Indeed, my experience in many complex situations is that moving away from Result towards something more rich is useful.

My argument is that the pervasiveness of Result is a bit of a red herring in my opinion and that symmetric usage of the same syntax and approaches in all contexts has value.


#15

I like auto-wrapping and throw very much. The Err(e)? and Ok(()) are Rust’s syntactical quirks I won’t miss.

The “auto wrapping” concept has to make an implicit “leap” from unwrapped value to Result somewhere, and I think this proposal puts the leap in the right place.

Locally, in the function declaration site, this syntax is entirely consistent: Given a function signature is foo() -> i32 catch Error it looks as if it returns i32 and “catches Error” without a Result in sight, so accepting bare return 0; throw err within the function IMHO is absolutely fine, and consistent with foo() -> i32 declarations which also support return 0 (c.f. how weird would it be if the function type didn’t mention Result, but function body was sometimes required to use Result constructors and sometimes not).

There is a bit of magic if you notice that “from the inside” the function returns i32, but on the outside it returns Result<i32>, but it’s at the API boundary, so if it has to be somewhere, that’s the right place. If Rustdoc documented i32 catch Error functions as Result<i32, Error> then the return type would not be surprising to users of the function.

So it’s well-placed syntax sugar IMHO; +1


#16

I like this set of proposals better than Ok wrapping, which I never had any fundamental philosophical objections to, but which felt a bit meh. These proposals together give a more coherent picture (e.g., the function-level catch can be seen as wrapping the function body in a big catch block).

However, it’s not a slam dunk for me yet, because it doesn’t really work out Option as @withoutboats themselves said. Result needs the sugar more badly, but if this syntax is deliberately leaning on ? and catch, it feels like using it with Option should be at least sensible if your function is structured like a Result-returning one (early returns of None and “regular” returns of Some). With the proposal as-is, it feels like trying to fit a square peg in a round hole:

  • What, if anything, do you write in throw to return None? Existing code can do None?; which really isn’t great compared to return None; — can throw improve this too without having to deal with NoneError? (Does it even have to be improved?)
  • Having to write -> T catch NoneError as Option<T> in the signature is twice as verbose and much uglier than -> Option<T>, even when you then manually wrap the function body in a normal catch.

Unlike @skade, however, I don’t see the need to cover other monadic types like Future with this sugar:

  • Those types are sufficiently different that we should not expect a single solution that works for them as well as for “native control flow” handling Results and Options (short of general do notation, which doesn’t work well in Rust for a variety of reasons).
  • This is already reflected in how ? does not work for futures etc. and they instead get their own separate sugar async/await. While it’s true that async/await are experimental, that they even exist shows that we can’t expect the same sugar to apply to them.

But if there’s a way to support at least Option better (without unduly impacting Result), that would strengthen the analogy with non-function catch blocks.


#17

OK, that helps. I understand your point-of-view better now.

That said, I don’t quite understand why it seems so important that you see the Ok. What makes this particular coercion different in your mind from other coercions we already do on return? (e.g., deref coercions)


#18

So one thought I had when chatting with @withoutboats over IRC is that, if we want to support types beyond Result, we might change from catches E to catches R (where R is the raw return type).

So you might write something like:

fn foo() -> catches io::Result<()> {
    let x = read(input)?;
    let y = write(output)?;
}

Now if you introduce a catch block manually, it looks very similar:

let input = open_some_file();
let output = open_some_file();
let r = catches io::Result<()> {
    let x = read(input)?;
    let y = write(output)?;
};

Maybe here the catches keyword is not the best, though, and of course there’s the obvious downside that I must write the Result part. @withoutboats pointed out that there was a similar proposal at some point to do -> io::Result<()>? as the return type of the function. I like that less, but it’s in a similar vein.


#19

But I see this as also a benefit: after all, now the “external signature” is also clearly visible. That is, it seems a bit like declaring parameters as mut:

fn foo(mut x: T) { ... }

While the mut x appears in the fn header, it is not part of the external signature that concerns our clients. Same holds for the word catches.


#20

I think this really exacerbates @josh’s concern though. As @kornel points out, inside the function it returns T and the signature is -> T. I don’t know how to square this; I still think the type after -> being what return takes is a major advantage, but I would like to figure out a clean way to support Option.


#21

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.


#22

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.


#23

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…


#24

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.


#25

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.


#26

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.