Design `?` to work on named blocks

An idea that I didn’t see discussed in the RFC #2046 (label-break-value) is to have the ? operator do a break-with-value. Then you’d have the freedom to exit the outer function or any intermediary named block.

There’s no need for either of try or catch

fn foo() -> Result<T, E> {

    let r: Result<T, E> = 'a: {
        fallible()?.bar(); // exit fn
        fallible()'a?.bar(); // exit 'a
        ...
    }
    ...
}

But why only limit yourself to the innermost named block:

'outer: {
    'inner: {
        fallible(x)'outer?.bar();
    }
}

Coheres well with labelled blocks and loops. Example showing short-circuiting with a success value:

'a: {
    if (easy_condition) { break 'a 42; }
    ...
}

Early abort the fn:

'a: {
    if (bad_condition) { return; }
    ...
}
11 Likes

I’m starting to feel this would be a more rusty and wholesome solution.

It would be nice since it keeps all type inference hints at the usual places.

Note: I think at some point it was proposed that break 'fn could be used to directly refer to the function scope. That would work for this solution as well.

6 Likes

Some notes:

  • If we did this, then ? should by default short-circuit to the innermost labeled block; otherwise you’d get 'init all over the place which would add significant noise and negate gains in ergonomics.
    • I’m not sure that could be done backwards-compatibly tho as you have 'foo: loop { .. } already, so it would require edition breakage.
    • I suspect the edition breakage would not be extensive.
  • This does not use Ok-wrapping and I don’t think it could.

Other than that it is interesting.

1 Like

If you copy and paste the code somewhere, you have to update the labels, which doesn’t bode well for example code. And you’ll get shadowing warnings if the labels overlap with others in scope.

3 Likes

@steven099 Or you can argue the opposite is true: you’d avoid simple copy-paste errors. Named destinations can be safer as their names may carry intention and semantic meaning that can’t easily be expressed in plain block hierarchies.

1 Like

@Centril Yes, I first proposed ? to bubble up to the innermost named block but was unsure about what could be done about the current situation: label-break-value #2046 is accepted but not yet implemented or stable. Labelled loops (with values) are stable though. :unamused:

I don’t necessarily want a breaking edition charge if explicit labels aren’t that bad.

try and catch would require a breaking change, I suppose. But it’d be nicer without.


Anyhow, we already have break with value in the pipeline. So, it’d be virtuous to build upon that rather than adding a completely new and unrelated means of propagating values, I’d like to think.

1 Like

This looks cool, but I find having to come up with a name for every block problematic. Naming is hard, and I’m too lazy to make effort for every internal 2-3 lines of code. I’d probably write 'a on all of them, or at best repeat the name of the variable they’re for:

let frob = 'frob: {
    fallible()'frob?.bar()
}
3 Likes

I don’t think it would be bad to have some kind of default name like we have with T for a template type and 'a for a lifetime. This could replace the proposed try with 'try for example. (Or any other default name we end op choosing.)

1 Like

In the case of try and catch, any breakage is easily rust-fixable tho. I'm not so sure it is as readily fixable for 'foo: loop { .. }.

I'd say that having to specify the label in the common case as fallible()'init?.bar() is not an alternative to me. It is not very legible syntax either in comparison to try { .. }.

While I like the generality of your proposal, I'd also want to echo @kornel's sentiments on naming. I'd probably just go with let quux = 'try: { foo()'try?.bar()'try?.baz() }; most of the time.

I also don't think it is quite common to nest like this and want to break out to different scopes, so while the generality is nice, the loss of Ok-wrapping feels like a bigger hit than what is gained.

Your proposal is also orthogonal to try { .. } in the sense that it is possible to have both and try { .. } seems more ergonomic for most common cases.

1 Like

For the other, but breaking, alternative of:

'a: {
    fallible()'fn?.bar(); // exit fn, explicitly
    fallible()'a?.bar(); // exit 'a, explicitly
    fallible()?.bar(); // exit whatever's innermost
    ...
}

the only breaking change, if I’m correct, is the case of using ? from within a labelled loop:

'a: loop {
    fallible()?.bar();
    .... 
}

which already has a stable interpretation and implementation of exiting from the fn. This doesn’t apply to other labelled blocks though because they are not yet implemented or stable. So there exists no code like the following:

'a: {
    fallible()?.bar();
}

I don’t know how common it is to use ? from whitin labelled loops, so I cannot estimate the amount of breakage. Perhaps not that bad?

The rustfix would be to automatically add 'fn to ? within loops.

2 Likes

I would much rather the syntax for break-with-value (a relatively rare construct that already inspired some uneasiness about its effect on the legibility of control flow) and “break-with-error”/“break-with-special-case” (an extremely common construct in widespread use) remain completely separate.

3 Likes

Probably not that bad, yes.

Sure, that works; However, it is predicated on introducing 'fn.

Why expr? breaks innermost by default? There’s no breakage if it breaks function by default.

For simpler local try, allow block labels to shadow outer ones is sufficient, and I think its more rusty. We can teach that 'try: { .. } is the de-facto syntax for local try block.

3 Likes

Because if it does not break the innermost label by default, then you'll have to litter every ? with 'label? to get the same behavior as try { .. }. An example:

let result: Result<Foo, MyError> = 'try: {
    let x = alpha()'try?;
    let y = beta(x)'try?;
    let z = gamma(y)'try?;
    delta(z)
}

This version with 'try is more fair since people should not use one-letter names. But since it introduces significant noise, I think it will lead to the following instead:

let result: Result<Foo, MyError> = 'a: {
    let x = alpha()'a?;
    let y = beta(x)'a?;
    let z = gamma(y)'a?;
    delta(z)
}

This still has noise that is in the way and is not terribly readable wrt. intent.

Compare this to:

let result: Result<Foo, MyError> = try {
    let x = alpha()?;
    let y = beta(x)?;
    let z = gamma(y)?;
    delta(z)?
}

In this version, there is no added noise, and I think it helps the much more common case while also being clear with respect to intent.

3 Likes

One possible choice is to make ?op escapes the label 'try by default, and implicitly label every function block as 'try.

Yes, I also think this is too magical. And it definitely breaks code that labels loop with 'try.

This idea leads me here: can we make that try { .. } as a syntactic-sugar-like? With this idea, every functions have unnamed label that traps non-labeled ?op and the try block is the only way to use this unnamed label.

I’m still not sure if this can be generalized to labeled break and loops. Thoughts?

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