Explicit bindings in patterns

Rust is very pleasant language to use because it catches most of your attempts to write invalid code. However, there are still several gotchas. One of them is match statement which behaves differently depending on whether some symbol is imported or not instead of giving clear error:

// comment this to change 'match' behavior'.
use std::fmt::Alignment::Left;

fn main() {
    let value = Some(std::fmt::Alignment::Right);
    match value {
        Some(Left) => println!("left"),
        _ => println!("other"),
    }
}

Ok, Rust isn't completely silent here:

warning: variable `Left` should have a snake case name`

And fresh Rust even gives hard error:

pattern binding `Left` is named the same as one of the variants of the type `std::fmt::Alignment`

However, it all seems like heuristics instead of systematic solution.

There is another form of same problem:

let a = 2;
let n = Some(3);
match n {
  Some(a) => println!("n == a"),
  _ => println!("not equal"),
}

Behavior which many users expect here is:

let a = 2;
let n = Some(3);
match n {
  Some(a) if a == n => println!("n == a"),
  _ => println!("not equal"),
}

But there is no way to distinguish fresh binding from reference to existing one.

Then why not require explicit bindings in patterns?

match n {
  Some(let a) => …
  let other => …
}

Pros:

  • Clear binding indication
  • 'Identifier not found' instead of silent binding
  • Easy to introduce in epoch, since only syntax is changed, not semantics

Cons:

  • More verbose, sometimes more ugly (&(let a))

What do you think of that? Are there any existing discussions/issues/rfcs on this topic?

3 Likes

Did you know that let itself supports patterns? How does your idea work here? Do we write

let x = 1;

as

let let x = 1;

now, because x itself is no longer a pattern that binds x?

For a less degenerate case, currently it’s

let (x, y) = (1, 2);

so would that become the following?

let (let x, let y) = (1, 2);

Also I don’t think the proposal is realistic; I believe it’s just too drastic a change to existing Rust syntax, affecting way too much code; those are usually a non-starter (the linked&quoted section applies to “fundamental changes”; of course one can argue if the change proposed here really is all that “fundamental”, but also – as I’m e.g. wondering about simple let x = something; as explained above – I’m not sure what exactly is the extent of the proposal, anyway).

Changes that would break existing Rust code are non-starters. Even in an edition, changes this fundamental remain extremely unlikely. The established Rust community with knowledge of existing Rust syntax has a great deal of value, and to be considered, a syntax change proposal would have to be not just better, but so wildly better as to overcome the massive downside of switching.


There are definitely existing discussions around the issue of behavior “differently depending on whether some symbol is imported or not”. A recent one I can recall e.g. would be this one on the Users forum.

6 Likes

Yeah, despite being fully automatic, the code change would be huuuuge.

And let let is indeed weird.

2 Likes

I agree this is problematic, but I would suggest a different solution. Making _ the only acceptable wildcard pattern, always requiring @ to bind to a name:

match n {
  Some(a @ _) => …
  other @ _ => …
}
11 Likes

if the syntax somehow allowed "bare" patterns without a let prefix, then let x = 1 could continue to work, and (let x, let y) = (1, 2) could be a thing too. Next maybe let mut could be changed to var.

Overall it'd be nice. Unfortunately, it would be ambiguous with EXPR = EXPR syntax in lots of other cases, and a pretty big churn.

5 Likes

This could be a reasonable way to introduce new bindings within destructuring assignments. I write something like this quite often:

let flag;
(x, flag) = x.overflowing_add(y);
// use `flag` later

which is fine as is, but might be nicer as

(x, let flag) = x.overflowing_add(y);
9 Likes

I think a much less drastic but potentially worthwhile change in the vein of this is adding a Clippy warning for using unqualified variants other than Ok/Err and Some/None.

Have it suggest aliasing the enum with a 1-letter name if you want brevity but just have it complain about use Enum::* and then doing match x { Variant => {} } with a warning like “use of unqualified variants is considered an antipattern, try use Enum as E; … E::Variant instead” (but phrased in clippy language).

Some prior art: Perl already works like this, and generalises it to allow declarations pretty much anywhere you could write an expression (e.g. you can write print(my $x = 4), the equivalent of Rust print!("{}", let x = 4), and $x now holds 4 even after the print statement has finished running).

I think that forbidding the old syntax in Rust generally would probably be too large of a change, although it's interesting to brainstorm about what it would be like to have a Perl-style "declare anywhere" alongside the existing syntax (which seems like it could potentially be useful, and could even be combined with a lint to disallow the old syntax that codebases could turn on if they found the extra checks to be helpful).

One big problem is likely to be the interaction with if let: it seems wrong to force people to write if let Some(x) = y as if let Some(let x) = y, which doesn't really make sense. You'd probably have to make fallible assignments return booleans in if/while conditions, allowing if Some(let x) = y if x were previously undeclared and newly allowing if Some(x) = y if x were already declared (this assigns to x, visible even after the if statement is over, if y is Some and leaves x alone if y is none). It seems like there might be some consistent design along these lines that actually works (you could get rid of the if let … && … special case, so it would make the language simpler by removing a special case), but I'm not sure how intuitive it would be to a) understand the scoping rules or b) understand whether or not code is guaranteed to initialise a variable. (It reminds me a lot of lifetime extension of temporaries – it basically has the same set of issues, but with scopes rather than lifetimes. There are some awkward cases, like if let Some(x) = y || z { … } which would be accepted, but where x would be treated as an uninitialised variable due to the z codepath and so you couldn't actually use it.)

1 Like

Swift also requires you write let to make new bindings in patterns instead of trying to reference existing ones, though it softens it by letting you write it “outside” of a subpattern to apply to all identifiers within it. On the one hand making new bindings is by far the common case; on the other, if you mess it up you usually get a useful error (“this name doesn’t exist, maybe you forgot let?” rather than “this binding is unused”).

I agree that changing this for Rust is probably not worth it, even across an edition.

1 Like

Note that there's no issue with Some -- because it has the parens, there's no ambiguity with bindings.

Personally, if we did anything I think I'd do a mix of these:

  • Add the inferred-variant syntax, so that I write
match x {
    .Less => ...,
    .Equal => ...,
    .Greater => ...,
}

and it's syntactically clear it's matching a variant, not ever making a binding.

  • Finish const_pat, so you can explicitly match a constant with
match x {
    const { WHATEVER } => ...,
    other => ...,
}

and that's also unambiguous.

(Though I don't know how much I'd want to force using const blocks for that. Might be better to leave that as a opt-in clippy lint for places that would prefer that style.)

2 Likes