Design `?` to work on named blocks

The advantage would be that every jump that isn't out of the function would be visible at jump-site. And bailout macros could take an optional label at start to allow specifying what they're bailing out of.

I don't particularly like to repeat myself in code. So to me, it isn't an advantage at all. It is just repetitive noise.

You could still allow the explicitly labeled version which would still allow macros to take an optional label.

I think it is entirely feasible technically to permit try { alpha()?.beta()? } and 'lab: { alpha()'lab?.beta() }, but I suspect the usefulness will be limited once you have try { .. }.

There's also the question of whether the label should be prefix: foo()'lab?.bar or postfix: foo()?'lab.bar. Neither option seems particularly obvious to me.

Yeah, I'm more the kind that tries to label every break or continue unless it's a really small fn.

A small comparison

Let’s compare the three main proposals discussed in this thread. The exception style syntax is simple but less capable. It also introduces a new concept (try or catch blocks). The blocky style variants reuse existing Rust features instead and give you more control over where to bubble values, and can bubble up both failures and success values. There’s a short and implicit variant, others might value the explicitness of the other variant.

Some might find the exceptional style familiar, others might find it confusing because it’s not exactly the same as in other languages. The blocky variants do not use exceptional terminology of either try or throw.

try {}

try {
    fallible()?;
}
edition breaking change yes
automatic rustfix rename instances to r#try
known conflicts try! macro in code/docs/forums/blogs…
syntax exceptional style
propagate error to innermost (...)?
propagate error to outer block N/A
propagate error to function N/A
short-circuit success to innermost N/A
short-circuit success to outer block N/A



Main benefits:

  • syntax is short
  • automatic Ok-wrapping


explicit blocky ? – the main contender

'a {
    fallible()'a?;
}
edition breaking change no
automatic rustfix
known conflicts
syntax reuse blocks
propagate error to innermost (...)'a?
propagate error to outer block (...)'b?
propagate error to function (...)?
short-circuit success to innermost break 'a 42;
short-circuit success to outer block break 'b 42;



Main benefits:

  • No breaking change!
  • The most common case of propagating to fn, is short: (EXPR)?
  • Less implicitness and hidden conversions at block boundaries
  • Coheres well with labelled break, continue and loop


implicit blocky ?

'a {
    fallible()?;
}
edition breaking change yes
automatic rustfix add 'fn to ? only in labelled loops
known conflicts
syntax reuse blocks
propagate error to innermost (...)?
propagate error to outer block (...)'b?
propagate error to function (...)'fn?
short-circuit success to innermost break 'a 42;
short-circuit success to outer block break 'b 42;



Main benefits compared to the exceptional style:

  • Just as short: (EXPR)?
  • More capable
  • Less implicitness and hidden conversions at block boundaries
5 Likes

To be honest, it doesn't look that unreadable to me. And the intent is certainly clear.

The more common case in practice is probably to exit the function and then you just write: alpha()?;

2 Likes

@repax I think your review of try { .. } misses Ok-wrapping as a main benefit.

Of course exiting to the function will be least noisy and common, but once you already have introduced try {..} into your code, then it seems to me that the most common case there is to propagate any errors to the try { .. } block. Therefore, for the purposes of a localized propagation, try { .. } seems more optimal than repeating 'try?.

2 Likes

@Centril Thanks, I’ll add that to the list.

We should probably come up with a set of criteria for determining the best possible design.

Personally, I’m still not convinced that try{} blocks (whatever the syntax) really pull their weight in the first place. So when I see the assumption that “The most common case” is “propagating to fn”, that sounds like an argument to not bother with any variation of try{} block in the first place, not an argument for label syntax over keyword syntax.

5 Likes

I would add as a negative to try that there’s a crate named like that.

As for normal blocks not auto-converting their last result, I’d see that as a plus. The less hidden type conversion there are the better.

4 Likes

This design really fits in with return, break, and continue. They all behave with the obvious scope on default and you can change the scope using an explicit label. They also all do some kind of early jump. I feel like this would make labels feel less a feature that was added in as an afterthought and more as a real part of the language.

Whether the obvious scope is the function or the innermost named scope does not really change much in most excisting cases.

I agree with phaylon that Ok wrapping is not something i would want in either proposal because that makes the precise meaning of the code a lot less clear. I also don’t see a good way of doing this in general for implementers of the Try trait and I don’t want Result the be a special exception.

5 Likes

I’m actually surprised to hear that try{} blocks doing Ok-wrapping is controversial. That makes me even more skeptical that try{} blocks (with any syntax) can pull their weight as a feature.


