Default binding modes - best practices

So yesterday, I (as many probably shall soon) went across my codebase to take advantage of rust 1.26 features, one of which was the default binding modes feature. This basically lets you get rid of a lot of refs.

like these refs:

         match (&self.clean, &self.dirty) {
-            (&Some(ref clean), &Some(ref dirty)) => f(clean) != f(dirty),
-            (&None, &Some(_)) => true,
-            (&Some(_), &None) => false,
-            (&None, &None) => unreachable!(),
+            (Some(clean), Some(dirty)) => f(clean) != f(dirty),
+            (None, Some(_)) => true,
+            (Some(_), None) => false,
+            (None, None) => unreachable!(),
         }

and this ref

-{ iter.into_iter().enumerate().max_by_key(|&(_, ref v)| v.clone()).map(|(i,_)| i) }
+{ iter.into_iter().enumerate().max_by_key(|(_, v)| v.clone()).map(|(i,_)| i) }

and these refs

         let GammaSystemAnalysis {
-            ref ev_acousticness, ref ev_polarization,
-            ref ev_frequencies, ref unfold_probs,
-            ref ev_layer_acousticness,
+            ev_acousticness, ev_polarization,
+            ev_frequencies, unfold_probs,
+            ev_layer_acousticness,
             ev_raman_tensors: _,
             ev_classifications: _,
             layer_sc_mats: _,
-        } = *self;
+        } = self;

and these refs

-        self.0.as_ref().map(|&(ref a, ref b)| (a,b))
+        self.0.as_ref().map(|(a, b)| (a, b))

and... uh, hold on a second. Did I really just write |(a, b)| (a, b)? Yes I did, and it's not the identity function; it's turning a &(A, B) into a (&A, &B). I'm probably going to get confused the next time I see this. I hope this doesn't become idiomatic.

This makes me wonder what other WTFs I introduced by changing existing code to use match default bindings. I wonder, what are the best practices here?


My thoughts are something along the following:

  • You should not deliberately change existing code to use default binding modes. Unless it's really sickening to look at like the clean/dirty example.
  • Regardless, you will still benefit from the mere existence of the feature as you prototype and refactor code, requiring fewer check-edit cycles to get back to a runnable state. Notably, I find that it is common for local bindings of type T to become type &T when extracting a function, and default binding modes will (hopefully) help a lot more code continue to compile even in the face of such type changes.

On the other hand, I would really like if let Some(x) = &self.x to become the idiomatic way of borrowing fields with types like Option (replacing if let Some(ref x) = self.x). It just seems to me that, if that becomes idiomatic, then it's only a short step away from calling this idiomatic:

match &result {
    Ok(x) => Ok(x),
    Err(e) => Err(e),
}
3 Likes

I'm not sure there's anything that can be always idiomatic. Consider:

if let Some(foo) = self.foo() { ... }

versus

if let Some(ref foo) = *self.foo() { ... }

In the second it's immediately clear that I'm borrowing something, not taking ownership of something generated.

Not sure if the word idiomatic applies here, but I think the ground principle should be to make sure intent is clear in code.

Another example might be scenarios like:

match self.foo() {
    (x, 404) => { ... },
    other => { ... }, // other is not a tuple!
}

And I have similar feelings about mixing the different variants in the same fn, especially when they're on the same piece of data.

FWIW, I've always been on the fence about default binding modes. I can see their appeal in terms of newbie-friendliness, but once you understand how the current model works, their presence seems like a readability hindrance.

4 Likes

I personally will be using default binding modes as much as possible. I have the compiler to tell me if something is borrowed or not; it's less important to see something here, IMHO.

1 Like

The compiler tells if things are correct or not. It can't really help minimizing the context one has to read, or the confusion that can arise from reading code and interpreting things wrongly the first time around.

But different people and projects will have different preferences.

1 Like

Yeah, this is basically what I'm getting at. Let's take something similar in a different context:

let x = foo.bar;

Is foo a reference here, or not? (of course you have to have a Copy type for this to work in Rust, but I digress...) If this was C, this works differently:

int c = foo.bar;    // foo is not a pointer
int c = foo->bar;   // foo is a pointer
int c = (*foo).bar; // same thing

One could argue that the -> is important here, as it shows you that you're doing a de-reference. Rust does auto ref/deref here, and the code is much more clear without this distinction. We made you type (*foo) a while back, and it was not fun.

I see default match bindings the same way. YMMV.

1 Like

I’d say in Rust it’s important to know whether you have a T, &T, or a &mut T because they’re treated as different types by the type system. Each of those can have a different impl of some trait, for example. Then we also layer a heavy dose of type inference use. All that can lead to surprises later in the code because the actual type is different from what one may think. There’s a fine line that Rust has been walking in terms of hidden behaviors in the name of ergonomics, and this makes the line even thinner. But time will tell how this pans out.

3 Likes

This is another case of opposite desires when writing code and reading code.

  • When writing code I want to type the absolute minimum, because I know what I mean. The current line of code is in my head and it's obvious to me, so I just want minimum number of keystrokes for the machine to understand me, and I don't want to write any information that the compiler can find by itself.

  • When reading code I want it to be as clear as possible, because I don't know/remember what it does exactly. So I want to read all the details, and I even want to read things that the compiler knows implicitly, because I don't.

It's worth keeping that trade-off in mind, as it comes up often. Another example is lifetime elision — tons of "obvious" <'a>s are annoying when writing code, but elided <'a>s are also annoying when the same code is viewed by someone else via Rustdoc.

There isn't a single right answer for this, so I'm hoping for rustfmt or similar tool to solve this dilemma by automatically inserting "obvious" things when formatting code, so that I can write minimum, and read maximum.

7 Likes

It's of course not unusual for |x| x to not be the identity function either. Or for foo to be different from |x| foo(x)...

1 Like

I see this argument come up in many discussions. But this generalizes to "it's important to know whether you have a T or an U" which Rust will hide by default everywhere but in signatures and declarations, so whether you have a reference or not is the least of your concerns if you're unsure about what type you're dealing with. I do sympathize with semver hazards about types implementing traits in the future, but that's not relevant to binding modes.

I mentioned it mostly to highlight the major difference between @steveklabnik’s C example using a value vs a reference.

@kornel summed up my feelings on the matter pretty well.

2 Likes

This was the first time I heard that default binding modes were implemented let alone close to stabilization, it has been way to long since I read the RFC but I was wondering if it were possible to write this instead as

if let Some(ref bar) = self.foo() { ... }

(where fn foo(&self) -> &Option<_> still), a quick bit of testing in the playground shows that it is. I feel still using ref in patterns, while avoiding * on the value and & in the patterns will commonly strike the best balance between readability and explicitness. The fact that foo is returning a reference to an option of an owned value, vs an owned option to a referenced value, doesn't seem important to know when working out what this code is doing, whereas knowing that bar is a reference value might. (When you're writing/changing this code you might need to know that, but as mentioned before commonly the number of times code is read is >>> the number of times the code is written/changed, and it's just a quick look at the function signature away).

1 Like

Is that something RustFMT can (or will be able to) do? Having elided lifetimes added automatically sounds certainly interesting!

1 Like

Orrrrrrrr...... Hypothetically in magic land you are just toggling your ide editor view.

Great comment.

  1. Can I turn this off?

  2. How do you manage what ultimately gets a lint? Depreciated I know but I mean the decision process and project management.