Is `ref` more powerful than match ergonomics?

Since the switch to match ergonomics, I literary haven't use ref and ref mut syntax even once (outside of parser tests).

Are there cases where ref and ref mut are genuinely more expressive, or can we, at least in theory, remove them from the language?

7 Likes

I'd have imagined a pattern match where there is a bind-by-move on one hand, and a bind-by-ref on the other, such as in:

fn main ()
{
    let mut it = (Some(vec![42]), Some(vec![]));
    if let (Some(v), Some(ref mut vs)) = it {
        vs.push(v);
    }
    dbg!(&it.1);
}

but sadly this currently fails with:

error[E0009]: cannot bind by-move and by-ref in the same pattern

And yet, there is a:

This limitation may be removed in a future version of Rust

in the detailed error message.

(And yes, replacing = it by = (it.0, it.1.as_mut()) works, and does not require refs, but that is implicitely performing one more nested pattern match on the .1 field, so I think it would qualify as less expressive).


A similar code that works right now is when the by-move is Copy (since it can still be by-ref under the hood, I imagine):

let mut it = (Some(42), Some(vec![]));
if let (Some(v), Some(ref mut vs)) = it {
    vs.push(v);
}
dbg!(&it.1);

which, imho, is a bit more expressive than:

let mut it = (Some(42), Some(vec![]));
if let (Some(v), Some(vs)) = (it.0, &mut it.1) {
    vs.push(v);
}
dbg!(&it.1);

