An alternative proposal to `try`/`catch`/`throw` error handling

An alternative proposal to try/catch/throw error handling

There is currently a lot of discussion going on about the future of Rust error handling. The general direction seems towards error handling that looks like it does in exception based languages.

Bias Warning: Personally, I’m opposed to such changes, and would like to build a counter-proposal.

First, some links to the current/previous discussions and posts:

A rough outline of the things that are in the pipeline or have been brought up in the past as possibilities would be:

  • try/catch blocks for catching and handling errors.
  • throw or fail for raising errors to some catching scope.
  • ? also raising towards some catching scope.
  • Auto-converting final expression results to Ok or Some.

My goals with this proposal are the following:

  • Users wishing to use these semantics can easily opt-in to them.
  • Users who don’t want these semantics can still use the control flow semantics in an explicit manner.
  • The additional semantics can be more powerful and feature-rich than language constructs, giving additional value to those that do want to use them.
  • Unfamiliar users are still given hints that additional semantics might be in play.

Enhancing Rust to allow the ecosystem to provide the functionality

My proposal is: Instead of introducing special syntax for when error handling is close to that in exception based language, we build up from existing Rust control flow. This would allow all custom semantics to be built using macros. Given the existance of break-with-value, this would simply amount to adding a special block label.

This has a couple of advantages:

  • The semantics can be experimented with and iterated on by the ecosystem.

  • If such functionality is indeed wanted without extra dependencies at some point once things have settled, they can be included in std.

  • It is much easier to make future adjustments and improvements to macros than it is to change language constructs. There is a massive difference between iterating core features, and iterating a library on crates.io, with the latter allowing a lot more freedom and flexibility.

  • Before the new semantics find wide adoption, there is an explicit opt-in with a dependency.

  • Even if the macros end up in std, the additional semantics are still being signalled by them being macro functionality. Rust developers are already trained to expect additional semantics from macros.

  • The provided semantics can be a lot richer than is possible with builtin language syntax in the current context of error propagation. For example: There can be auto-converting/non-converting alternatives, allowing one to skip result type hints when the conversions aren’t necessary. Note: See this section of the "throw expression" proposal on a possible exception for propagating through scopes where types don’t match and the jump destination is determined by types.

  • If some of the additional functionality turns out useful/wide spread enough to get it’s own syntax, it can still be done, but based on actual semantics of the usage in the wild. This is similar to how try! used to encapsulate a common use-case, and turned into ? once things had settled.

  • It is easy for new macros that are more special cased to be introduced along-side the existing ones, while still remaining consistent.

  • It allows a bigger picture of error handling control flow requirements to develop in the community before anything is made part of the core language.

But there would also be advantages to users who opt not to use this kind of semantics:

  • The additional functionality provided by Rust used to implement the macros is also available to other parts of the ecosystem.

  • They are useful by itself even when not used by macros. As they allow early-escape from scopes in many scenarios, and are not limited to the success/error paradigm due to the lack of implicit auto-conversion.

  • The new functionality required to implement the macros in the ecosystem is very limited, so the core language surface doesn’t increase by much.

Introducing a built-in 'escape block label

This is basically the only change that is required. There would be an implicit 'escape label at the function boundary, but developers can also put it on a block inside the function to limit where ? propagates to.

In essence, this would simply introduce an explicitly specifiable target block for ?.

The usefulness of this would be provided by combining it with the break-with-value feature.

An interesting note is that the trait-based exception handling RFC that introduced ? actually uses break-with-value to explain semantics.

This would also further minimize breakage, as label identifiers are a non-majority subset of used identifiers.

The label 'try has come up as a possibility in the past. This would also be a good possibility. A 'try label also wouldn’t suffer from too much exception association I believe, seeing how it looks different than the construct in exception based languages.

Exception-like syntax as macros on top.

The new label would allow implementing the exception like syntax as macros on top:

  • A catching block macro with auto-conversion would use an 'escape block that applies the Try conversion to its result on the inside. It could include ways to give type-hints (which are often necessary with always-on conversions) or do error transforms as part of it’s syntax. It could also provide a non-converting alternative. Due to it being a macro it can be a lot more flexible with those features.

  • A throwing macro can use break 'escape Err(value). Conversion semantics could be applied as well, or converting and non-converting alternatives provided. This function would be able to throw to both a catching block and the function boundary.

Bike-shedding

Fortunately, there is very little of that. Just the name of the 'escape block to reserve.

Examples of direct feature use

Limiting Error Propagation

let result: Result<_, MyError> = 'escape: {
    let item_a = calc_a()?;
    let item_b = calc_b()?;
    Ok(combine(item_a, item_b)?)
};

