Destructuring assignment else

This isn't an actual proposal, more of a shower thought.

We have stable destructing assignment, now

$irrefutable_pattern = $expr;

as well as unstable let-else

let $refutable_pattern = $expr else {
    $diverges_block
}

so... there's a logical combination of these, with destructuring assignment else:

$refutable_pattern = $expr else {
    $diverges_block
}

This takes all the complaints from let-else and amps them up more... this feels like a bad idea, but I can't articulate why.

1 Like

There's some earlier discussion in destructuring assignment and let else · Issue #93995 · rust-lang/rust · GitHub, where I agree with Josh's:

And in particular, I think destructuring assignments with else have a disadvantage and higher bar than let-else does, because let ... else has the let as an introducer for the type of statement, while assignments do not, making the else feel more unexpected.

Other than overloading try, would this feel better for assignment-else?

try Ok((foo, bar)) = baz() else {
    ...
};

Another good point made in the linked issue was that refutable assignment doesn't necessarily need a diverging else. Whereas let-else can bring the (typically short) else-block close to the point of matching and reduce indentation, assignment-else would just be sugar for the repetition in

if let Ok(tmp) = baz() {
    (foo, bar) = tmp;
} else {
    ...
}
8 Likes

Honestly I think this is a bad idea just because there aren't lots of use-cases. Features like that are small improvement (over e = if let e = p { e } else { ... }), but when coming in mass, it becomes profitable. The let ... else has demonstrated a very good potential inside the compiler and outside of it, but I don't see the same for assignment else.

However, at least I tend to not see the places where those features can be applied until they're available and I start to think about it. It happened with let ... else and also with if let chains. So, maybe it is worth it...

1 Like

I’m not seeing much of a use case either; for any WLOG-like constructs I can think of, letelse seems adequate enough. But I am not convinced none exist either. Perhaps someone else will find refutable assignment sorely missing.

We wouldn’t need to have this discussion if assignment to an existing variable were an orthogonal place denotation in binding patterns. Refutable assignment would just fall out of the syntax naturally for those who might need it. Were it to prove a really bad idea indeed, it could be linted against.

1 Like

This was actually the first thing I thought of when I read @CAD97's thought. @CAD97 can you provide a more concrete example that can't be solved by the above pattern? Or where your pattern is significantly better than the above pattern?

I ask because for me personally the above pattern is much easier to read, so I feel like I'm missing something when I read your thought.

2 Likes

Here's the motivating example from the issue linked upthread:

#[derive(Debug)]
enum Foo {
    Done,
    Nested(Option<&'static Foo>),
}

fn walk(mut value: &Foo) {
    loop {
        println!("{:?}", value);
        &Foo::Nested(Some(value)) = value else { break };
    }
}

Rewritten with regular if-let, that would be something like

fn walk(mut value: &Foo) {
    loop {
        println!("{:?}", value);
        if let &Foo::Nested(Some(value_new)) = value {
            value = value_new;
        } else {
            break;
        }
    }
}

which is vertically longer and introduces a second name, but on the other hand, the break isn't way off to the right. Personally I think it's about the same level of unreadable either way.

@josh pointed out in the issue that this particular example would be better as

fn walk(mut value: &Foo) {
    do {
        println!("{:?}", value);
    } while let &Foo::Nested(Some(value)) = value;
}

(if only we had do-while) ... I can imagine "loop and a half" situations where you really wanted the else { break } in the middle, but I am not sure how likely they are to come up in real life.

1 Like

Yeah, I can see that, but it's still less legible to me than your expanded version of:

fn walk(mut value: &Foo) {
    loop {
        println!("{:?}", value);
        if let &Foo::Nested(Some(value_new)) = value {
            value = value_new;
        } else {
            break;
        }
    }
}

What I want to see is a use case where using this makes the code more legible, not just more compact.

1 Like

The use case is basically exactly postfix .unwrap_or!().

The code that I was working on when I thought about it looks like

loop {
    match input.find('\\') {
        Some(pos) => {
            buf.push_str(&input[..pos]);
            let ch;
            (ch, input) = unescape_char(&input[pos..]).unwrap();
            buf.push(ch);
        },
        None => {
            buf.push_str(input);
            break;
        },
    };
}

unescape_char currently returns Option, and unwrap here is going to panic on ill-formed input.

With assign else, this could be written

loop {
    match input.find('\\') {
        Some(pos) => {
            buf.push_str(&input[..pos]);
            let ch;
            Some((ch, input)) = unescape_char(&input[pos..]) else {
                return Err(Error::Unknown);
            }
            buf.push(ch);
        },
        None => {
            buf.push_str(input);
            break;
        },
    };
}

whereas without, I'd probably write it as

loop {
    match input.find('\\') {
        Some(pos) => {
            buf.push_str(&input[..pos]);
            match unescape_char(&input[pos..]) {
                None => return Err(Error::Unknown),
                Some((ch, after)) => {
                    buf.push(ch);
                    input = after;
                }
            }
        },
        None => {
            buf.push_str(input);
            break;
        },
    };
}

However, this isn't actually a good example, because unescape_char should eventually return Result saying why the escape was invalid, and more code should be added to propagate error information so a good syntax error can be produced, anyway.

Basically, the value of "assignment else" is the benefit of let else, but just niched even further from when new bindings are acceptable/desirable to when updating the existing binding is needed.

// let else
let (some, group, of, names) = match create() {
    Big::Patern { that_binds: some, interesting: AndNontrivial { group, of names } }
        => (some, group, of, names),
    _ => else_diverges!(),
}
// becomes
let Big::Patern { that_binds: some, interesting: AndNontrivial { group, of names } }
    = create()
else {
    else_diverges!()
}

// assign else
(some, group, of, names) = match create() {
    Big::Patern { that_binds: some, interesting: AndNontrivial { group, of names } }
        => (some, group, of, names),
    _ => else_diverges!(),
}
// becomes
Big::Patern { that_binds: some, interesting: AndNontrivial { group, of names } }
    = create()
else {
    else_diverges!()
}

(This was typed in the irlo text box, excuse nonoptimal formatting.)

The example is obviously overly artificial and constructed, but that's because, in general, idiomatic higher level Rust code tends to prefer a more functional style without mutation, and even when mutation happens, it's typically via &mut self and not via reassignment.

It's very interesting that my original case that got me thinking about this had a "new" binding in the destructuring assignment. This makes the else diverges actually important, and I posit would probably be the primary case where assignment else would be practically useful: mixed assignment and new bindings. I think it'd be rare to want to reassign the values from a pattern, and not be served by just binding the whole thing into one binding rather than many.

Yeah, I would probably use ok_or_else for this particular use case, but I know that your code was just an example of the use case, and what motivated you originally.

This is the issue that I have with it, it definitely makes the code more compact, but it also adds yet another chunk of syntax that we have to parse correctly when reading code. There are lots of cases where adding more syntax is a good idea, but in those cases it usually buys us something significant, like making the code easier to read, or letting Rust do something that was difficult or impossible before. This doesn't feel like it buys us anything super significant. It feels like something that would only be used rarely, just rarely enough that you can sort-of forget what it does between times you run into it. That doesn't give me warm fuzzy feeling, because it means that someone reading your code might not quite comprehend what its doing.

Just to be clear, I don't have any super strong evidence of this, it's just a gut feeling I've got.

Just for clarification: this works if your diverging is an unwind, but not if it's control flow (return, break, continue). That's the reason for a postfix macro .ok_or!(); you can't .ok_or_else(|| return Err(..)). A destructure else is "just" a general form of .unwrap_or!().

I'm not really for this syntax, and the reason I'd see to suppport it would be specifically because it's making assignment less different from let binding in addition to use cases. I personally don't think either individually motivate allowing it (so long as good diagnostics exist for it).

@josh: I think I'd prefer to write that as (ch, input) = unescape_char(&input[pos..]).ok_or(Error::Unknown)?;

[quote after, which breaks the forum software LUL]

Insert a trace! or something so it can't be just ?d, and the motivation (however slim) remains the same.

I think I'd prefer to write that as (ch, input) = unescape_char(&input[pos..]).ok_or(Error::Unknown)?;

Or refactor it as you suggest later such that unescape_char returns a Result, in which case you can just directly use ? on it.

Edit: looks like we posted at the same time. And yes, I agree that this doesn't address the case where the desired fallback control flow is not ?.

I do, however, think you summed up my position best when you said:

This isn't at all obvious, but you can also write:

let ch;
if let Some(res) = unescape_char(&input[pos..]) {
    (ch, input) = res;
} else {
    // diverge here, so it's okay to not initialize ch
    return Err(Error::Unknown);
}
buf.push(ch);

playground

But the same reasoning exists for let ... else, yet people came into conclusion (which I used to disagree but I agree with now) that this feature has a place.

3 Likes

Among other reasons, the common pattern for let else is:

let x = if let Pattern(x) = expr() {
    x
} else {
    // diverge
};

Which feels much more awkward due to the let ... = if let. The pattern for assignment, on the other hand, is:

if let Pattern(x) = expr() {
    existing_x = x;
} else {
    // diverge
}

which feels less awkward and doesn't need to nest an if let inside a let.

That doesn't mean it wouldn't be useful, it just lowers the usefulness somewhat. The lowered usefulness of fallible-destructuring-assignment-else combined with the higher difficulty of reading fallible-destructuring-assignment-else (due to the lack of introducer) seems to me to be enough to swing the overall balance away from support.

7 Likes

Sorry, I wasn't being clear enough:

loop {
    match input.find('\\') {
        Some(pos) => {
            buf.push_str(&input[..pos]);
            let ch;
            (ch, input) = unescape_char(&input[pos..]).ok_or_else(|| {Err(Error)})?;
            buf.push(ch);
        },
        None => {
            buf.push_str(input);
            break;
        },
    };
}

That said, I get what you're saying, AND I know that I'm starting to stray from the original topic.

Here to point out that it's already being discussed in the thread for the let-else RFC: Tracking Issue for RFC 3137: let-else statements · Issue #87335 · rust-lang/rust · GitHub

3 Likes