I have been reading and thinking a lot about the topics on this thread. I think that this comment by @WiSaGaN is very interesting. Even though I think that @WiSaGaN was basically pushing back against many of the proposals that I support, I actually agree with a lot of what they are saying here:
In particular, I agree with the end. We do not want to make “ergonomic improvements” that make it harder for programmers to spot easily noticeable bugs or to control performance. In other words, while I want to focus on productivity, I do not want to do so at the expense of reliability or performance. What we should strive for are positive sum changes that improve productivity without costing us elsewhere.
I think where @WiSaGaN and I may differ is in two areas:
-
How feasible is it to find such positive sum changes? I feel like when I look back over the history of Rust, examples pop up all over the place. We’ve seen a number in this thread, but I’ll dig into one particular example in a bit.
-
Who will benefit? It’s easy to think that new users want to ignore details whereas experienced users want to control them. But I think that’s wrong. I think that what all of us want, new and experienced alike, is to control the details when it is important and ignore them the rest of the time. So we should strive for ways to give people control, but in as simple and minimal a way as possible.
A case study: deref coercions
There have been a number of examples thrown out, but I’ll add another one: at some point, we added “deref coercions”, which basically allow you to write &x instead of &*x or &**x and so forth. The nice thing about deref coercions is that they allow you to think about your code at the level of ownership and borrowing – when you call a function, if you want to lend that fn access to your data, you can write foo(&x). If it happens that this function wants a &str and you have a Rc<String>, that’s no problem. Before we added deref coercions, you would have had to write foo(&**x). Clearly this is more explicit, but is it adding any value?
Certainly I am someone who feels pretty comfortable with the concept of pointers. But one of the things I hated most in the pre-deref-coercion days was having to add just the right number of * annotations to make things compile. It wasn’t like it was hard for me to do, but it interrupted my flow. I had to stop and look closely at the error message, get the types just right, and never once did it make me say “oh! I thought it was just foo(&*x). Now that I know it is foo(&**x) I will try to do something else”. So for me, as an experienced Rust user, this change was a big usability win.
It should go without saying that being extremely particular about &x vs &*x vs &**x was also not helpful for new users. There the problem is compounded. Not only is the compiler being fiddly with little value, but the new users don’t have all the concepts firm in their mind. They are still trying to understand ownership/borrowing but now they must simultaneously understand a few other things. If they encounter enough such errors, there is a good chance that they just walk away. So for new users, this change was a big win too.
This is not to say the change has no flaws. For example, I have occasionally – well, I think exactly once – read some blog post that complained that Rust doesn’t make all your pointer dereferences explicit (this also, of course, applies to ., which btw does all kinds of things to make method calls ergonomic). While true, I don’t think that writing * explicitly really helps to control performance except perhaps in some very exceptional situations – and even then the compiler’s optimizations probably clean it up for you. Mostly, autoderef and deref coercions just make things more DRY. You as the programmer specify your data layout in struct and enum and fn declarations. The deref coercions fill in the gaps, for the most part. To me, this is an example of controlling the details only when it’s important (in the actual layout of data) and not so much when its not (in your local variables).
Another problem is that coercions always have a bit of janky interaction with generic functions. If I have fn foo<T>(x: T) and I pass foo(&x) where x: Rc<String>, T will get inferred to &Rc<String>. Sometimes you would have preferred that T be inferred to &str, perhaps because of trait bounds. This is a tricky problem. I think we can do better than we do now. But on balance, I think deref coercions totally paid off – and maybe we’ll make progress on improving the edges.
Generalizing from that example
Many of the examples that I added in my original list fit this mold. For example, match expressions do not autoderef, and require explicit ref keywords to create references. This means that if I have, say, an &Option<String> and I want to get a reference to the string inside, I have to write some code like this:
match *x {
Some(ref v) => ...
}
Interestingly, the * and the ref here are basically mandatory. There really isn’t anything else that would even type-check! At this point I usually write them first, and if I get a type-check error, of cousre I know how to fix it, but it’s never the sort of error that I really appreciate, that reveals a flaw in my thinking. It’s just busy work. It would be much nicer if I could write:
match x {
Some(v) => ...
}
and then just have v (which would have type String) be an implicit reference into the content of the option. This would retool how we think of match statements quite a bit. The mental model would shift from “a binding v moves the value out of the value being matched” to “a binding v creates a reference into the value being matched, moving only when v itself is moved” (more or less). @nrc and I have worked out a lot of the details here and I think it works out quite nicely (there is one catch about cases where you assign to x within the match arm, where we would also have to force moves, though I’d probably lint). It’s basically the same as closure upvar inference – a closure borrows things from its environment if it can, moves if it must.
Presuming @nrc and I are right, and this can be done, then I posit that making this change would make the experience of using Rust smoother for experienced users. For new users, I think the effect is more dramatic. With today’s language, one has to simultaneously learn how match and enums work (which is unfamiliar for many people) while also balancing that with ownership/borrowing. This change would help to separate those concerns, so they can be learned independently.
Conclusion
This is turning into more of an essay than a comment, sorry, so I’m going to stop here. The main themes I wanted to emphasize:
- We should not sacrifice control, but identify way it is truly needed and where it is accidental.
- In terms of improving new user experience, we should look for places where multiple difficult concepts coincide.
- In terms of improving end user experience, we should look for places where you have to write things that have only one reasonable answer, and see if we can avoid that (sometimes we won’t be able to, sometimes we will).
Certainly updating the language is not the only way to improve new user experience. Tools can help. Documentation can help. But the language is a tool to be brought to bear too. =)