Optional operations in sequences

let final: Option<_> = 'escape: {
    let mut sum = 0;
    for item in items {
        sum += item.get_value()?;
    }
    Some(sum)
};

Searching for an item

let item = 'escape: {
    for item in items {
        let data = verify(item)?;
        if matches(data) {
            break 'escape Ok(data);
        }
    }
    Err(MyError::NotFound)
};

Examples of implementable macros

A catching block with final-result conversion

macro_rules! catch {
    ($($body:tt)*) => {
        'escape: { ::std::ops::Try::from_ok({ $($body)* }) }
    }
}

let result = catch! { a? + b? };

An error throwing macro with final-result conversion

macro_rules! throw {
    ($value:expr) => {
        break 'escape ::std::ops::Try::from_error($value)
    }
}

fn open(path: &Path) -> Result<File, Error> {
    match File::open(path) {
        Ok(file) => Ok(file),
        Err(io_err) => {
            log("IO error occured");
            throw!(io_error);
        },
    }
}

Finalising a block with a success value

macro_rules! final {
    ($value:expr) => {
        break 'escape ::std::ops::Try::from_ok($value)
    }
}

let value: Option<_> = catch! {
    if let Some(cached) = cache.get(&id) {
        final!(cached);
    }
    let new = calc();
    cache.insert(id, new);
    new
};

Other possibilities for macros

Note: The names aren’t final suggestions and merely serve as illustration. The list is also likely not exhaustive.

// catch without conversion
catch_plain! { ... }

// catch with preset Result<_, _> hint
fallible! { ... }

// catch with preset Option<_> hint
optional! { ... }

// catch with error as converted final result
attempt! { ... }

// catch with preset Result<_, Error>
catch_error!(Error { ... })

// catch with preset Result<Success, Failure>
catch_result!(Success or Failure { ... })

// catch with preset Option<Value>
catch_option!(Value { ... })

// throwing without conversion
catch_plain!(...)

// providing a final value without conversion
final_plain!(...)

// optionally throwing an Option<Error>
throw_if_some!(...)

// Finalizing an optional value
final_if_some!(...)

// Inline mapping of an error
handle!(do { ... } match err { ... })

// Inline mapping and hinting of an error
handle!(do { ... } match err: Error { ... })

// Special case for failure crate removing need to type hint errors
catch_failure! { ... }

Summary

I believe adding 'escape functionality to blocks would allow us to pause on our way to try/catch/throw syntax, and gain experience before things are finalized as core language syntax.

With this post I’m hoping to find out if there would be enough support for turning this into an RFC and considering it an alternative (for the immediate future, at least).

5 Likes

We already discussed this a bit on IRC; But... Let me talk a bit about this from a purely technical perspective of expressive power why this statement is factually incorrect or at least misleading to say "a lot richer".

Let's start by calling label-break-value + try {..} mechanisms with the special label 'try as Proposal A and the main proposal in this thread Proposal B.

We can technically include 'try in A because try becomes a reserved keyword. This magical label 'try refers to the innermost try {..} or the enclosing closure / function if there's no try {..}.

There's only one difference in expressive power assuming try {..} blocks Ok-wrap. Even so, ok!(expr) can easily substitute for Ok(expr) at the tail expanding to break 'try Ok(expr).

If they do not Ok-wrap, then the sum total of proposal A is more powerful than proposal B because of the type based reasoning only the compiler could do (but doesn't have to, if we don't want it to..) whereas macros only work on token trees.

The 'try label let's you target it with break 'try expr which can be wrapped in macros to implement the macro based mechanisms you discuss. Most of these mechanisms are also some form of succeed!(expr) or fail!(expr) + extra behavior on top so given fail expr, you could use it as a primitive for macros.

Ostensibly, one difference where try { .. } is more constrained is that it requires the resulting expression to implement Try;


Now that I've gotten the more technical aspects out of the way; let me discuss some more subjective points.

