Allow disabling of ergonomic features on a per-crate basis?

I want type ascription in match to defeat ergonomics.

I’d also like to add another argument: I’m of the opinion that it’s the language with many ways to do things that already causes the fragmentation. Allowing crates to choose the fragment they’re in just creates clarity for developers.

For example, both these are valid decisions to me:

  • We don’t want impl Trait in argument position and always use <T> and where clauses for consistency.
  • We always want impl Trait for internal functions to ease readability.

I disagree with one of them, but they’re both valid decisions a crate can make. Neither are wrong to want a lint to enforce consistency within the project.

2 Likes

If I understand you correctly, you ran into a reference problem when you consumed the match--you thought you were working on a value and it turned out you were working with a reference, so you needed to deref before passing into a function, for example:

pub fn main() {
    let x = &Some(3);
    match x {
        Some(a) => foo(a), // oops! should be foo(*a)
        None => {}
    }
}

fn foo(b: i32) {
    println!("{}", b)
}

Sort of, it’s been a few weeks so I can’t exactly remember it, but it was like:

let SomeStruct { x } = *self; // or maybe it was just self

// call methods on x, unknowing x is itself a reference
// make some changes...
// consume x as value, error because x is not a value, confusion ensues.

Previously the destructuring would have tried to move x immediately, which either would have worked or given me an error to clone/copy it right away, wherein with match ergonomics it secretly became a reference.

Even with something that simple, there is a certain introduced ambiguity that I despise.

1 Like

Another way to phrase this: this change to the language makes it much easier for your mental model of what the code is doing, and the compiler’s model of what the code is doing to disagree. When the two interpretations no longer coincide, this causes confusion for the human, as they’re forced to re-evaluate everything they think they know about the code.

This is why I really didn’t like this change: it’s improving mechanical ergonomics (easier to type) in exchange for impairing mental ergonomics (the thing that’s actually hard).

10 Likes

also relevant: https://github.com/rust-lang-nursery/rust-clippy/issues/2378

hopefully we can get type ascription ASAP

Type inference and deref coercions fall under exactly that same argument, right?

1 Like

I've been silently following this thread, and although I really like the feature in question (i.e. default match bindings), especially since it reduces the random sequence of &, * and ref tokens in my match statements, I do have to share my use-case where I have mixed feelings about it.

I'm implementing a Scheme interpreter in Rust, and I have a lot of structures that implement std::ops::Deref, or use wrappers like Box or Rc. However at times I would have liked to be able to tell the compiler to not automatically call deref behind the scenes, or tell it to ignore all traits implemented for Box and Rc, so that my interpreter close as possible to what happens behind the scenes.

Well, I can say the same about the default match bindings: I love it when developing and cobbling code together, but when it comes to "commit ready code", I would love to be able to tell the compiler to remove the syntactic sugar and switch back to the old explicit behaviour.


P.S.: That is why I also love the & dyn Trait feature (for which I have enabled the warning to tell me when I should have used it), so that I know where my code uses dynamic dispatch instead of using generics.

I agree, problems often crop up when what I believe is occurring and what actually occurs disagree. I've often found, however, that what I think is occurring seldom matters compared to the trust I put in my tooling. For example, when I program Rust, I rarely care about precise lifetimes and references. What I care about is that it prevents me from making mistakes. Put another way, references and lifetimes are tools, not ends in themselves.

From that perspective, I'd much rather see the problems @novacrazy and others point out addressed by improving the tooling (by, say, creating an editor plugin for lifetime tracing, or adding quick-fixes to RLS) than roll back a feature that eliminates boilerplate.

That said, there are aspects of Rust that I'm inclined to gloss over because I rely so heavily on intuition. That's why I reached for an example, above. Understanding the point at which @novacrazy's intuition didn't match the code informs the ergonomic debate, allowing correction in cases where it causes confusion.

This is about as close to a minimal example as I can get:

struct SomeStruct { y : SomeOtherStruct }
struct SomeOtherStruct { z: i32 }

impl SomeOtherStruct { fn frob(&self) { println!("frobbed {:?}", self.z) } }

pub fn main() {
    let x = &SomeStruct { y: SomeOtherStruct { z: 5 } };
    let SomeStruct { y } = x;
    y.frob();
    foo(y) // expected struct `SomeOtherStruct`, found reference
}

