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

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.

Something that I’ve noticed in this thread and others:

All of the examples of default binding modes going strange is from let bindings. Sure this may be because of the ease of showing an example with let instead of match, but I’m not sure that’s all of it.

I’ve not seen anyone complain about a case in a match. The feature was “billed” as match ergonomics. I don’t recall any discussion around the RFC using let and not match.

I think it’s possible people are more open to the local infirence of default binding modes in a match than a let. I think part of this is that nothing about a let screams complexity, unlike a match which has multiple moving parts to it. If people learned a let pattern as destructuring rather than a full pattern match equivalent to a match, that could explain the surprise now that let isn’t a simple destructure.

Even if we were to go back to pre-stable with what we know now, I’d argue for keeping let equivalent to a match, however. There’s a good structure to the language that the two are the same.

However, this does mean that there’s potentially unexpected complexity in the simplest of statements – let – that can obscure the hardest part of the language to wrap your mental model around – references. We used to have let Some( x ) = ... guarantee that x was bound by-value from the rhs. We don’t have that anymore.

3 Likes

we could use some sort of type ascription for strictness.

I mean it still wouldn’t apply to deref coercions I guess, but…

@ahmedcharles What is this notion of "non-local information" you're referring to? Match ergonomics relies only on the type of the argument to the match expression, and the patterns in the match expression, so I don't understand how it could be any more non-local than type inference, method auto-deref, deref coercions, etc.

This is also true of almost all language changes.


