rfcs icon indicating copy to clipboard operation
rfcs copied to clipboard

Match ergonomics 2024

Open Jules-Bertholet opened this issue 1 year ago • 42 comments

Rendered

Changes to match ergonomics for the 2024 edition.

@rustbot label T-lang A-patterns A-edition-2024

Jules-Bertholet avatar May 06 '24 20:05 Jules-Bertholet

The "& patterns matching against &mut" part seems reasonable but also fairly independent of the other changes. It might make more sense as part of a deref patterns RFC, which might (or might not!) also choose to use & more generally.

rpjohnst avatar May 06 '24 22:05 rpjohnst

The "& patterns matching against &mut" part seems reasonable but also fairly independent of the other changes.

It could be split off, yes. I mention this in the rationale section, but one of the motivating factors for including it is the "no inherited ref mut behind &" rule. With that rule, whether an inherited reference is ref or ref mut (and therefore, whether it can be matched with &mut) depends on non-local factors, so allowing users to always use & unless they need mutation becomes especially important.

Jules-Bertholet avatar May 06 '24 22:05 Jules-Bertholet

The "no inherited ref mut behind &" rules seems equally independent and forwards-compatible. (And also equally reasonable! Just probably worth considering holistically alongside deref patterns.)

rpjohnst avatar May 07 '24 00:05 rpjohnst

The "no inherited ref mut behind &" rules seems equally independent and forwards-compatible.

It is not. On edition 2024, this example would work without the rule, but errors with it: let &[[&mut a]] = &[&mut [42]];