fn foo(b: SomeOtherStruct) {
    println!("{}", b.z)
}

In the above case, it's not entirely obvious that let SomeStruct { y } = x; is match ergonomics for let SomeStruct { ref y } = *x let &SomeStruct { ref y } = x. I suspect your example of let SomeStruct { y } = *x; is incorrect, because the compiler complains (as I would expect), about moving out of x. The move error also occurs when foo(y) is replaced by foo(*y).

Assuming I replicated your problem closely enough, I wouldn't blame match ergonomics for this, because it is clear that x (which is analogous to self in your example) is a reference. (In my code, this is clear because I take the reference on the preceding line, while for an inherent method it is clear from the signature.) Because SomeOtherStruct is not Copy, there's no other interpretation of y except that it is a reference, And, if y were Copy, then let SomeStruct { y } = *x; destructures to a copy into y, and the whole thing compiles cleanly.

I should recognize, however, that this is a toy example. This may not be as clear-cut in real-world scenarios.

2 Likes
let S { x } = y;
let x: Expected<..> = x;
// your code here

match x {
    Some(a) => {
        let a: Expected<..> = a;
        // your code here
    },
    _ => {}
}

Yes, I suppose they do. The only counter-argument I can think of is that I only rarely have issues due to type inference or deref coercions, and when I do they're usually pretty easy to resolve.

To give a comparative example: I was around before deref chaining was added. I was slightly nervous about that too, but after it was added I didn't notice any real issues caused by it. It fit fairly well into a mental model of "I want to borrow the contents of this thing", even if the path from what I had to what I was using wasn't immediately apparent.

Since the match thing happened, though, it's already tripped me up three times thus far, and that's while I'm still trying to write explicit code. I feel like the compiler doesn't have my back any more, and it's making me a lot less sure that any given pattern match I write is doing what I think it's doing. There's no consistent visual trigger here: any pattern match could be doing something other than what it looks like.

In the case of type inference, I can avoid it by just explicitly filling in all the type holes in my code. The only way I can be sure that a pattern match is doing what I think it's doing is to go out of my way to try and break it.

Right, but when you make a mistake, you have to fix it. That's where having a mental model that is highly consistent with the compiler helps enormously.

Incidentally, Rust does not prevent all problems, so too much trust in your tools is a bad idea. For example, I actually had a really nasty concurrency bug in my code recently because I trusted Rust too much. It's not that there was a bug in the compiler or hole in the language, it's just that I made a mistake in an area where the language couldn't protect me because I took "fearless concurrency" too much to heart.


As an aside, I don't find arguments about being able to turn this off (via whatever mechanism) causing fragmentation to be very compelling. After all, Rust without auto-ref in pattern matching is just a more explicit form of Rust. Anyone who understands one will understand the other; it's like saying the ability to explicitly write types inside function bodies causes fragmentary dialects because some will write types and some won't.

Actually, I think having explicitness lints in general might be a good idea. That might free up the core team to add more of the convenience features newcomers to the language want, while ameliorating the concerns of those who love Rust because it's more explicit than a lot of other contemporary languages.

Either way, this specific convenience is one I will definitely be excising from my serious code as soon as I can.

8 Likes

This seems to be the crux of the problem. Restated, you find match ergonomics lack the external consistency required for your intuition to guide you to a correct solution. In effect, you're arguing the feature works like 2015 modules--while guided by simple rules, the implications of those rules are non-obvious.

I've encountered this frequently while writing or reading heavily functional code. Scala's implicit arguments and Haskell's "point-free" notation are especially confusing, because they rely a great deal on what's not stated. My intuition says "what you see is all there is", so I often end up going down a rabbit hole of increasingly bizarre error messages. It's especially demoralizing when I find that simple-looking code is filled with unstated assumptions.

I'm interested in knowing which patterns you find surprising. Even better (though it's a lot of effort), would be a screencast or writeup that demonstrates you running into surprising behavior while editing code. If there are particular patterns that cause trouble, these could be used to improve compiler guidance, especially adding lints that offer guidance to bring non-obvious elision to the fore.