Personally, I have no strong intuition on whether let Some(x) = &y; ought to make x be a reference to y or the value y itself. Both seem like reasonable options, based on past experience with pattern matching and destructuring (mostly because I've never used a language that combined references/ownership with destructuring/pattern matching before Rust). In the context of match statements, there was a very strong argument that x being a reference is almost always what you want, so having that be the behavior in let as well just seems consistent to me.

It's valuable to tease out the exact sources of confusion, if only to help existing Rustaceans update their mental models of pattern matching (I agree with @CAD97 that it seems to be only let's behavior that's confusing people), but so far I'm not seeing anything that makes me think match ergonomics were a bad idea or need to be destabilized or deserve a special off switch when almost no other stable feature has that.

2 Likes

I think you might be missing a key interpretation here.

To use the same example mentioned earlier:

So, one interpretation is what "match default bindings" does: "oh, you must want to match a reference".

Another interpretation is "you got the type wrong, here's a helpful compiler error so that you can fix the types to match".

I've seen multiple people argue that this change will only ever make code compile that didn't compile before. And even if that's true, that's the problem. People who don't want this change, myself included, want the compiler to keep refusing to compile that code. That's what people mean when they say "it feels like the compiler doesn't have my back anymore".

I don't want the compiler saying "there's only one thing you could mean here, I'll guess you mean that". I want the compiler rejecting the code with a type error and making me fix it so that readers of the code can easily see how the types all match consistently.

7 Likes

Sure, and my issue with that argument is that the compiler already does exactly that kind of thing in a ton of contexts! For example:

fn f(_: &str) {}

let s = Arc::new(Rc::new(String::new()));
let s = &mut &s;

f(s);
// equivalent to
f((*s).deref().deref().deref());

Method resolution is another example.

It's not clear to me why this specific addition is particularly different than any of that.

3 Likes

I agree with this statement, but believe that, to advance the discussion, we must either:

  • define what constitutes a trivial change then see if the match changes meaning given the trivial change, or
  • find examples of matches whose meaning changes with the context, and decide whether the change falls within the trivial category.

That is, I agree in principle, but the proof is in the pudding. As @Ixrec points out, pattern matching is localized. Further, in the cases mentioned thus far, most changes caused compiler errors.

There is one case that irritates me: match ergonomics allow partial elisions. It seems to me that, for any given type, either all references should be elided or none. Consider the following (admittedly silly) example:

let x = &&&&Foo(5);
let &&Foo(y) = x; // this should be linted to `&&&&Foo(ref y)`
let Foo(z) = x; // this is fine.

References, insofar as match ergonomics are concerned, are part of the type. Josh's argument, as I understand it, is that he wants to match the type to ensure that he can use it to reason through the implications of the match.

1 Like

Deref coersion removes references. Default binding modes adds them. That's the key difference. Deref<Target=X> is expected to function as X. Removing references leads to easier functionality with less *s scattered around. Adding references leads to fighting the borrow checker without having &/ref to highlight borrows.

1 Like

Deref coercion goes from a &T to a &U where there’s a path through derefs from T to U. Default match binding modes also go from a &T to a &U where T contains a U you’re binding to in the match, right?

1 Like

This might be an intuition worth digging into, because I feel very differently.

To me, part of the reason match ergonomics are so natural is that the novice tends to write their match blocks and patterns solely to destructure the "underlying type" T, implicitly assuming the "ownership status" (T, &T, &mut T, etc) of that type is irrelevant. I vividly remember that "why does ref exist when we have &?" was one of the very few questions I genuinely struggled with for days when I was new to Rust, pretty much for this reason. My intuition regarding match ergonomics is that we've simply accepted this intuition as correct: pattern matching should only destructure the underlying type without changing the ownership status, unless you explicitly ask it to. By default, destructuring an owned value gives you smaller owned values, destructuring a & gives you smaller &s, and destructuring a &mut gives you smaller &muts.

Now, the reason why this feels like a natural move for the Rust language overall to me is that I believe we were already heading in the direction of this sort of mental model with features like deref coercion, method auto-deref, and &mut to & coercion. To further generalize the previous paragraph: Rust does not expect you to write & and * everywhere that an address-of or deref operation occurs, like you would in C. Instead, Rust typically only wants you to have to explicitly write & in type/function signatures or when there's a meaningful and deliberate transition from owned value to borrowed value.

6 Likes

It depends on what you're applying the coersion to, I suppose... In the standard let Some ( x ) = ... it seems the standard (faulty?) intuition is that x is going from a (compile error) by-move binding to a (coerced) by-ref binding.

You've put into words exactly where my position lies. This is exactly the behavior I want from matches. Probably if let and for as well. Now that I'm specifically thinking about let as a pattern match, maybe even let. The problem comes with let where the rhs is (unexpectedly) a reference -- previously this would be a compile error at the let, where now it's a compile error further down the line than where the actual error was (the coerced by-ref binding as opposed to expected by-value).

1 Like

This might be a critical difference indeed, because when I first started working with Rust, my assumption was "ownership status is ludicrously important and the compiler is providing me with tons of help to make sure I distinguish between owned and borrowed values".

I've run into multiple practical cases where I accidentally ended up with a &&str or even &&&str (not too hard if you use iter() instead of into_iter()), and the compiler caught that when I tried to either 1) match on that or 2) pass that to something expecting a &str.

4 Likes

The way I've always felt, the accumulation of &-borrows is an unfortunate byproduct of how these structures work rather than a desired part of the language. Without a &mut in there, &'a &'b &'c &'d _ is functionally equivalent to &'e _ where 'e is the shortest of the other lifetimes for 99% of functionality. There's no reason to chase four pointers. (Unless you're giving it to a receiver expecting that level of indirection with those longer lifetimes.)

With &mut that changes a bit as that's a point in the indirection where you can write. But with read-only refs, the number of them is more an implementation detail than an important detail I (rather than the computer) needs to care about.

In the basic model that I use 99% of the time (read: not using unsafe), there's three states I care about: Owned, Shared, and Mutable Borrow. Multiple stacks of Shared don't change functionality, so they should collapse in my model.

1 Like

All the other cases where the compiler does this sort of stuff aren’t significantly different and I personally don’t like those either. I’m not sure why referencing other language features that are older would somehow change my opinion of the current feature under discussion.

2 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.