Is `ref` more powerful than match ergonomics?

Indeed, there is this tension between write-optimized terse syntax and read-optimized "explicit" syntax.

We don't have a clear solution for this in Rust. My impression is that Rust ends up following Stroustrup's Rule:

  • For new features, people insist on LOUD explicit syntax.
  • For established features, people want terse notation.

Rust started with a match on every Result, and ended up with ?. As lifetimes became more familiar, Rust got more lifetime elision. When pattern matching got more familiar, Rust got match ergonomics. Although I think there's a bit more to it in the match ergonomics case, because the annoying-to-write details are often (but sadly not always) unimportant.

Can Rust do something out of the box here? Instead of fighting for every feature whether it should be "explicit" or whether it should be easy/convenient/not-noisy/etc., find a way to have both?

My first idea is to have rustdoc and rustfmt expand syntax sugar, so people can write terse notation, and let others read verbose notation.

6 Likes

Frankly I think it was an unfortunate unintended accident, caused by a little-discussed "quick fix" after the RFC was merged, that we wound up with this particular behavior, but resolving it is blocked because the lang team has several ideas for how to do so and hasn't been able to reach consensus on which one(s) to use.

(Edit: it seems the discussion on this point is rather scattered and hard to find, so here are the still-open "roots" of what I'm aware of since that tracking issue:

)

If/when some solution is reached there, the compiler could suggest it, at least. That would address @josh's wish (which I share) for the suggestion to point to the pattern rather than the use.

4 Likes

My intent was to include making that code work in addition to making the lint happen in "making this work"; I was already aware that as written the pattern does not work. The working pattern in this case would be &Some(val).

I would note that his entire post doesn't contain the word experience. When I read it, I didn't think that either group would be more experienced, they would just have worked on different projects. He does use the word wisdom, but it's not possible to simplify wisdom into experience.

I think that the analogy here doesn't really work:

  1. Result matching vs ? doesn't change explicitness, it changes loudness (to use your quoted terminology). ? is transformed into precisely the same match syntax/semantic every time and more importantly, it didn't allow existing syntax to have a different meaning.
  2. Lifetime elision also maps a specific lack of lifetimes to one precise lifetime being used in a single way, which then has to succeed at compilation or fails. It doesn't allow the compiler to produce different results from the same local input. It also didn't allow existing syntax to have a different meaning.
  3. Match ergonomics allows the same syntax to have multiple meanings based on the declaration of a non-local type. It also allowed existing syntax to have new semantics.

One of the important things that much of the pro-ergonomics perspective seems to leave behind is that error messages teach people the semantics of a language as much as books do. I actually prefer error messages when they reinforce making the right decisions and I consider by-value/by-reference to be an important decision.

5 Likes

Here is a case where ref is more expressive. This code treats foo as a lazily-initialized field. (originally posted here):

struct Container {
    foo: Option<i32>,
}

impl Container {
    fn foo_mut(&mut self) -> &mut i32 {
        if let Some(foo) = &mut self.foo {
            return foo;
        }

        self.foo = Some(0);
        self.foo.as_mut().unwrap()
    }
}

That doesn't compile. But if you apply this diff, it works fine.

-        if let Some(foo) = &mut self.foo {
+        if let Some(ref mut foo) = self.foo {
4 Likes

FWIW, that pattern can use Option::get_or_insert, and you get a stronger guarantee to optimize away the unwrap(), as it uses hint::unreachable_unchecked().

This is basically the same thing like the example earlier in this thread:

Both of which will be fixed at some point in the future.

1 Like

My apologies if this offended. I really don't think the point I was trying to make was adequately made with this rather loose and clumsy analogy. It sounded OK at the time I wrote it, but, now re-reading it, I think my point got lost. The point I was ineffectively attempting to make is that those who are initially or still opposed to a particular ergonomic initiative are probably, at least initially, doubly-bewildered by any suggestion of removing the original thing that the ergonomic initiative is what they (at least perceive to) believe is simply syntactic-sugar for. This results in a feeling of being put on the defensive and makes it difficult to discuss the matter fully objectively. It's just something to consider when explaining the rationale for these kinds of proposals.

6 Likes

Interesting example! That said, the fact that the original version gives you an error is basically a bug in the borrow checker that I hope we can correct in Polonius. Kind of surprising to me that the ref mut variant works, actually, though I think I know why that comes about.

UPDATE: To be clear, when I say "bug", I mean "it's a known limitation of the borrow checker that I hope we can lift in the future".

2 Likes

This is true of a large portion of Rust's syntax, because Rust employs both type inference and type-based dispatch:

  • Methods and field accesses
  • for loops
  • Every overloadable operator (including most bin-ops, dereferences, ?, and await)
  • Generic function calls

Probably there are more. Basically every construct but the boolean constructs is type-overloaded. Match ergonomics is constrained to operations on reference types, unlike most of these which can be overloaded in the manner of arbitrary types.

2 Likes

I agree. My point wasn't that this should be avoided at all costs. I was pointing out the rather glaring difference between match ergonomics and ?, for example.

Granted, I don't really view ? as 'doing different things', because it simply does:

match <expr> {
    Ok(v) => v,
    Err(e) => return Err(e.into()),
}

The fact that Into does different things on different types or that match works on different types doesn't really mean that ? does different things.

Anyways, I wasn't attempting to make a generalized point. Sometimes features like type inference are very useful and sometimes they aren't worth the tradeoffs. We probably disagree about whether this is true for match ergonomics, but that's fine. I understand that it's nuanced and tradeoffs are always made, regardless.

1 Like

That's not the desugaring of ?- it goes through the Try trait as well as Into, doing different things based on the type of the operand. (This is how it supports Option, for example.)

1 Like

Also, the desugarring using From not Into, and this won't be changed because it causes too many inference failures.

Do what?

Describe code style as an actual illness that affects real people. It's also not really correct about said illness, and perpetuates that incorrectness. It's inappropriate for a number of reasons.

1 Like

I knew this, I've looked at the compiler code that implements it before. My point wasn't to be pedantic about the specific desugaring but to point out that it has a single fixed desugaring.

1 Like

But that's just it. It doesn't have a single fixed desugaring any more than + does. Even ignoring error conversion, it invokes Try::into_result on its operand before the match- the only reason it even includes a fixed match is that into_result can't do a non-local return on its own.

So going back to your original claim, it also "allows the same syntax to have multiple meanings based on the declaration of a non-local type," which can decide completely arbitrarily when to return early and what to "unwrap" to.

I read boats' post not as quibbling about ?, but as emphasizing that binding modes in fact do far less than what you claimed. They give identifier patterns one of a fixed set of two meanings- by-reference or by-value, determined by how the local pattern corresponds to the type of the scrutinee. This is even more limited than autoderef, which can use Deref impls- there is no open set of possible meanings, not even one locked behind an unstable trait like ?.

Your other point about giving existing syntax new semantics is also arguably missing the point made subsequently by boats. Lifetime elision takes existing syntax which used to be an error because of a type definition elsewhere and gives it a "reasonable" meaning. The same is true of field projection and method call syntax, thanks to autoderef. Binding modes similarly take an existing syntax which used to be an error, again because of a type definition elsewhere, and give it a "reasonable" meaning.

That is, with or without binding modes, non-local changes to the types in question can change the meaning of a pattern, without necessarily producing a compile-time error. The same is true but to an even greater degree of ?, lifetime elision, autoderef, and everything else boats mentioned. This is not a new problem unique to (or even especially pronounced for) binding modes, which is why we already had ways to mitigate it, like limiting the reach of type inference.

4 Likes

I think we're talking past each other a bit. The ? operator doesn't do type inference, the fact that it desugars into a From does. Basically, ? can be implemented as a macro_rules macro, which means that it's syntax is ultimately transformed into precisely one set of tokens implemented in terms of other language features. Those features may have inference or other implicit behaviors, but ? does not. Hopefully that explains the disconnect.

1 Like

@ahmedcharles same is true for +, which simply desugars to a call to Add::add. A macro_rules! macro could do that, too. (I got the impression you are arguing that ? is different? Maybe I misunderstood.)

Exactly! As far as I can tell, c and d are calculated with equivalent semantics:

fn main() {
  let a = 1;
  let b = 2;
  let c = a + b;
  println!("{}", c);
  let d = std::ops::Add::add(a, b);
  println!("{}", d);
}

The fact that traits use type based dispatch and that type based dispatch may or may not involve locally visible types at the point of the function call, doesn't mean that + does anything more complicated than desugar into a fixed usage of other language features. Same with ?. Match ergonomics however, doesn't share this property.

1 Like