Pre-RFC: `while let ... break`

I frequently find myself writing something like the following:

let value = loop {
    match fallible_function() {
        Ok(value) => break value,
        Err(e) => {
            // Some form of retry logic, log message, exponential back-off
        },
    }
};
// Do something with the value

To me, this screams that it should be a while let loop, since I'm looping on one pattern until I get the other pattern. The only problem being that I need to get the Ok result out, which a while let does not allow. Thus, I propose the following syntax:

while let Err(e) = fallible_function() break Ok(value) {
    // Some form of retry logic, log message, exponential back-off
}
// Do something with `value` which is now bound

Generally, the idea is that if the let bind doesn't match the output of the function, then we bind whatever pattern follows the break in the current namespace after the loop. Combined, the two patterns need to be able to match any pattern the value can have, so we can apply the break bind to whatever was checked.

I think it's probably also best to disallow break statements in a while let ... break loop. There's probably some way to make it work, but it seems too complicated and hard to implement/explain/understand to be worth any possible use.

Anyone have any thoughts about this? Especially, I can't decide if it's better to use break as the keyword (is how one normally returns a value from a loop) or if it should be replaced with else (is normally how one indicates something is done if a condition doesn't hold), or if something else entirely should be done.

I also can't decide if it's better to bind the name from the pattern in the surrounding namespace, or to require the pattern have exactly one new identifier and have the loop evaluate to that value, which would turn the example above into this:

let value = while let Err(e) = fallible_function() break Ok(value) {
    // Some form of retry logic, log message, exponential back-off
};
// Do something with `value` which is now bound

My first thought is, this would be a good circumstance for the "eliding {} when you have a sequence of keywords" idea, i.e.

let value = loop match fallible_function() {
    Ok(value) => break value,
    Err(e) => {
        // ...
    },
};

although I didn't dig too deep into the past conversations about that idea, so there may be problems with it.

My second thought is: It feels like in this example, the "point" of the loop is to get the Ok value – that's the "happy path" – and the loop is just to take care of failures. So I would prefer a syntax that makes the happy path more visible. Something vaguely like:

let Ok(value) = fallible_function() else retry {
   Err(e) => ...
};

(I feel like this particular syntax is too magical, but maybe there is a way to do it that's more Rust-like)

3 Likes

Retry logic in particular seems pretty easy to abstract out, and once you do that this becomes

let value = retry(delay::Exponential::from_millis(10), fallible_function);

anyway, and you get the break for free inside the retry function (it's now "return"). There is a retry crate (syntax used above), but the function is not difficult to write and may have subtle requirements.

3 Likes

That works great if all I want is to delay, but I also might want to log::error! something, or there might be cleanup work needed before I can try again (or maybe other requirements that I haven't had to put in this loop yet but other people have), so the function gets very complicated to handle all use-cases (which is why I'd prefer to have a syntax like this to just handle it inline).

I don't think Rust is likely to add a language control flow construct for retries; consider that while we have let-else, it unconditionally discards the nonmatched information, and people are recommended to use a standard match for that case instead.

A general function that permits any retry logic is not difficult.
// if retry until success or panic
pub fn retry_with<T, E>(
    mut op: impl FnMut() -> Result<T, E>,
    mut retry: impl FnMut(E),
) -> T {
    loop {
        match op() {
            Ok(t) => return t,
            Err(e) => retry(e),
        }
    }
}

// if able to stop retrying via error
pub fn retry_or_else<T, E>(
    mut op: impl FnMut() -> Result<T, E>,
    mut retry: impl FnMut(E) -> Result<(), E>,
) -> Result<T, E> {
    loop {
        match op() {
            Ok(t) => return Ok(t),
            Err(e) => retry(e)?,
        }
    }
}

// example
let value = retry_with(fallible_function, |e| {
    let duration = backoff.next_backoff();
    log::debug!("operation failed; retrying in {duration}");
    wait(duration);
});

The backoff crate's retry includes the option of a "notify" callback, which means, using the above helper, it'd look something like:

fn retry_notify<T, E>(
    mut backoff: impl Backoff,
    mut op: impl FnMut() -> Result<T, backoff::Error<E>>,
    mut notify: impl FnMut(E, Duration),
) -> Result<T, backoff::Error<E>> {
    retry_or_else(op, |e| match e {
        backoff::Error::Permanent(_) => e,
        backoff::Error::Transient { err, retry_after } => {
            let Some(wait_duration) = retry_after.or_else(|| backoff.next_backoff()) else {
                return backoff::Error::Transient { err, retry_after: None };
            }
            notify(err, wait_duration);
            sleep(wait_duration);
            Ok(())
        },
    })
}

The main limitation of a closure-based retry is that it's not transparent like a language control flow construct is. Meaning, you need a different "color" of the function for each potentially relevant combination of effects, e.g. sync-infallible, sync-fallible, async-infallible, and async-fallible. This is because with language syntax e.g. ? and .await target the containing function, but in a closure, they target the closure.

I did wonder about whether a try-else construct might make some sense, but that would essentially already be spellable as let Ok(value) = try { ... } else { ... }; with let-else, so wouldn't much need special syntax (and would need it to be reserved up front before try blocks are stabilized).

One big problem is that enabling this requires essentially one of the following changes to the Rust grammar:

  1. Making the curly braces for the loop entirely optional. The gotofail vulnerability is a relevant example of why could be undesirable.
  2. Special-case a loop match construct. I'm not so sure it would be a good idea to have grammar constructs that start not with one but 2 keywords. In addition, special-casing this will be a perpetual thorn in the backside of the Rust developers, and prone to fall out of sync with loop { match {... } }.
2 Likes

Something I want regularly (and which has been suggested before) is for for/while loops to have an optional else block that executes if the loop reaches the end without breaking. eg.

fn iter_find<T: PartialEq>(
    pred: impl Fn(&T) -> bool,
    iter: impl Iterator<Item = T>,
) -> Option<T> {
    for val in iter {
        if pred(&val) {
            break Some(val);
        }
    } else {
        None
    }
}

A natural extension of this is if the else could have a pattern (which is implicitly () if omitted) that matches the value which failed the loop condition. ie.

let value = while let Err(e) = fallible_function() {
    // retry logic
} else Ok(value) {
    value
};

This also allows for loops to be generalized to take generators rather than iterators since any Iterator<Item = T> can be turned into a Generator<Yield = T, Return = ()>. You could then write:

fn generator_find<T: PartialEq, E>(
    pred: impl Fn(&T) -> bool,
    generator: impl Generator<Yield = T, Return = E>,
) -> Result<T, E> {
    for val in generator {
        if pred(&val) {
            break Ok(val);
        }
    } else e {
        Err(e)
    }
}
3 Likes

Executing else if the loop executes is entirely counterintuitive. What I would expect from such syntax is for else to execute if and only if the loop is never entered, thus allowing for-else and while-else to have a value other than ().

2 Likes

Yeah, else being the wrong keyword is the complaint that usually gets raised. However:

  • a) Python already has this, and uses else for it. So there's precedent in a widely-known language.
  • b) There aren't really any other keywords that make more sense, particularly not any reserved keywords.
  • c) You'll get used to it.