which has required explicitly splitting the matched expression.

  • to my surprise, the following does not work despite match ergonomics:

    if let (Some(&mut v), Some(v)) = &mut it {
    
    • and even if it did, that would still be 2 x &mut vs. 1 x ref mut.
1 Like

The second example can be written as

fn main() {
    let mut it = (Some(42), Some(vec![]));
    if let (Some(v), Some(vs)) = &mut it {
        vs.push(*v);
    }
    dbg!(&it.1);
}

I would actually expect being able to write it as

fn main() {
    let mut it = (Some(42), Some(vec![]));
    if let (Some(&mut v), Some(vs)) = &mut it {
        vs.push(v);
    }
    dbg!(&it.1);
}

but that doesn't work.

I would actually expect being able to write it as

I completely agree. I opened an issue about this counterintuitive behaviour here: Match ergonomics means bindings have different types in patterns and match arm; cannot deref references in pattern · Issue #64586 · rust-lang/rust · GitHub. Unfortunately, it looks like correcting it would be a breaking change.

3 Likes

Hm, isn't it exactly in the case where we switch from "macth ergonomics" mode to "match classic" mode, due to ref/ref mut/mut keyword?

That's actually is my "hidden agenda" here -- I've noticed this counter-intuitive behavior (and also this:

let x = "Rust".to_string();
match x {
    _ => (),
}
dbg!(x); // valid

Here, match doesn't move out of x, although it looks like it should
)

I haven't dug into this yet, but my gut feeling is that weirdness comes up when old and the new models interact, and, if we edition-away the old model, we can get something smaller and more intuitive.

2 Likes

_ is a special pattern that doesn't consume what it is matched against. This makes it much easier to immediately drop something while still silencing the unused_must_use lint. For example

struct NoisyDrop(&'static str);
impl Drop for NoisyDrop {
    fn drop(&mut self) {
        println!("dropped {}", self.0);
    }
}
let _ = NoisyDrop("underscore");
println!("a");
let _foo = NoisyDrop("foo");
println!("b");

will print

dropped underscore
a
b
dropped foo

not

a
b
dropped foo
dropped underscore
5 Likes

Yes, but it's not what happens here. I would say it is the opposite of the describe problem, if it wasn't orthogonal altogether :smiley:

What I perceive as "wrong" is that anywhere in the language expression x means "move x", except for matching expressions , where you need to look at the patterns understand what's happening.

What I like about match ergonomics most is that now match x means "consume", match &x means "look" and match &mut x means "touch", except for the cases where the old model strikes back. The above example is this case: the _ doesn't bind anything, so the overall expression is not consumed.

3 Likes

Depending on one's definition of "expressive", I find them a lot more expressive in basically all cases, and I sincerely hope they don't get removed from the language. That would force on me the use of the weird thing that is "you can match a reference with a non-reference, covertly turning other non-references into references".

7 Likes

match does not work on rvalue expressions, it works on lvalue expressions (place expressions). This cannot change because we currently allow matching on slices directly.

let x: Vec<String> = ...;
match *x {
    [] => {},
    [ref mut a, ref b, .., ref c, ref mut d] => {}
    _ => {}
}

So the intuition that match x { ... } always moves x is wrong, as you can't move unsized values directly.

2 Likes

Hm, I still think that it doesn't need to be wrong under match ergonoimcs? Like, the above example could be written as

let x: Vec<String> = ...;
match &mut *x {
    [] => {},
    [a, b, .., c, d] => {}
    _ => {}
}

where there are no unsized values, and where it is immediately clear, looking only at the head of the match, that we are going to mutate x (and that we actually miss a mut on let x).

1 Like

Also, there are cases where match ergonomics don't work, a simple example:

if let Some(x) = self.foo.as_mut() {
    x.mutate();
    return x
}

self.foo = Some(bar());

match self.foo.as_mut() {
    Some(x) => return x,
    None => unreachable!()
}

Which would work if you did

if let Some(ref mut x) = self.foo {
    x.mutate();
    return x
}

self.foo = Some(bar());

match self.foo {
    Some(ref mut x) => return x,
    None => unreachable!()
}

playground, while yes it is possible to rewrite this exact code to work (as seen in works_1), it may not always be feasible. For example, if you are using a custom enum with a large number of variants.

In any case, the fact that matches use place expressions has already been documented in the reference here

A match behaves differently depending on whether or not the scrutinee expression is a place expression or value expression. If the scrutinee expression is a value expression, it is first evaluated into a temporary location, and the resulting value is sequentially compared to the patterns in the arms until a match is found. The first arm with a matching pattern is chosen as the branch target of the match , any variables bound by the pattern are assigned to local variables in the arm's block, and control enters the block.

When the scrutinee expression is a place expression, the match does not allocate a temporary location; however, a by-value binding may copy or move from the memory location. When possible, it is preferable to match on place expressions, as the lifetime of these matches inherits the lifetime of the place expression rather than being restricted to the inside of the match.

(emphasis mine) This bolded text shows precisely why we can't switch over to match ergonomics, as it would be less expressive.

5 Likes

Thanks, these are indeed examples I was looking for. I must confess that my rvalue foo is not strong enough to immediately grasp why the two cases need to behave differently, I guess I need to sit with a reference for a while now...

1 Like

The "match ergonomics" discussions from when it came up are full of examples of good things you can do with ref and ref mut and why the ability to be explicit is great.

As I'm currently working on a lint that restores the stricter pre-match-ergonomics patterns, I'd really hope for the ability of control you get with ref and ref mut to not be removed.

The most important feature of ref and ref mut to me is that I can take a &mut T binding and inside the pattern drop the requirement of mutable access down to a &T. If I lost this, it would make my code worse.

I'm also kinda saddened and frustrated that I'm once again having to explain this. It's probably not helping that our arguments back then were kind of put on the sidelines. The book just all puts it under a "Legacy" label even though we made a good case that it isn't legacy, and many people still use the features.

8 Likes

My idea on this as someone who’s still fairly new to rust: The problem you have is caused by the fact that people almost never see ref or ref mut, as demonstrated by OP.

I’ve fairly recently, when learning rust, read through most (if not all) of the rust book and don’t remember any examples involving explicit ref or ref mut (if there were any, they weren’t very prominent). Perhaps that is something that could be improved.

I would agree. It's a useful and powerful tool that's been sidelined. That's the reason for the frustration.

I'm going to try and be very diplomatic here: The book is very opinionated and doesn't show the full language. I think it still doesn't mention that block comments (/* ... */) and their doc variation even exist. The same team decided that ref and ref mut are out of the spotlight.

3 Likes

I am specifically looking for "more expressive / more powerful" things, not for "good". I'd be glad to see some specific examples of increased expressiveness.

The most important feature of ref and ref mut to me is that I can take a &mut T binding and inside the pattern drop the requirement of mutable access down to a &T .

I understand that point of view. However I'd like to make a case that my personal preference for match ergonomics comes from that same underlying value of explicitness. I find match ergonomics more explicit than match classic. The reason is that you need explicit &mut in match &mut x for mutation.

consider iteration:

// clearly consuming xs:
for x in xs {
    ...
}

// clearly mutating xs:
for x in xs.iter_mut() {
    ...
}
for x in &mut xs {
    ...
}

// clearly borrowing from xs
for x in xs.iter() {
    ...
}
for x in &xs {
    ...
}

now consider match ergonomics:

// clearly consuming opt
match opt {
    ...
}

// clearly mutating  opt
match &mut opt {
    ...
}
match opt.as_mut() {
    ...
}

// clearly borrowing from opt
match &opt {
    ...
}
match opt.as_ref() {
    ...
}

now consider match classic:

// this ... can do whatever
match opt {
    ...
}

match opt {
    Some(x) => // consume,
    Some(ref x) => // borrow,
    Some(ref mut x) => // mutate,
}

So there are two kinds of explicitness involved: "caller explicitness" , and "callee explicitness". I do personally find caller-explicitness more important (as it is more modular), and also more in line with how other parts of the language work. I personally don't find callee explicitness in this case as valuable: I am more than happy to write for x in &mut xs rather than the more explicit for &mut ref mut x in &mut xs.

I hope this makes my position more clear :slight_smile: I am all for being more explicit, I just think that match classic makes the less important part explicit, and the more important part implicit.

12 Likes

Just out of curiosity, since I couldn’t come up with anything myself. Are there other examples that will not be fixed by polonius?

1 Like

Here's an example: Match Ergonomics Using Default Binding Modes by cramertj · Pull Request #2005 · rust-lang/rfcs · GitHub

Like I said, there's a long tail of examples and discussions about it. I find it more expressive when used explicit because it allows me to express intent. I act mutably on some parts of a type and non-mutably on others, this way that intent is encoded at the destructuring site, where people go when they want to know where a value came from. It puts the binding in a mut/non-mut relation with the other bindings.

It's more powerful than not doing it because to achieve the same you have to reborrow as shared afterwards. So instead of one expressive binding pattern, we now have a binding pattern not expressing intent, some following line doing a reborrow, and then any actual usage of the restricted value. This no longer communicates intent.

It's like let mut value = 23 versus changing the language to be "always mutable" and use let value = 23 and make mut implicit. Everything still works, but we'd have lost something great.

I really wish any of this would have made the book.

6 Likes

In my codebase I've found only one case where I used ref for real:

for &(i, ref v) in &[(1, vec![])] {
}

Using just (i, v) gives me &i32, which I don't like. And (&i, v) doesn't work as a pattern here.

It's not strictly necessary as I could dereference the integer later.

I remember when I was learning Rust the ref keyword was super confusing for me, so I'm happy it's mostly gone.

Patterns being a "dual" of expression is too nerdy IMHO. I'd prefer them to mimic expressions instead (so that * in patterns would dereference just like everywhere else, instead of & having two meanings).

10 Likes

I tried adopted @matklad's perspective a couple weeks ago when we had a similar conversation on reddit, but I ultimately went back to "classic match." I just find it much clearer to see ref or ref mut at the destructuring site, because that's where my eyes go in order to figure out where a variable is coming from. If I use "new match," then my eyes need to scan back up to the subject of match.

This could be a bias, and maybe I'll change my opinion over time (I still intend on continuing to try "new match"), but as of now, I find "classic match" more readable.

14 Likes