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

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:

I personally don’t think it should be valid for match ergonomics to change the code at all (i.e. I disagree with the feature). But that wasn’t my point. My point was that match ergonomics (as implemented and as defined in the RFC) picks one of multiple valid interpretations of what the user could have intended when writing the code. Deref coercion does the same thing. That’s why I said that I agree that it picks the likely interpretation, but that’s not the same as there only being one interpretation of the user’s intent.

That’s true of almost all language features. Values are dropped at the end of scope even if a user may have interpreted them as dropping after their last use. Inherent methods are prioritized over trait methods when they share a name even though a user may have wanted the trait method. Integer addition panics on overflow in debug builds even though a user may have intended to allow the overflow to occur. The language has semantics, and those may not match up with the picture someone has in their head.

I’ve been looking at this thread, and a conversation I had a while back with a friend about Rust seems relevant. My friend spends most of his time writing C++, but is very interested in seeing how Rust can improve his code’s correctness. C++ is, as mentioned above, pretty infamous for having more features than anyone knows what to do with, so style guides spring up all over the place (the main one that comes to mind is Google C++, which I follow when writing C++). I don’t think this is as big of a problem for Rust, since a lot of these style guides are meant to ensure that thousands of engineers are speaking the same dialect… but he thought that a style guide might want to limit type inference, for readability.

Another scenario I discussed with someone else is finding a good language to write the trusted kernel of a proof assistant. Such a kernel needs to be trusted, simple, and easy to audit, and Rust seems like a good candidate… but it would be even better to have a compile-time guarantee that it’s written in a very stripped-down version of Rust, if, for example, you wanted to disable type polymorphism entirely, so you didn’t have to trust the trait solver. I think #![no_std] is a bit like prior art for this; it instructs the compiler that you do not want to pay the complexity cost of the whole of std that would make your crate unusuable by, say, an OS kernel.

I think a lot of these things would be great to have as something that can be checked mechanically. While it seems tempting to want to be able to have a big block of #![forbid(..)] statements at the top of lib.rs, I think that extending the scope of clippy to be able to outright ban large parts of the language, if a project desires it, is a better alternative, especially if clippy becomes more pluggable (though I don’t really have much background on clippy development). I like to imagine that a company or organization with lots of Rust projects would maintain their own library of aggressive lints for automatically enforcing their style guide.

1 Like

Agreed, with the caveat that it's important to be mindful of how people interpret changes. @ahmedcharles and others are arguing that the match ergonomics have altered Rust's semantics to be incongruent with their mental model.

One problem, as I see it, is they were using sigils in match statements to help them reason through the code, and are now bewildered because, according to their mental model, match x used to be a move, but now it's sometimes a borrow, and match *x used to be a borrow, but now it's sometimes a move. Now, it's true that it never was that way, but it's also true that Rust made it seem to work that way.

Consider that the idiom many people learned for matching on a borrow was let Foo(ref x) = *x (which binds a reference from a dereferenced structure). Match ergonomics make it seem like the ref can be elided, but doing so changes *x to a move. Meanwhile, match ergonomics that look like moves desugar to the pattern let &Foo(ref x) = x (which binds a reference within a reference). Now match ergnomics suddenly allow ref elision, and the pattern is still a borrow! The fact that a borrow is the only possible interpretation doesn't matter, what matters is that the wider inconsistency is surprising.

That surprise is caused, on the one hand, by an incorrect mental model, but, on the other, because Rust idioms encouraged the incorrect model.

2 Likes

Perhaps I'm just failing to communicate this well, so I'll try harder. :stuck_out_tongue:

The distinction in my head is, if I know the semantics of Rust 100% and I'm looking at an expression or statement, how much extra context is required for me to know what the semantics of this expression happens to be.

A variable declaration for a value results in that value being dropped at the end of scope unless it is moved before the end of scope. The compiler doesn't make a choice based on non-local information, which makes it not part of the category of ergonomic features I'm attempting to define.

I actually agree with this one, it's the compiler making a choice based on non-local information. I don't think it's too bad, mostly because traits have to be explicitly brought into scope for them to be considered and there's a chance that the signatures of the inherent and trait method conflict in a way that results in only one compiling. Overall, I think this one is sort of worth it, cause using UFCS all the time would be a bit much.

Integer overflow semantics are completely local and therefore, they aren't part of the category of ergonomic features I'm attempting to define.

Like I clarified above, this my categorization assumes perfect knowledge of the language. The question is about how much non-local context is required to determine the semantics of a specific expression or statement. If I see a statement of the form let Foo { y } = x; what can I know about that statement and the semantics of it? Before match ergonomics, assuming it compiles, the possible valid semantics are quite low. Similarly for foo(&x);, before deref coercion.

Hopefully I've conveyed that this isn't about users not knowing the language well enough.

It's not that it is incongruent. It's two specific things:

  1. Learning the language by using the language is harder because the compiler is less restrictive and therefore, it will accept things that I type even if I don't understand what's actually happening.
  2. More importantly, assuming I know the language perfectly, I now have less information about a given pattern match than I did before because there are less restrictions on the local structure of that match and it's semantics.

These tradeoffs may in fact be worth it, that's not for me to decide, but they are tradeoffs and they aren't specific to me or my experiences.

More accurately, a borrow is the only interpretation based on local changes that result in the expression compiling given the context within which it occurs. For it to be the only possible interpretation, trivial changes to it's context would have to be unlikely to result in it remaining valid code while meaning something else.