[Pre-RFC] Breaking out from blocks [already exists; but stalled]

Background: thought of this while reading yaah's post on try blocks, then tweeted about it. Further background: have wished for something like it several times while writing Rust, but it wasn't defined enough until now.

Proposal: introduce a keyword or mechanism (special-cased macro, whatever) to "early exit" at the block level.

This is in symmetry to:

  • return at the function level
  • break at the loop level

Some prelims:

  1. I have used the imaginary keyword breakout for this. This is not intended for serious consideration, don't bikeshed it :wink:
  2. The label syntax is also probably bikesheddable but I'd like to focus discussion on the concept rather than syntax at first.

Trivial example:

let custom = 'up: {
    let c = get_config();
    if c.all_defaults() { breakout 'up None }
    // more processing
    if c.invalid_condition() { breakout 'up None }
    // even more
    Some(c)
};

This could be written today as:

let config = {
    let c = get_raw_config();
    if c.all_defaults() {
        None
    } else {
        // more processing
        if c.invalid_condition() {
            None
        } else {
            // even more
            Some(c)
        }
    }
};

or even:

let custom = loop {
    let c = get_config();
    if c.all_defaults() { break None; }
    // more processing
    if c.invalid_condition() { break None; }
    // even more
    break Some(c);
};

So at first glance, this is a generalisation of the pattern when you don't actually want a loop, saving some syntax / making it more clear. However, notice I used a label on the breakout example and not on the loop example: that's because all blocks can be broken out of:

if let Magical(child) = candidate 'schooling: {
    if child.is_squib() { breakout 'schooling }
    child.parents().inform();

    let letter = Hogwarts::introduce(magician);
    Hogwarts::owlery().send(letter)?;

    if child.parents.request_visit {
        let prof = Hogwarts::staff().travel_to(child.residence);
        if child.parents.refuse_offer {
            prof.obliviate(child);
            prof.obliviate(child.parents);
            breakout 'schooling;
        }
    }

    child.go_to::<Alley<Diagon>>().shop(letter.supplies_list())?;
}

This in turns desugars to:

if let Magical(child) = candidate {
    if !child.is_squib() {
        child.parents().inform();

        let letter = Hogwarts::introduce(magician);
        Hogwarts::owlery().send(letter)?;

        if child.parents.request_visit {
            let prof = Hogwarts::staff().travel_to(child.residence);
            if child.parents.refuse_offer {
                prof.obliviate(child);
                prof.obliviate(child.parents);
            } else {
                child.go_to::<Alley<Diagon>>().shop(letter.supplies_list())?;
            }
        } else {
            child.go_to::<Alley<Diagon>>().shop(letter.supplies_list())?;
        }
    }
}

Alternatively, this could be broken into a function, and early return used, but that has implications for variable scope and type inference etc.

Desugaring to clean Rust gets even more complex with multiple labelled "bare" blocks, but I'll leave that as an exercise to the reader and talk about more block types:

Unsafe: pretty much identical as bare blocks above, except the innards are unsafe.

Async: this is, I think, a no-go. Happy to be wrong, though. If wrong, it would look like:

async 'conf: {
    let config = match fs::read_to_string().await {
        Err(e) => { eprintln!("no config: {:?}", e); breakout 'conf; }
        Ok(c) => c
    };
    let config: Config = config.parse()?;
    // etc
}

Try: this is interesting, especially in the context of debating throw/raise/yeet keyword syntax, as:

try {
    yeet some_error;
}

would become sugar for:

try {
    breakout Err(some_error);
}

and this proposal would also allow early-ok-exit:

try 'trying: {
    // stuff
    if good { breakout 'trying Ok(success); }
    // more stuff
    breakout Err(fail);
    // etc
}

which with only try-blocks, would desugar to:

try {
    // stuff
    if good {
        success
    } else {
        // more stuff
        yeet fail;
        // etc
    }
}

Edit: this already exists, but is stalled: https://github.com/rust-lang/rust/issues/48594

Afaik the async interaction hasn't been explored, though.

1 Like

In progress!

4 Likes

I'd like to bikeshed that return should be used instead of break, since we are expressing early return from a block, and in most other languages break is mostly associated with a for/while loop, not arbitrary block.

Most C-derived languages also allow break in a switch statement which isn't a loop.

1 Like

You are right but that usage is quite niche.

To me a more substantial point in favor of return is the consideration of the contract programming motivation I brought up lastly. Allowing bare return from the function inside of the block passes over the postcondition implicitly, what we could prohibit by shadowing. Returning inside of the block could then be enabled by return 'fn or say return 'loophole, which violates the contract again, but at least in an explicit fashion. A control structure for contract programming may then be provided by a simple macro. I'm not saying it has to be implemented this way, but we would support it in case someone wants to have it quickly without depending on a crate.

A similar argument applies to the error handling after such a block, which may be passed over implicitly in case the types coincide.

Since the label would become redundant in the base case, we would have to decide anew how to introduce the block. I would be content with 'block: {}, but that's not to everyones taste.

That argument can be trivially flipped 180°: Most PLs associate return exclusively with exiting a function-like construct. Furthermore, break is associated with exiting local control flow constructs and thus it makes more sense to use break, simply for reasons of similarity and Principle of Least Surprise.

10 Likes

I seem to me it's the opposite. In any language I know, return is never used to break any kind of block, except functions or closures.

While break can be used to break various kinds of blocks (for, while, switch,...), even labeled blocks in Java, JavaScript and Kotlin at least.

1 Like

That link 404s. It should be https://yaah.dev/try-blocks.

To be consistent with labelled loops, the label should go before the whole expression, not the opening brace:

'schooling: if let Magical(child) = candidate {

The same applies to the async and try examples.

The RFC could be extended to break out of any expression, not just blocks, example:

if 'cond: a || b || foo(c || breakout 'cond false) {...}

Which is equivalent to

if 'cond: {
    a || b || foo(c || breakout 'cond false)
}
{...}

That's pretty useless in most cases, but it might simplify the grammar and make the feature more consistent.

EDIT: That was a bad idea. If 'label: E1 || E2 was equivalent to 'label: (E1 || E2), then 'label: {...} || E2 would be equivalent to 'label: ({...} || E2), which is confusing.

1 Like

You can also write it as

let custom = (|| {
    let c = get_config();
    if c.all_defaults() { return None; }
    // more processing
    if c.invalid_condition() { return None; }
    Some(c)
})();

You can place it into a macro to make the code easier to read:

macro_rules! block {
    ($($t:tt)*) => {
        (|| {$($t)*})();
    }
}

Now consider my example but with a if foo { return bar; } in the middle. IIFEs are not the answer.

1 Like

I'm not sure I understand. What should foo and bar represent? Or is that a return from the calling function? The latter is a problem, yes.

Do we need syntax for this beyond Err(some_error)? as in current use? I can understand the appeal of a keyword, but I expect the general principles behind moving from box a to Box::new(a) also apply here.