All in all I think the utility of this would outweight any confusion beginners might have towards it.

2 Likes

What's Python's experience with it, though? The impression I've gotten from some previous conversations is that people get confused by it there, and thus it's poor precedent for inclusion elsewhere.

10 Likes

There's a problem with this that I haven't seen mentioned before: It looks awfully similar to an if-let chain. That would definitely end up extremely confusing, and thus from me it's a vote against.

3 Likes

It is awfully similar to an if-let chain though. When the let pattern doesn't match the else block runs. If we were going to have this then if-let chains should also support patterns on else though.

FWIW the grammar already has and special cases "expr with block" ({}, unsafe {}, loop {}, while _ {}, for _ in _ {}, if _ {}, match _ {}), so we could make a grammar change uniformly across all of them rather than only permitting specific combinations.

Though looking at that list, doing so wouldn't be all that desirable, given that it'd permit loop while condition { ... } but make it mean loop { condition; ... }, or loop if condition, or I'm sure there are other funny ways to get bad results from that :sweat_smile:

loop match is a common enough pair that it could potentially justify a combo syntax. I'd almost go as to say order of magnitude over any other pair, so long as you ignore if let if let which has a shorthand (let chains) on the docket. (I think the next would be nested for loops, but I nearly everyone would agree that the bracket nesting is helpful there. Then loop match in third.)

