Pre-RFC: Break with value in for/while loops

It is currently allowed to break from a loop { ... } with a value, and that value is the value of the loop expression.

let mut i = 0;
let x = loop {
    if foo() == 3 {
        break 4;
    } else if {i += 1; i == 10} {
        break 5;
    }
};

I propose that for and while loops should also support break expr. The obvious question is then what to do when the loop finishes but was not broken out of. I would recommend that we use Option<T> for these loops and have the resulting value be None if the loop finishes normally.

This also has an additional benefit that we can then have the python for ... else construct without introducing new syntax. It actually goes above and beyond that because we have a nice place to do for ... then (the opposite of for ... else)

match for i in 0..10 {
    if foo(i) == 4 {
        break;
    }
} {
    Some(_) => { /* else */ },
    None => { /* then */ }
}
2 Likes

I think that would require Option to be tied to the language as a lang item. Let's not bloat the language willy-nilly. edit: not a good reason.

For and while loops with return values have been proposed before:

We considered this in the past and decided not to do this.

There are a few different designs:

The first is to add a else block, which runs if the loop completes without breaking. This is usually regarded as too confusing. Python has a similar feature and there's been polling showing that most users don't know what it means.

The second is to make them evaluate to Option, as you've proposed here. This means that the type that for and while evaluate to will depend on whether their bodies contain a break with value expression (currently they always evaluate to ()).

I don't think this functionality is worth having to explain to users the nuance of how it works. It's fine to just use a mutable variable initialized to None instead, or use a loop loop, with value break depending on your use case.

Option is already hard coded into the language by the desugaring of for loop syntax. This might not be implemented by a lang item, but if that's the case its because its implemented a worse way (a hardcoded path). In general, turning fundamental std primitives into lang items is not a good reason not to implement a feature.

7 Likes

Thanks for the link. I tried to search for previous suggestions like this but didn't find any. Probably because I didn't look back far enough.

What do you mean by a lang item? A type that always is needed to compile Rust?

I don't think that there needs to be as much nuance as you imply because the evalutated value could just change to always be Option<T> where T == () in the "normal" sense. And we make a plain break result in Some(()).

This would be a breaking change:

fn main() {
    // for and while loops evaluate to a value of type `()`
    let _: () = while true { };
}
2 Likes

That is true, which does put the barrier for adding higher since I would have to justify the breaking change (even if postponed to the next edition).

However, I do think that these sorts of constructs, if done correctly, help with the expressiveness of code. Especially since polluting code with additional flag variables can get quite messy.

(This may not be relied upon in practice since it's fairly pathological.)

Currently users are relying on it every time they write a while or for loop as their terminal expression:

fn foo(collection: &[Bar]) {
    for elem in collection {
        /// This only compiles because for loops evaluate to ()
    }
}

We could handle these specially and treat them as if they add a semicolon after them when they're terminal, but that seems worse.

I agree there are advantages and maybe if it were before 1.0 we could have done things slightly differently so it wouldn't have such downsides. It'd probably also require some different way to handle terminal expressions as per my example in this post.

5 Likes

Oh good point; that's seems pretty bad indeed.

Huh, I never thought of that before. So fn foobar() is type equivalent to fn foobar() -> () or just for the purposes of throwing away the last expr vs just returning it?

I disagree with this. These "primitives" are not really primitives, as they are implemented in the library. Being able to use them uniformly (eg. in generics), and potentially substitute them with custom types is valuable, hence turning them into lang items shouldn't be treated lightly.

4 Likes

This feels overstated to me. For example, core::ops::Add isn't somehow non-uniform just because it's marked #[lang = "add"]. Similarly, saying that we shouldn't have #[lang = "unit"] struct Unit; just leads to things like () instead, which is less uniform. Letting the compiler desugar syntax to core things seems like a good thing, to me.

7 Likes

By that reasoning, we shouldn't have the Try trait, instead every std type that currently supports it should be baked into the compiler.

There is some necessity for lang items, but introducing not strictly necessary tight coupling (in the Try example, using many concrete lang items instead of a single general one) just for supporting syntactic sugar is ill-advised.

That does not at all follow.

3 Likes

I can't remember a single case in my practice where I would've used this feature, so I am very skeptical about it and I don't think it pulls its weight in the proposed form.

As for return value of for loop I strongly prefer potential integration wit generators to the proposed approach:

1 Like

That incompatibility can be perfectly solved with editions: current edition warns when the value of for/while is not immediately discarded, next edition enables the feature.

I dislike that proposal, precisely because it would not integrate well with the feature proposed here. There is something fishy about the loop body being free to choose the value it passes to break, but its type being dictated by another object.

Labeled blocks allow for such a for-then construction without additional branches:

fn for_then(k: i32) {
    'otherwise: {
        'then: {
            for i in 0..10 {
                if i==k {break 'then;}
            }
            println!("completed");
            break 'otherwise;
        }
        println!("left early");
    }
}

fn main() {
    for_then(4);
    for_then(10);
}

The restricted construction is more readable:

fn for_then(k: i32) {
    'then: {
        for i in 0..10 {
            if i==k {
                println!("left early");
                break 'then;
            }
        }
        println!("completed");
    }
}

Even the desired syntax is handled better by them:

enum Break {Then, Else}
use Break::{Then, Else};

fn for_then(k: i32) {
    match 'block: {
        for i in 0..10 {
            if i==k {break 'block Else;}
        }
        Then
    } {
        Then => println!("completed"),
        Else => println!("left early")
    }
}
2 Likes

Could you point out some of the downsides of this approach, having for and while evaluate to Option<T> if they contain break T; and evaluate to () it they contain break; or no break? From the perspective of reading and writing Rust code this seems perfectly natural to me, and it is not a breaking change because break T; is not currently allowed there. And in some ways it is consistent with break inside loop in that loop evaluates to T if it contains break T; and evaluates to () if it contains break;.

break T; sufficiently clearly communicates from writer to reader that we care about the value of the enclosing loop expression. I think it is more valuable to be consistent about break T; breaking with a value than to be consistent about break (); being interchangeable with break;, which is theoretically attractive but not at all valuable to reading or writing code.

4 Likes

I think you misunderstood something. In the cited proposal break inside for loops will continue to work as it does today (i.e. you can't use break 1; inside them). There were suggestions to pass break argument as a generator resume argument (e.g. ending iteration without break will be desugared into gen.resume(None) and break 1; to gen.resume(Some(1))), but I am not sure about usefulness of such feature.

Can you provide practical examples for which break arguments inside for loops could be useful? I personally can't recall any.

UPD: I've messed-up the description of continue/break integration with generators, see the next message for a correct one.