(I'll edit the RFC to make that more clear)

Jules-Bertholet avatar May 07 '24 00:05 Jules-Bertholet

I'm not sure why (a version of) "no inherited ref mut behind &" couldn't accept that example. The binding mode could be something like "ref mut behind ref" rather than flattening all the way to ref. Indeed, places behave similarly: given x: &&mut i32, *x still has type &mut i32. You may not be able to move or write through it, but that doesn't make it a &i32.

Really I'm not sure why we would even want to reject it in the first place. There is still a &mut there, after all, and the problem only arises if we try to move it out from behind a &. It would be unfortunate if, for example, let [[&mut a]] = [&mut [42]] worked but let &[[&mut a]] = &[&mut [42]] did not.

This is exactly the sort of thing that would be good to work out as part of deref patterns. Going back to the comparison with places, the reason you can get a &i32 from *x is that auto(de)ref performs a reborrow- the exact expression-side equivalent to deref patterns.

Accepting this example is a different sort of consistency than the "immutability takes precedence" one. Instead, it's "inherited reference type matches skipped reference type." This also brings back the locality that "& patterns matching against &mut" is trying to compensate for.

If we want to defer any decision at all here, I believe we could instead forbid matching &mut against "ref mut behind ref" entirely. That would make it forward compatible to implement either "no inherited ref mut behind &" or "ref mut behind ref is a distinct mode."

rpjohnst avatar May 07 '24 05:05 rpjohnst

Instead, it's "inherited reference type matches skipped reference type."

If there are several skipped reference types with different mutabilities, you have to choose: eg in let [[/*HERE*/ x]] = &[&mut[42]];, which mutability do you use to determine that of the inherited reference at HERE? So it's impossible to be fully consistent with the principle you give.

Jules-Bertholet avatar May 07 '24 05:05 Jules-Bertholet

If there are several skipped reference types with different mutabilities, you have to choose: ..., which mutability do you use to determine that of the inherited reference?

The innermost one, of course! Why would it be anything else? That's how default binding modes already work, and we're merely exposing that to the user so they can exit the current mode, not trying to reinvent how the mode is entered.

rpjohnst avatar May 07 '24 16:05 rpjohnst

The innermost one, of course! Why would it be anything else? That's how default binding modes already work

No it's not! Outer shared + inner mutable results in ref, not ref mut. https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=c0c3f5fbe3499b54813bbe3af95e31eb

Jules-Bertholet avatar May 07 '24 16:05 Jules-Bertholet

What I'm saying is that it is how initially entering a default binding mode already works. That's why the example let &[[x]] = &[&mut [42]] produces x: &mut i32 even though it's underneath a &.

The inconsistency we're both talking about is the difference in behavior between "behind an explicit &" and "in default ref mode," demonstrated by the example above and your latest example let [a] = &&mut [42] => a: &i32.

You're proposing we resolve it in favor of the binding mode (essentially letting explicit & patterns change a sort of "not-yet-default binding mode); I'm proposing we resolve it in favor of the actual skipped reference type (and converting the existing ref->&mut->ref->& mode transition to a more auto(de)ref-like ref->&mut->ref mut->& mechanism), for the reasons I gave above- greater locality, and alignment with explicit patterns, places, and auto(de)ref.

rpjohnst avatar May 07 '24 17:05 rpjohnst

Here's another way of looking at it: if &mut patterns "unwrap" a mutable reference, it should be possible to get a mutable reference by removing the pattern.

Simple case:

let &mut x = &mut 42; // x: i32
let x = &mut 42; // x: &mut i32

Roundabout case:

let &[&mut x] = &[&mut 42]; // x: i32
//let &[x] = &[&mut 42]; // ERROR, but…
let &[ref x] = &[&mut 42]; // x: &&mut i32 -> we can get an &mut in the end

However:

let &[[&mut x]] = &[&mut [42]]; // If we allow this, with x: i32
//let &[[x]] = &[&mut [42]]; // and then remove the &mut… -> ERROR move check, if the default binding mode is to be `ref mut`

// nothing we do will get us &mut i32 in any form

Jules-Bertholet avatar May 07 '24 18:05 Jules-Bertholet

Indeed, that is a demonstration of the fact that we are limited to a single layer of inherited reference/a single binding mode. If we relaxed that (not that I think this would be useful in practice), you could get a &mut in that last example:

let &[[&mut x]] = &[&mut [42]]; // x: i32
let &[[x]] = &[&mut [42]]; // ERROR move check, unless the pattern equivalent of auto(de)ref coerces &mut i32 -> x: &i32
let &[[ref x]] = &[&mut [42]]; // HYPOTHETICAL: A `ref` binding + an inherited `&mut` -> x: &&mut i32

My proposal is not so much breaking the user's ability to remove a &mut pattern to get a mutable reference, but (somewhat uselessly, on its own) extending that ability to all inherited &muts, and then restricting their use in the same way they are restricted in places. (With the additional possibility of making them nicer to work with via an auto(de)ref-like mechanism, as part of deref patterns.)

If I understand what you're getting at, your proposal would avoid this edge case by preemptively replacing &mut patterns with & in these places where you would be forbidden from using the &mut anyway. I think this is a reasonable design goal but it doesn't need to be accomplished by the binding modes themselves, which as you've noted, makes pattern matching less local.

rpjohnst avatar May 07 '24 19:05 rpjohnst

that is a demonstration of the fact that we are limited to a single layer of inherited reference/a single binding mode. If we relaxed that...

It is semantically impossible to relax that. E.g.

let var = Some(0);
if let Some(x) = &&var {
   //...
}

Here x cannot be &&u32, because where would we store the &u32 that it points to? There is no &u32 in memory we could make it point to. There is however a u32, so we can have x: &u32 pointing inside var.

Nadrieril avatar May 07 '24 19:05 Nadrieril

Well, that was only a hypothetical to demonstrate a different way of thinking about the limitation Jules pointed out. But I don't think it's entirely impossible to materialize those extra reference layers- we could introduce temporaries for them around the match, it would just be a bit silly, and they would have shortened lifetimes. (Not to mention confusing to be able to layer binding modes like my example.)

rpjohnst avatar May 07 '24 19:05 rpjohnst

@rfcbot fcp merge

We discussed this RFC in the lang-team design meeting. There was general excitement about the contents (subject to one minor caveat which I will file as a concern). The reasoning made sense and the changes felt like they would make the language more true to itself. Therefore, I am moving to fcp merge now.

nikomatsakis avatar May 15 '24 17:05 nikomatsakis

Team member @nikomatsakis has proposed to merge this. The next step is review by the rest of the tagged team members:

  • [x] @joshtriplett
  • [x] @nikomatsakis
  • [ ] @pnkfelix
  • [x] @scottmcm
  • [x] @tmandry

Concerns:

  • ~~leaving-us-in-an-inconsitent-state~~ resolved by https://github.com/rust-lang/rfcs/pull/3627#issuecomment-2125549976
  • ~~switch-to-option-1~~ resolved by https://github.com/rust-lang/rfcs/pull/3627#issuecomment-2125055950
  • ~~what tradeoff are we making with the "ref mut behind &" rule~~ resolved by https://github.com/rust-lang/rfcs/pull/3627#issuecomment--2086817090

Once a majority of reviewers approve (and at most 2 approvals are outstanding), this will enter its final comment period. If you spot a major issue that hasn't been raised at any point in this process, please speak up!

cc @rust-lang/lang-advisors: FCP proposed for lang, please feel free to register concerns. See this document for info about what commands tagged team members can give me.

rfcbot avatar May 15 '24 17:05 rfcbot

@rfcbot concern leaving-us-in-an-inconsitent-state

The RFC as proposed leaves us in a bit of inconsistent state. It means that you can write

let [mut x] = &mut [22];

...and get a binding mut x: &mut i32, but there is no fully elaborated form of this, you would need...

let &mut [ref mut (mut x)] = &mut [22];

...but the RFC (intentionally) avoids proposing that syntax or any explicit replacement for it.

In the meeting we identified three options:

  1. Make mut x in inherited patterns an error; convert mut x with inherited ref to & (mut x) or &mut (mut x) in Rust 2021.
    • can be extended later backwards compatibly (even in Rust 2024) with the semantics described here (and an explicit syntax)
  2. Support mut x with inherited refs but do not have an explicit syntax. We probably want an explicit syntax later, but we don't have to discuss it now. <-- current RFC
  3. Add ref (mut x) and ref mut (mut x) syntax (or some alternative) now

Of these, we felt that option 2 (the current text) was in some ways the worse. Option 1 might be annoying but it's internally consistent and leaves room for Option 3. Option 3 is perhaps the most nice but also adds confusing syntax for what feels like an edge case.

In the meeting we felt it was best to start with Option 1 for now and consider Option 3 later.

We identified the following desirable properties (we are sacrificing 1 for now when there are inherited references):

  1. You can change x to mut x to get mutability -- good for users! Writing let mut x = x is possible but surprising.
  2. There is an explicit form in terms of & and ref etc for everything you can write with inherited references.
  3. Property that, if you have a pattern P without inherited references, e.g., let &(mut x) = &22 gives mut x: i32, and you delete an &, then the bindings below become of type &T (so e.g. let mut x = &22, now mut x: &i32)
    • "weak version" == if both compile, it holds

nikomatsakis avatar May 15 '24 17:05 nikomatsakis

@rfcbot concern switch-to-option-1

I share @nikomatsakis's concern, and I would specifically like to go with option 1. This does not close the door on doing option 3 in the future, after introducing new syntax for it.

joshtriplett avatar May 15 '24 18:05 joshtriplett

I've updated the RFC in light of today's lang team design meeting (minutes).

  • Mutable by-reference bindings are now an error (addresses outstanding concerns)
  • Added more examples to the rationale section for the "no ref mut behind &" rule that was discussed in the last few minutes of the meeting

Jules-Bertholet avatar May 15 '24 18:05 Jules-Bertholet

I would still prefer to be able to consistently write a &mut pattern that matches an inherited reference, regardless of whether the binding mode has been converted to ref by an outer &, so long as there is a &mut value involved.

As we discussed above, this makes the set of available patterns more local, which relaxes the need to commit to &-matching-&mut in the short term.

rpjohnst avatar May 15 '24 19:05 rpjohnst

This makes the model more complex, since we would need to distinguish "ref binding mode" from "ref binding mode that can be matched with &mut".

Nadrieril avatar May 15 '24 20:05 Nadrieril

In that sense, the current RFC also introduces an additional binding mode: "move binding mode that transitions to ref on &mut." In this framing, either choice of "move that transitions to ref" or "ref matchable by &mut" adds a new distinction to match ergonomics. However, I think both of these ways of thinking are focused too narrowly on patterns and binding modes, to the detriment of the language as a whole.

Another framing, rather than changing the binding modes at all, is to defer this sort of "&mut -> &" adjustment to an auto(de)ref-like mechanism at the site of the binding (or the next-inner pattern). This (again) corresponds more closely to how expressions work: we leave the types of deref place expressions alone, even when moving out of them would be an error, and instead we defer that issue to the containing expression, which may trigger a coercion.

When the inherited reference corresponds to the actual skipped reference, the combined model gets simpler: pattern and scrutinee types match, and dealing with shared &muts works the same way in both expressions and patterns. Trying to solve this with binding modes alone is asking too much of binding modes, and in the process pulling in stuff that would otherwise be easy to leave to deref patterns.

rpjohnst avatar May 15 '24 21:05 rpjohnst

@rustbot labels +I-lang-nominated

Let's nominate to ensure that we discuss and review the updates to the RFC that we requested and whether those resolve the outstanding concerns.

traviscross avatar May 15 '24 21:05 traviscross

@nikomatsakis @joshtriplett I fail to see the benefit of "option 1". It feels like making things worse, for the ability to delay decision, even though there is only 1 semantic for mut x = (inhereted reference) which we plausibly would want to have in the future.

i.e. making it an error does not actually help anything, and the state where you have neither the match ergonomics way not the "explicit" way does not sound good to me.

I see how having an explicit way might be desirable, but I don't think it should be a blocker.

WaffleLapkin avatar May 16 '24 16:05 WaffleLapkin

I would still prefer to be able to consistently write a &mut pattern that matches an inherited reference, regardless of whether the binding mode has been converted to ref by an outer &, so long as there is a &mut value involved.

AIUI, what you're proposing would be backward compatible. There is going to be a follow-up to this RFC at some point (to choose a syntax for mutable by-reference bindings, and possibly also to provide a solution for matching &mut behind &), so we can re-evaluate this as well when the time comes.

Jules-Bertholet avatar May 16 '24 23:05 Jules-Bertholet

The reason I'm making this proposal in the first place is because it enables us to defer "& matching against &mut," narrowing the focus of this RFC significantly.

rpjohnst avatar May 17 '24 00:05 rpjohnst

@rfcbot reviewed

Having mulled it over, I think this RFC strikes the right balance on all the questions we need to decide now. I would also like to see us move forward with an implementation in nightly quickly to give enough time for experimentation with the new rules and with the edition migration.

Never setting default binding mode to ref mut behind &

//! All editions: works only with this rule
let &[[a]] = &[&mut [42]]; // x: &i32
//! Edition ≥ 2024: works only without this rule
let &[[&mut a]] = &[&mut [42]]; // x: i32

I would rather have the first option work than the second, especially when considering the alternatives (to binding x: &i32 and x: i32, respectively) described in the RFC.

mut on a binding with a by-reference binding mode is an error.

While I can see that we might want to allow this in the future, it's not entirely clear to me that this is the case. As the RFC states, "there is not much use for mutable by-reference bindings". They seem rare enough that I would guess a significant percentage of the time, they are not actually what the user wants when written, and when used correctly are unclear to the reader.

tmandry avatar May 17 '24 00:05 tmandry

I would also like to see us move forward with an implementation in nightly quickly

ICYMI: https://github.com/rust-lang/rust/issues/123076

Everything in this RFC, including the migration lint, is either already in nightly under an experimental feature gate, or waiting on PR review.

Jules-Bertholet avatar May 17 '24 03:05 Jules-Bertholet

I would rather have the first option work than the second, especially when considering the alternatives (to binding x: &i32 and x: i32, respectively) described in the RFC.

Reviewing this section again, I've realized that I grossly overstated my case. For the let &[[a]] = &[&mut [42]]; // x: &i32 example that works only with the proposed rule, let [[a]] = &[&mut [42]]; would work just as well, whether or not we adopt the rule. I apologize for the misrepresentation, and I've replaced the example with a better one that is more fair.

Jules-Bertholet avatar May 17 '24 03:05 Jules-Bertholet

I would rather have the first option work than the second, especially when considering the alternatives (to binding x: &i32 and x: i32, respectively) described in the RFC.

As Jules noted, it should be backwards compatible with the RFC to accept the let &[[&mut a]] = &[&mut [42]]; // a: i32 example in the future as well. So I don't think we are fundamentally choosing between these x: &i32 and x: i32 examples.

Rather, given that we want to make let &[[a]] = &[&mut [42]]; // a: &i32 work to match let [[a]] = &[&mut [42]]; // a: &i32, our choice is how to match these "downgraded" inherited references when switching back to the move binding mode:

  • The RFC proposes let &[[&a]] = &[&mut [42]]; // a: i32, and then suggests that we allow & to match non-inherited &muts as well: let a: &u8 = &mut 42;.
  • We could alternatively support let &[[&mut a]] = &[&mut [42]]; // a: i32 immediately, and leave "& patterns matching against &mut" (both inherited and non-inherited) as the backwards-compatible future extension.

rpjohnst avatar May 17 '24 04:05 rpjohnst

@rfcbot resolve switch-to-option-1 @rfcbot reviewed

joshtriplett avatar May 22 '24 15:05 joshtriplett