Uh, what do you consider if let $pat = $expr {} or while let $pat = $expr, then, if not grammar constructs starting with two keywords? Because it's not if (let $pat = $expr); let is (for the time being[1] still) always a statement, never an expression.


  1. if let chains will make let more expression-ish, but even then still limited to being in if/while conditions, at the root level, so that parenthesization is still invalid. ↩ī¸Ž

I agree with this argument in favor in particular.

I also agree with the general argument against freely mixing and matching. In particular, the ability to chain keyword arbitrarily as if they're German or Dutch nouns (what I'll call Keyword Tetris in the context of programming languages) shouldn't be a thing, because as you indicate that could trivially lead to surprising behavior.

I consider that a complete oversight on my part :smile: You're absolutely correct.

Is there a formal Rust grammar document? I'd love to take a peek at the grammatical structure of let being a sort of contextual semi-expression, see how it's implemented at that level. Currently I'm imagining it essentially being a production that's only used within the predicate position of the productions for if/while expressions, but I could easily be wrong.

EDIT: I think I found the current structure, but I don't know how to find any document regarding such a change.

Literally today URLO had a topic that reminded me of the repeated suggestions for for ... else but where the else is the value if the loop didn't hit a break. I like the concept, but it's been rejected multiple times for being confusable with the python behavior you're referencing, so I think both behaviors are out with that specific syntax.

I kind of like the suggestion on one of the issues I link there of using then for the "value of the loop if you don't break out", which pairs nicely with also adding the python behavior as the else:

for i in numbers {
  if is_prime(i) {
    break i;
  }
} then {
  1
} else {
  0
}

But iterators like iter().find().or_else() is fine in the few cases this comes up for me.

Wait, isn't "didn't break" and "didn't run" the same thing? They are, aren't they, in actuality both mean "didn't produce a result".

To me this functionality seems superfluous.

As demonstrated earlier in this thread it can be solved via higher order functions (which I think is cleaner and more readable).

It can also be solved via a mut variable of Option type outside the loop if you want something more imperative for some strange reason.

Rust is quite a complex language as is, and it is not worth making the language harder to learn for very little reason. New syntax has an especially high mental load, and thus high bar to clear. While a macro is lower load, and a normal function or trait even less so.

4 Likes

One's a superset of the other, at least my understanding of "didn't run" is "didn't enter the loop" (i.e. the iterator was empty), while "didn't break" can result with a non-empty iterator.

All control flow in Rust beyond functions, loop, match, break, and return are superfluous. All the other control flow constructs (if let, while, for, continue, &c) are just combinations of those that represent commonly-used patterns.

Yes, it's true that this can be expressed with a higher-order function that takes what to do when it doesn't match as input, but so can:

fn for<T>(iterator: impl IntoIterator<Item = T>, per_elem: Fn(T)) {
    let mut iterator = iterator.into_iter();
    loop {
        match iterator.next() {
             Some(elem) => per_elem(elem),
             None => break,
        }
    }
}

And yet, Rust has for loops as a separate syntactic element that doesn't require you to write this helper function or manually do the desugaring yourself. I believe that this, like the for-loop, is a patern that appears commonly enough to be worth adding a syntactic sugar to let it be more-easily specified.

Re else on a loop being confusing: this is part of why in my initial post, I disallowed break statements in the while let ... break loop. There can't be any confusion about when something does or does not happen upon leaving the loop if there's only one way to leave the loop and it always goes through the extra block (though it sounds like break might be a better keyword to use there if people don't like else).