Try   HackMD

Design Meeting: Match Ergonomics 5

Context

At the beginnings of rust, patterns had to correspond to the matched type reference-for-reference, with patterns like &(x, ref y). This form is what we call a "fully explicit"/"fully desugared" pattern. x/ref x/ref mut x is called the "binding mode" of a binding.

RFC 2005 "Match ergonomics" allowed patterns to omit &/&mut, causing a "default" binding mode to be deduced from the type. That default can still be overriden with an explicit ref x/ref mut x.

After many years of experience with RFC2005 match ergonomics, two issues have come up many times, prompting us to rethink match ergonomics:

Current status

We have had a fair amount of previous discussions on the topic:

Our consensus so far (AFAICT):

  • We're ready to alter how match ergonomics work (this leaves the behavior of fully-desugared patterns unchanged);
  • We want to fix "mut resets binding mode";
  • If SomePattern(x) gives x: &T, we want SomePattern(&p) to be allowed (relevant issue), and same for &mut T/&mut p;
  • We settled on "eat-one-layer" for the following case:
if let Some(x) = &Some(&0) {
    // `x: &&i32`
}
if let Some(&x) = &Some(&0) {
    // Today aka eat-two-layers: `x: i32`
    // eat-one-layer: `x: &i32`
}
  • As a convenience bonus, we want &p to be allowed on &mut T.

Question: do we agree on that?

There remain undecided points around:

  • Further edge-cases within eat-one-layer;
  • Should we allow let Some(mut x) = &Some(0)?
  • Should we allow let Some(ref x) = &Some(0)? what about let Some(ref x) = &mut Some(0)?
  • "Rule 3" i.e. should we downgrade &mut inherited references when we know they'll cause a borrow error;

I would like to discuss these and find consensus on them.

Edge cases of eat-one-layer

"eat-one-layer" is the idea that:

  • if let SomePattern(x): U gives x: &T, let SomePattern(&p): U should be allowed for appropriate p;
  • if let SomePattern(x): U gives x: &&T, let SomePattern(&x): U should give x: &T regardless of U (instead of sometimes x: T as is the case in stable rust).

This by itself leaves some edge-cases unspecified. Some terminology first: in the presence of a non-move default binding mode (aka an inherited reference), there are two types at play:

let [x]: &[T] = ...;
//         ^ in-memory type
// x: &T
//    ^ "inherited" reference
//    ^^ user-visible type

Here is the choice to make: should a &p/&mut p pattern be generally matched against the in-memory type, or the user-visible type?

Some points to consider:

let [x]: &[&mut T] = ...;
// both        : `x: &&mut T`
let [&mut x]: &[&mut T] = ...;
// in-memory   : `x: &T`
// user-visible: type error
let [&x]: &[&mut T] = ...;
// in-memory   : `x: &T` because `&p` is allowed to match `&mut T`
// user-visible: `x: &mut T` (can't copy `&mut`)

let [x]: &mut [&T] = ...;
// both        : `x: &mut &T`
let [&mut x]: &mut [&T] = ...;
// in-memory   : type error, or fallback to `x: &T`
// user-visible: `x: &T`
let [&x]: &mut [&T] = ...;
// in-memory   : `x: &mut T` or `x: &T` depending on rule 3
// user-visible: `x: &T` because `&p` can match `&mut`

let [&(mut x)]: &[&T] = ...;
// in-memory   : error on `mut` binding with inherited reference because we keep the outer inherited reference
// user-visible: `x: &T` because we eat the outer inherited reference
  • eat-one-layer is defined and justified in terms of the user-visible type;
  • Matching the in-memory type first is what has been called "primacy of structural matching"; it's what RFCs 2005 and 3627 do;
  • "in-memory first" implies that manual monomorphization does not preserve type-checking of patterns:
fn first<T>(slice: &[T]) -> Option<&T> {
    match slice {
        [] => None,
        [&ref x, ..] => Some(x),
    }
}
// =>
fn first_mono(slice: &[&u8]) -> Option<&&u8> {
    match slice {
        [] => None,
        // `ref` is under a non-`move` default binding mode. Depending on what we choose below for this case:
        // - option 1: this is an error by itself;
        // - option 2: type error because `x: &u8`;
        // - option 3: borrow error because `x: &&u8` was obtained from `&&*(*slice)[0]`, which borrows a temporary.
        [&ref x, ..] => Some(x), // ERROR
    }
}

mut x on inherited references

Question: should we allow let [mut x]: &[T] = ...;? (In 2024 so far, this is an error). Per previous considerations, we want x: &T; this requires either:

  1. Stabilizing a syntax like mut ref x or ref (mut x) so that this pattern can be desugared;
  2. Giving up on the ability to desugar match ergonomics to stable rust syntax.

Points to consider:

  • Option 2 make match ergonomics able to represent more patterns than non-ergonomic patterns;
  • Desugaring is useful as a way to explain what match ergonomics "really does", but the explanation still mostly works even with that edge case;
  • The two options are identical wrt implementation complexity, in either case the compiler will have to represent the mut ref x case internally.

ref x on inherited references

Question: should we allow let [ref x]: &[T] = ...; etc? (In 2024 so far, this is an error). There are roughly three options:

  1. Always throw an error.
  2. Reset the binding mode i.e. override the mutability of the inherited reference; gives x: &T here.
  3. Create a temporary and borrow it; gives x: &&T here.

Points to consider:

  • Option 3 is at odds with how Rust usually does things;
  • Option 2 is useful for the case of downgrading an inherited &mut to &:
let [ref x]: &mut [T] = <expr>;
// With option 2: `x: &T`
// Without option 2, one would have to write instead:
let [&(ref x)]: &mut [T] = <expr>;
// or immutably borrow the scrutinee if applicable:
let [x]: &&mut [T] = &<expr>;
  • Options 1 makes pattern macros (particularly with guard patterns) harder to write; with options 2 and 3, ref x is always allowed and gives a &T;
  • Option 3 is more uniform for the purposes of type inference: it treats inherited and normal references the same. so "ref always adds a layer of reference", and conversely "removing ref always removes a layer of reference".

I propose option 2.

Rule 3

Take the following:

let &[x]: &&mut [T] = ...;
// Desugars to:
let &&mut [ref mut x]: &&mut [T] = ...; // borrow error

Here [x] is matched against type &mut [T], which causes an inherited &mut T reference. Because we're under a shared reference, we end up with a borrow error.

In RFC3627, TC proposed "rule 3": if the pattern is &p, while processing p downgrade all inherited references from &mut to &. The example would desugar instead to:

let &&[ref x]: &&mut [T] = ...;

Some points to consider:

  • This avoids some borrow errors;
  • If we allow option 2 for ref x, adding a ref would suffice to fix the borrow errors in question instead;
  • This does not solve all similar-looking errors, e.g. let &[x]: &[&mut T] is still an error;
  • This is only relevant when using match ergonomics under explicitly handled references. It does not apply if a pattern is fully explicit or if a pattern is fully match-ergonomic or if a pattern starts ergonomic then adds some explicit dereferences.
  • Cases where the two options differ, without rule3 the left and with rule3 on the right, based on my otherwise prefered choices for the options above. You may increase type depth to have more examples.
  • let &[&mut x]: &&mut [T] becomes a type error;
    • let &&mut [x]: &&mut [T] is still allowed, which may feel inconsistent;
  • This makes typechecking less "local"/"compositional": this requires an extra bit of state when formalizing and implementing match ergonomics;
  • We can leave this undecided for now by erroring in cases where we would downgrade to &.