I used to work in a bikeshop when I was a kid. I asked the owner to hand me a wrench. He threw it to me, but it slipped and ended up traveling straight at my head. After catching it, I looked up to see anguish painted on his face. He couldn't believe what he'd done. A few years later, I played a practical joke with a knife that could have cut off someone's finger, and found myself in the same position--appalled and filled with regret.

Every tool is dangerous, particularly the ones with safety features. My experiences reinforce the adage "Safety isn't a quality; it's a habit."

2 Likes

This brings up something in my mind. I have a table saw. It comes with a safety-guard. It is impossible to use effectively with the safety-guard attached except for trivial things because it obstructs your view too much. I always remove the safety-guard because it is actually safer to use when I can clearly see everything than with my view obstructed.

What does this story have to do with Rust? I don't know. Feel free to draw your own analogies.

Perhaps Rustfmt could rewrite “ergonomic” match to an explicit one?

There is always a tension between brevity for people writing the code Vs verbosity of code for people reading it.

7 Likes

I should note that in my anecdote, both the bikeshop owner and I knew better. We disregarded warnings and cautionary tales, and only through chance did noone get hurt. The stakes are seldom that high for computer programs, but the story is a memorable one. I admit, however, that it may be too pithy.

The analogy I intended to draw is that I don't trust that Rust will protect me. I trust that Rust reinforces safe habits. It's up to me to develop those habits in the first place.

There is certainly a Koan-like quality to your story.

On the one hand, this clearly could apply to match ergonomics, because match ergonomics can hide reference details. This interpretation says that the match ergonomics are the blade guard, obstructing your view of the borrows and derefs involved.

On the other hand, the inverse is an equally valid interpretation, because match ergonomics improve the visibility of the symbols. This interpretation says that the sigils are the blade guard, obstructing your view of the pattern being matched.

A third interpretation considers the borrow checker as the blade guard, with removal of the guard being analogous to unsafe. This interpretation might point to ergonomic features being acceptable outside of unsafe sections, but mandatory within them, with the argument being that the combination of ergonomics with "unsafety" disables one-too-many safeguards.

7 Likes

I don’t think type inference falls under the same argument, because it’s not really interpreting what you typed and making a choice. Ignoring all other coercion, type inference only deduce 1 correct type rather than 1 of N correct types that it would then have to pick from. If you add in coercions, which I mostly don’t understand, then inference is an issue, but that’s caused by the features mixing.

The issue with deref coercion and match ergonomics is that they make assume what you mean when you type something that could be valid rust if only you added some extra text somewhere. Technically, that extra text could be far away and unrelated to the current statement, but both ergonomics features assume that you meant to change the current statement and do that without you ever having to interact with the feature.

That’s why people who programmed Rust before deref coercion like it. They were forced to learn the language with it and after knowing how it worked, they removed the aspect of the language that forced them to learn it in the first place so they could save typing some characters at the cost me someone like me never actually learning the underlying features, like explicit deref. Your mental model of the language changes when you realize that let s: &str = &string; is really let s: &str = &*string; (I think).

1 Like

When do deref coercions or match ergonomics choose 1 of N correct interpretations?

Match ergonomics is easy:

let x = &Some(1);
let Some(y) = x;

Could be:

let x = &Some(1);
let Some(ref y) = *x;

or

let x = Some(1);
let Some(y) = x;

I agree that one interpretation is more likely than the other, but it’s still choosing.

Deref coercion has similar multiple interpretations, but I’ll agree that in that case, it’s probably more likely to be the local interpretation than any other. My main issue with it is that having it in the language prevents people from learning about derefs in most common cases. Match ergonomics results in explicit derefs being even more uncommon.

But my overall point was to debunk your implication that just because deref coercion follows the same logic as match ergonomics, that it makes match ergonomics ok. I think they’re both problematic for exactly the reasons discussed in the thread.

2 Likes

Why would it be valid for match ergonomics to change a different line of code than the match?

The match ergonomics RFC is very explicit about exactly what will happen: https://github.com/rust-lang/rfcs/blob/master/text/2005-match-ergonomics.md#binding-mode-rules.

Match ergonomics only effect the match arm, so let Some(y) = x desugars to let &Some(ref y) = x. The effect is the same, but it's much easier to reason about when you know the subject of the match (x) doesn't change.

FWIW, I made the same mistake above. I only recognized it because a mind-bending case @phaylon pointed out on reddit forced me to read the rfc :sweat_smile: