Pre-RFC: Catching Functions

This is an alternative design to Ok wrapping, which I am very excited about. Similar ideas have been floated in the past. Each subheading can be separated:

The throw keyword

The throw keyword has the semantics:

throw $expr <=> return Err(From::from($expr))

This makes ? sugar for:

match expr {
    Ok(x) => x,
    Err(e) => throw e,
}

The throw keyword is added mainly for assisting in raising errors, replacing Err(e)? expressions.

This integrates well with designs in which your errors may need to be into()d, including error-chain (sometimes), failure (using Error) and Box<Error> from std.

Unlike return, throw is caught by catch blocks.

throw like ? works with all Try types (which should maybe renamed to Throw, but that’s a separate discussion). For example, in functions that return options, throw NoneError;

catch E

@nikomatsakis has complained about type ascription issues with catch blocks as they currently exist. It would be great to allow inserting the error type into the catch expression directly:

// All `?`s and throws will convert into io::Error
catch io::Error {
    function()?;
    throw expr;
}

-> T catch E

First, catch is fixed to have the semantics specified by the RFC - the final expression is wrapped in Ok, rather than being required to be a result.

We add a special return syntax -> T catch E, which is function that returns a Result<T, E>. This like sugar for wrapping the body in a catch block, except that it catches returns as well:

fn foo() -> i32 catch Error {
    if bar() {

        // Ok wraps explicit returns
        return 0;

   } else if baz() {

       // Err wraps throws
       throw MyError;

   }

   // ? works
   quux()?;

   // Ok wraps final return
   7
}

In functions where the Ok variant is (), you can just omit it, just like with functions that return ():

fn foo() catch io::Error {
    writeln!(io::stderr(), "An error occurred.")?;
}

This is all syntactic sugar, the functions have the return type Result<T, E>. Likely they should be normalized in rustdoc, etc.

Probably we can support other types that implement Throw using the syntax -> T catch E as ThrowType, for example, -> T catch NoneError as Option<T>. It’s unclear to me how useful this is though. Or we could also allow omitting the E in the as case, because it can be inferred from the throw type (its an assoc type), so -> T catch as Option<T>. Possibly this as is a bit subtle.

Relationship to exceptions

What you get when you combine all these features is something that is very much like throwing exceptions in syntax, but with several key differences that, in my opinion, make it superior. In general, it preserves the ergonomics of throwing exceptions, while significantly improving your ability to reason locally about the paths of control flow.

The first critical difference is that every function call that could “throw” and return early is explicitly annotated with ?. This allows users to see every possible point of error and early return in their function, avoiding the nonlocality and secret control flow problems that exceptions tend have.

More subtly, Result<T, E> reifies the concept of fallibility. Languages with exceptions have types for their failures (the exception types), but not a type for the possibility of failure. This forces you to handle exceptions using only the built in try/catch control flow constructs. In contrast, Rust allows you to call methods on Result, match over it normally, and just generally treat fallibility in itself as a value.

Both of these advantages are advantages we already have, but the point is that this proposal preserves these advantages while getting the nice ergonomics that have led most mainstream languages to primarily use exceptions for error handling.

Relationship to Ok-wrapping

I prefer this to just adding Ok wrapping, or possibly Ok wrapping (), because it becomes syntactically identified that this behavior is happening in the function declaration, rather than it being a special rule of the Result type or something similar.

24 Likes

I think it might be helpful to see what some real-world code would look like before and after these changes.

20 Likes

So, first of all, I do like the throw keyword here, as syntactic sugar for the very common pattern of returning an error. This seems simple, intuitive, and a good complement to ?. I've occasionally found myself writing Err(e)? to force a concise return, and I think throw would stand out more.

Second, it seems reasonable to have an explicit syntax for type ascription on catch blocks; I've run into issues of finding the right From instance as well. And catch E { ... } seems as good a syntax for that as any. I do wonder if it might potentially confuse people used to exception-based error handling with a kind of uncanny-valley similarity of syntax but not semantics, but I don't have any objections to the syntax.

However, in the third section, two major issues jump out at me. First:

Please no. This has all the same problems as Ok-wrapping in general, and tying it to catch does not make those better. The function does not return 0, it returns Ok(0), and seeing return Ok(0) has huge didactic value in making the error handling very clear. return 0 looks and feels like a type error in a function that returns a Result.

Could they be normalized in the code, too, to -> Result<T, E>, to make the actual type of the function clear?

This feels like a syntactic obfuscation for something already expressible clearly and concisely in existing syntax. Writing -> Result<T, E> handles type ascription issues by specifying E, so the catch isn't needed for that reason. Beyond that, this syntax feels completely unrelated to Ok-wrapping.

In the interests of trying to come up with an alternative rather than just arguing against something... I put some thought into why I don't mind throw or ? having automatic wrapping behavior, but really really don't want return to have that behavior. I've seen Ok-wrapping described elsewhere by multiple people as attempting to make the happy path stand out, and that I don't have a problem with. I think the problem is with tying that to return. If that were a different keyword and not return, I'd have absolutely no issue with automatic wrapping behavior. (I don't have a strong preference on what that keyword is, as long as it stands out and looks like a control-flow action the way return and throw do.)

20 Likes

Can you elaborate more on what problems you are referring to? I remember there being issues because we were trying to have it both ways -- basically to have return foo sometimes return a Result<T, E> and sometimes just return T, and the result was confusing. But if it's required to return T when you are in a catch block, that doesn't seem confusing. In other words, I don't mind "ok-wrapping in catch" if we are consistent with doing it across multiple contexts.

However, this does bring up one question I would like to drill into a bit more. to what extent is Result special?

@withoutboats, in your proposal, I think that you write something like this:

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

and this is shorthand for:

fn foo() -> Result<usize, io::Error> {
    catch io::Error {
    }
}

Indeed, this is the major change from @glaebhoerl's original -- and inspirational :slight_smile: -- RFC, which included rather a provision to make such functions generic over the "carrier". I think that genericity was probably too much, but it is perhaps interesting to contemplate if we can make the result generic over other types somehow (though I think that it is also ok to favor Result slightly, so long as ? and throw continue to work normally with other types).

But I'm not sure what that means for catch blocks. For example, today I can do:

let x: Option<u32> = do catch {
    let y = foo()?; // `foo()` returns `Option<T>`
    Some(f(y))
};

instead of foo().map(|y| f(y)).

Would you be able to work with Option inside of a catch block in your proposal? If so, then it seems like we need more than catch E (i.e., just specifying the error type) to solve the type inference issues.

7 Likes

It might make sense to replace throw and Ok-wrapping with some other way of short-circuiting a catch block.

First, this would reduce some the uncanny-valley effect of “this looks like exceptions, why doesn’t it behave like them?” I’m not sure what aspect of the “ergonomics of exceptions” we’re going for here, but we should be able to get it without confusing newcomers.

Second, while catch reduces the scope of ? (and throw), it does not provide a way to short-circuit with an Ok value, a la return Ok. There is (labeled-)break-with-value- perhaps we should try to leverage that somehow? Some syntax like return/break/throw that means “exit the current catch block” but which works for both success and failure.

Third, the -> T catch E notation evokes something like “the function body is being replaced with a catch block,” especially with the new catch E {} syntax. While the proposal suggests changing catch to do Ok-wrapping, given the problems above it might make more sense to do the reverse: change the -> T catch E notation not to do Ok-wrapping.

Overall, I like the idea of preserving the first-class value-ness of Results as much as possible. It not only makes it clear what style of error handling is going on, but also makes it possible to short-circuit/break-with-value for Ok values as well as Err values.

3 Likes

I think ok-wrapping is pretty important, or something that achieves equivalent ergonomic gains.

1 Like

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.

42 Likes

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.

7 Likes

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.

2 Likes

@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."

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.

5 Likes

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?

4 Likes

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.

16 Likes

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.

10 Likes

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

9 Likes

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.

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)

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.

7 Likes

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.

2 Likes

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.