Getting back to the Try trait from my subjective POV, I think most cases where you want to bubble up have the Try trait semantics in some form (maybe more general with @scottmcm's work..)

My preference is that this should be opt-out rather than opt-in. I do think that try { .. } should be idiomatic.

I find unfamiliarity with try { .. } and throw expr to be unlikely despite differences in operational semantics and typing rules.

I don't think that is an accurate description; 'escape constitutes a magical label that adds to the language. I also think that the form 'escape: { .. } is much too subtle for such important control flow and inconsistent with the language at large for the moment; I understand that it is meant to be hidden under a macro; but at this point, you are just eventually reinventing try { .. }.

I also think that the rules for try { .. } are pretty well discussed by now; the remaining point of contention seems to be Ok-wrapping.

2 Likes

@Centril So, the difference of opinion seems to be that you'd like to implement try blocks and built on top of those.

I'd respond that the core point of the issue is to allow delying of the try/catch/throw strategy, including try and allow experimentation and consideration of the error handling strategy as a whole.

Ostensibly, one difference where try { .. } is more constrained is that it requires the resulting expression to implement Try;

I believe that is quite a big constraint. Experimentation would be more open if the control flow option were available in many scenarios, like custom composable DLSs not needing to resort to closures and return to implement early propagation.

My preference is that this should be opt-out rather than opt-in. I do think that try { .. } should be idiomatic.

As you can imagine, I'm against that :slight_smile: I believe if try blocks were to be added, they should have a limited use. With combinator methods like map or and_then always being first-choice over try blocks unless there are good reasons.

I don’t think that is an accurate description; 'escape constitutes a magical label that adds to the language. I also think that the form 'escape: { .. } is much too subtle for such important control flow and inconsistent with the language at large for the moment; I understand that it is meant to be hidden under a macro; but at this point, you are just eventually reinventing try { .. }.

Yes, that is the point :slight_smile: Instead of adding exception like error handling piece by piece we give the language the ability to express the semantics as macros,

I also think that the rules for try { .. } are pretty well discussed by now; the remaining point of contention seems to be Ok-wrapping.

And this is the kind of problem I'd like to avoid. Either option (conversion or not) could theoretically be the right one. But in difference to ? which had try!, we have no way to gain experience with these semantics on a wider scale. Building the whole thing up with macros to allow the semantics to settle would solve that, and also allow to see what semantics have actual need.

If it turns out there are multiple use-cases that can't go into a single construct, it might even be preferrable to end up with multiple macros in std in the long run.

3 Likes

I don't think that's any more true for try/? than it is for async/await.

Does async/await have a combinator option? I assumed that would have to transform the function into a state machine, which I find to be a different matter than try.

1 Like

Async started out with only a combinator option- all those methods on Future.

The reason try is different is that it transforms the block into a Result, which doesn’t need any associated code the way a T: Future does.

1 Like

Absolutely, that's how it's written on stable today:

Then I'd probably use that unless there's a good reason to go imperative.

I find the fixed pipeline of combinatorial APIs can offer advantages for readability compared to imperative versions. Though I do think that's more true for dealing with Results than async probably.

I'll certainly keep using things like foo().and_then(bar).with_context("baz") quite a lot.

2 Likes

It seems that I overstated this constraint;

To correct my mistake, I think that try { .. } without Ok-wrapping in proposal A actually would be strictly more powerful than the labeled version because the Try constraint on a try { .. } actually flows from Ok-wrapping in the tail and that without it, requiring Try is more or less a santity-check for the user that isn't technically necessary.

So the constraint is only technically required iff we have Ok-wrapping.

In any case, if we have Ok-wrapping, then it is possible to modify the semantics of try { .. } after the fact to make use of some sort of 'escape mechanism as a primitive; but I doubt the need will arise.

I've come to terms and accepted that Rust is neither a functional nor imperative language. Therefore, I think ? and try { .. } should be considered idiomatic; And you know what, they are kinda like idiom brackets for an with-imperative-features language where you have try { a? + b? } instead of [| a + b |]. So the belief that foo().and_then(bar).with_context("baz") is more functional and that try { a? + b? } somehow imperative is mistaken. With my Haskeller's hat on, I think that try { a? + b? c? } brings me closer to applicative programming for error handling; and I think that is great; it is even more general than using the heavyweight syntax of .and_then(..).

We do have nightly; that will allow us to at least test that the semantics we implement works well on a wider number of users than I believe an experimentation-with-crate approach will yield.

I think that would be a bad end result; There's certain value to not having a proliferation of constructs that all users are expected to learn; Having try { .. } in the language makes sure that we "speak the same language".

For throw syntax, maybe we don’t need a special syntax for it. I found that Err(error)? is really convenient for me and already spamming it a lot.

The terminology doesn't change the fact that I'll often prefer chains of map_err, with_context, map, and_then, ok_or_else and such over nested blocks.

Either way, the block vs. method distinction exists in both solutions, so I'm not sure it matters in the context of this proposal.

I'm not sure:

  • Experimenters on nightly are a different group than the people experimenting with a stable crate they get to keep.
  • You can iterate a lot more quickly on crates.io

More bluntly: Experimentation in core gave us std::error::Error, experimentation on crates.io gave us failure. Imagine if errors had been fixed syntax.

I think that would be a bad end result; There’s certain value to not having a proliferation of constructs that all users are expected to learn; Having try { .. } in the language makes sure that we “speak the same language”.

But not without experience what those constructs should be.

We can still end up with try blocks, we just don't need to hurry for now.

Edit: I would also argue that blocks, labels and breaks are a language we already speak. And we have the chance to figure out the new words with the ones we already have, which is macros on top of extensions of existing syntax.

2 Likes

You will still be able to do so after.. by no means must you adopt my style of writing :wink:

The benefit of try { .. } + ? is that other people who prefer the more idiom-bracket-style approach or "rethrow" model will be able to have their needs satisfied as well. A language, be it formal or natural must accomodate many people's expressive needs.

It's true; however, I think it is more likely that a crate based approach will reach fewer people with the main experiment than nightly will.

That seems unlikely; I don't think the analogy is apt. I'm not saying we should rush to stabilization of try { .. } or anything, but the semantics have been discussed for at least 4 years now; it seems to me we have some idea of what we're doing...

I agree; let's not hurry! I think sufficient experience can be gained from nightly -- it is not as if we are starting from scratch; the design space is not that wide open I believe.

Labeled breaks to non-loops are not even implemented yet, and using labels on loops is not something I've seen in code terribly widely.

We can put the macros in std in nightly :slight_smile:

I'm still not sure if it will end up with auto conversion or not. That doesn't seem like the semantics have fully been figured out.

It's not just about this specific design space. It's about the whole picture of error handling, of what should be idiomatic, what Rusts primary error handling philosophy is going to be and so on. That does seem like a big space to me. try is just one piece of the puzzle.

There are two really good reasons: loops are really painful to read and write in combinator form, and futures that include self-borrows are impossible to write safely in combinator form.

The same reasons apply to Result and ?. You can trivially write ? in a loop, and you can trivially keep borrows live across ?s. Doing so with combinators may not even be possible, I've never seen anyone even try. The only reason it's tolerated with Futures is that there's no alternative (yet).

Edit: And there's a third reason that is more subjective: using a lot of tiny combinator-style functions makes debugging harder, makes debug builds massively slower, and makes builds take longer. I really wish we had a better solution for this in general, but this style just isn't as well-supported in a lot of ways.

2 Likes

Then you are back to blessing one experimental model over the others. You could just experiment with language features directly instead.

(Aside: It seems macros became subject to stability all of a sudden; was this change recent?)

Well; figured out and figured out... People want different things; but the various designs are fairly fleshed out save for the exact details of a Try trait, but that applies to ? as well.

For Result, the possible choices in semantics clear enough to me. So I'd say it is less an issue of what we can do and more an issue of what we should do.

Well; we do have ? and an RFC on try { .. } was accepted recently. This should be indicative of what we think is idiomatic, at least to me.

If you're asking about whether clippy should lint by default against .and_then(..) then it becomes more tricky and we'll see how contentious that is when we come to it.

Sure, that is a good reason. I even have some examples in the proposal using 'escape for that. But I like to stay combinatorial in iterator chains for example, or if it's simple one-after-the-other small pieces of code with error modification in-between.

Then you are back to blessing one experimental model over the others. You could just experiment with language features directly instead.

Well, in nightly you could easily support multiple macros for different things, not just a try version.

The advantage of doing it on crates.io is that it works on stable, and will keep working.

@Centril

Well; we do have ? and an RFC on try { .. } was accepted recently. This should be indicative of what we think is idiomatic, at least to me.

Personally I feel what is idiomatic should come from community experience, not by try to pre-plan things.

One general note: I think these discussions might be better had when I submit the RFC, otherwise we're just going to repeat ourselves.

I'm wondering if anyone has any additional use-cases 'escape might solve, or if there's any technical problems.

Fair enough; I'm just making a prediction, but we should certainly let community experience decide this and lint in clippy after some time in stable. This will take considerable time to reach the linting stage.

So it would be great if you noted the technical aspects I brought up wrt. differences in expressive power (and not overstate the case ^,-).

I'm not sure I agree with them yet :slight_smile: But I'll certainly try before it becomes an RFC.

Interesting; Specifically what parts are wrong if so?

Because type-based jump targeting, e.g. full blown real life exceptions, I feel is way outside of Rust’s error handling philosophy. To argue that try is more powerful because try! can’t do that seems out of scope to me. Additionally, same as with 'escape if Rust were to gain a general not-error-related facility to do this, macros once again can be used to implement it.

“Finding an upscope return position by type instead of current control flow” is not a feature that has to be tied to try. Given this, and that I’m not aware any of that has ever been planned, or is somewhere planned for the future, I don’t think we can attribute that power to either macros or syntax yet.