I’m starting to think the only way we can make any real progress on the web of interrelated proposals for error-handling sugars is by systematic surveys of crates.io for code that might benefit from one flavor of sugar or another. Purely theoretical discussion doesn’t appear to be achieving much convergence of opinion so far. Unfortunately I don’t have the free time and energy to attempt any surveys like that myself.

Just wanted to say I think the ideas here (making ? related to break) are pretty interesting.

However take one issue - which actually applies to the original RFC 2046 - which is that labeling a block 'a { } suddenly has totally different semantics from an unlabeled block { }. It just becomes especially apparent when you start interrupting the meaning of ?, which you’re more likely to have been already using than break in any given chunk of code.

Why not something slightly more explicit, like a new once { } “loop” construct? This would change RFC2046 ('a: once { }) but I think it also somewhat improves the viability of the idea being discussed in this thread.

2 Likes

This looks extremely ugly and noisy. Especially if we have to keep writing labels before every occurrence of ?. The whole point of ? is painless and noise-less error propagation – it makes error handling very ergonomic and palatable, which is a Good Thing™, because un-ergonomic error handling tends to make people ignore errors altogether, and just drop and/or unwrap most Results.

Honestly, I never felt the need even for try/do { … } catch { … } blocks, despite the fact that I’m quite pedantic when it comes to error handling. I always unconditionally try to handle/propagate errors, and even at this level of rigour, the current behavior of ? returning from the innermost function sufficed in essentially every other case. And while I can imagine how catch might be helpful in a bigger function, my opinion is that those big functions should probably just be refactored and split up, instead of being supported with special syntax.

This is true to an even greater extent when it comes to this proposed syntax. In addition, the ugliness of labeled ? makes it hard and distracting to read and causes more confusion than it provides clarity.

2 Likes

@kainino @H2CO3 I understand your concerns, and this is not a darling of mine, but it is interesting and worth considering imo.

The foremost argument is that we already have break-with-value from labelled blocks so this is actually no more than an extension/elaboration of the same space of ideas. We have an opportunity to reuse the syntactic and semantic commonalities of labels (e.g. 'a) and blocks with values to create a cohesive set of tightly integrated features that work well together - rather than a completely new construct (the try block) with its entirety own semantics.

The explicit variant proposed here is of special interest. It is an absolutely non-breaking design (if we want to be able to capture failures within a fn) - no other proposition has this. It also does not make use of labels if the error value should exit the function.

Okay, so given that we now can make a small addition to the language that fits well with what we already have (break, continue, blocks and loops with values) do we actually want a way to capture errors in a fn?

I do think there was a decision by the core team to go in this direction!

I agree that often the right thing to do is to break up a function, but sometimes you do want to be able to raise an error and capture it in the same function. Especially if the error is closely linked to its solution. Having the ability to short-circuit a subset of the work can make the code cleaner and easier to understand. If you then can name the exact block / level to escape from, by naming its label, then that may bring the same clarity as with break-with-value. The ? operator would, in fact, just be a conditional break-with-value.

Error capturing also helps you translate some set of errors into another set of errors. You capture the errors of your callees and match them before letting them propagate further up the call tree.

At a higher level of analysis, why use a feature like this? I think that one answer is that it helps you to maintain certain invariants. You can call any number of functions that may fail, yet you promise not to fail yourself. So you do all the fallible stuff confined within this sort of block.

In exception safe C++ there is the idea and common pattern of the critical line of a function. Above the line anything may happen, exceptions may arise. Below the line nothing can fail. Whatever you do above the line can be rolled back, in case of a failure. Keeping these lines of separation really helps a lot in writing and maintaining robust code. You never end up with a partially correct state.

3 Likes

I don't think "we already have labeled control flow expressions" is a strong argument in favor of "let's add even more labeled control flow expressions". Sure, it would fit in that lineage, but not fitting with break/continue would be the least significant problem of this construct.

So would be not adding anything like it at all.

Certainly – and there exist several solutions for that already. if let, match, Result::unwrap_or{_else}, etc.

The ? shorthand is specifically useful for painlessly propagating errors across functions, so that we gain error handling without having to worry about it, i.e. we get to not just have to ignore the errors if we are willing to type/read one extra character.

However, …

  1. if one wants to handle errors inside a function explicitly, that's a much less common case, so I don't think it warrants the addition of a completely new syntactic and semantic construct, when existing language and library features (see above) can satisfy these requirements, and
  2. if one is dealing with errors explicitly, one might as well get away with using an explicit match/Result method call.

By the way, "It has a use case" (or "I like it") is never enough of a reason to add a feature to a language, that only leads to feature creep. There are probably many hundreds or thousands of different features that could be useful in some cases. By that reasoning, should we add all of these to Rust (or any other language)? Naturally, no. A feature should probably have an order of magnitude more significant advantages than potential disadvantages in order to justify the increase in complexity (which in itself inevitably incurs compiler bugs and errors arising out of misunderstandings).

This is exactly the kind of code that begs for being extracted into its own function.

Same as above. In theory it might sound neat to be able to use ? syntax within a block, but again, I've been writing C and C++ for 8 years, and Rust for the past 3 years and I have literally never run into a case where this would have been an ergonomics problem.

1 Like

As an example of code I think works better with a catch-like construct (just to pick a way to refer to it), I present an excerpt from what I’ve been working on lately. There are many problems with this code, and it’s been burninated since for a completely different design, but for the algorithm at hand I think the catch-like construct makes things clearer.

"{" => {
    let mut tokens = vec![];
    let mut depth = 1_u32;
    {
        let mut parse_interpolated = || {
            loop {
                let (i, o) = token(rest, pool)?;
                rest = i;
                match o {
                    Token { kind: Kind::Symbol, source: "}", .. } => {
                        depth -= 1;
                        if depth == 0 {
                            break;
                        }
                    },
                    Token { kind: Kind::Symbol, source: "{", .. } => depth += 1,
                    _ => {},
                }
                tokens.push(o);
            }
            Ok((rest, ()))
        };
        let res: IResult<Cursor<'i>, ()> = parse_interpolated();
        if res.is_err() {
            warn!("Unterminated String Interpolation");
        }
    }


    fragments.push(StringFragment::Interpolated(tokens));
}

The key note here is that tokens and depth from the outer scope are mutated from the inner catch construct (here a immediately-executed closure, parse_interpolated). Similarly, rest, which isn’t even defined in this excerpt of the code, needs to continue to be updated in order for everything to be in the right state for continuing once the loop has terminated.

Another key point is that if an error occurs in this section, I want to log it, but I don’t want to stop progressing. I want to keep going so that the user of this function can get their result out. The current design completely sidesteps this issue, and is definitely better than the annoying 80 line function, but this contained-error with environment capture is the exact use-case for the catch-construct that can’t be easily extracted to its own function.

The code’s using nom, if you’d like to show how this design could be better implemented without a catch-like construct.

I’m not sure I completely understand where exactly you would use the catch here – instead of the creation and the call of the closure?

If so, then I don’t see how it would be of significant advantage. As I understand, the rewritten code would look like:

"{" => {
    let mut tokens = vec![];
    let mut depth = 1_u32;
    {
        let res: IResult<Cursor<'i>, ()> = catch {
            loop {
                let (i, o) = token(rest, pool)?;
                rest = i;
                match o {
                    Token { kind: Kind::Symbol, source: "}", .. } => {
                        depth -= 1;
                        if depth == 0 {
                            break;
                        }
                    },
                    Token { kind: Kind::Symbol, source: "{", .. } => depth += 1,
                    _ => {},
                }
                tokens.push(o);
            }
            (rest, ())
        };
        if res.is_err() {
            warn!("Unterminated String Interpolation");
        }
    }

    fragments.push(StringFragment::Interpolated(tokens));
}

which doesn’t seem a huge improvement to me. With this example, the problem is not that error propagation is hard (I personally think it’s just fine with the FnMut closure you created), but that indeed, the code is heavily context-dependent.

This doesn’t mean that it’s hard to refactor it into its own function — you just did that (pretty well IMO). It rather means that it’s hard to move it out of its context. And that’s something catch doesn’t seem to help with, either.

If you were to refactor this into a method or a free function, you would need explicit parameters to be passed to the function in either case, instead of the necessary context variables being captured implicitly by the closure, or the catch block just having regular lexical access to them.

@H2CO3 I’m sorry but I will not be able to convince you of anything, or have a discussion with the goal of finding out how this proposition stacks up against the current experimental do catch design, because you seem to already have made up your mind. Thus far, you have taken every chance to shoot down any point I’ve made.

A small clarification

We currently have an experimental implementation of do catch blocks, tracking issue #31436. Enable with the following crate attribute: #![feature(catch_expr)]. Read the two RFCs #243 and #1859 for more background.

The main reason for this thread has been to discuss and evaluate an alternative and simplified design, all under the presupposition that there is a strong interest within the Rust core team to make an advancement in the area of in-function error capturing for the ? operator.

Simplified design?

In its essence, the whole proposal can be described as just one change to the ? operator: that it can take an optional label.

  • without the label: (EXPR)? the function is the exit point.
  • with the label: (EXPR)'a? the block named 'a is the exit point, implemented as break 'a error;.

Goals

Is there any merit to this alternative design? Is it a good idea to reuse the already accepted (and for loops, already stable) feature of break-with-value as a means of propagating errors to specific points within the fn?

6 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.