Opinion: Rust code typically works once compiled, why?

That's a really strong statement. From where do you get the idea that they made these design decisions for the wrong reason?

Take Rust's prized ownership-and-borrow system. It was supposed to complement classic GC language and provide better performance, not form the language backbone.

But once these these affine types were added they were used more-and-more and eventually GC was demoted and once demoted it was removed.

Just read the Graydon's article. Lots of things which were changed in the language against the principal author's wishes, most of the time because there were technical issues with implementing what Graydon wanted… but quite often these “technical issues” are symptom of some more deep and important reason to do or not do something — and in most cases these deep reasons are never voiced till after the change is made.

3 Likes

Strong disagree there. "Graydon didn't/doesn't want this" doesn't mean "it's wrong" or "it's there for a wrong reason". I do agree with Graydon in many things (e.g. he strongly disliked the whole kangaroo court around match ergonomics), but some of the things he lists in that posts are just quite frankly preposterous. Language-special-cased containers, second-class references without lifetimes, or removing traits altogether (!) seems like we'd be much worse off with a language that's very painful to write generic code in, for example.

This is just more proof that whoever did this knew what s/he was doing. Updating one's beliefs based on evidence is basic Bayesian logic, not "doing things for the wrong reason". And not updating one's beliefs based on evidence is just unnecessary pride/being stubborn, not "doing things for good reasons".

4 Likes

Neither C++ nor Zig have traits and it's precisely because of that writing generic code is a joy in these.

Rust added traits to ensure that all the bugs would be detected before monomorphisation, yet post-monomorphisation errors are still very much possible while creation of generics in runtime is still very much impossible.

All in all it really looks as if adding traits have made writing efficient generic code extra-hard (in fact it's so hard that standard library enabled extra-special unstable features just for itself to make that possible). You may argue that they pay for themselves because of safety, ease of use of the generic code and so on and this may be true but if they were added to “make writing generic code less painful” then this is another right thing added for the wrong reason.

Depends on the order. If you first update beliefs and then do something because of these changed beliefs it's one thing, if you do something under threat or force and only after you have done something you realise it's actually a good thing… that's “doing the right things for the wrong reasons”.

1 Like

Nah, writing "generic" code in C++ is a horrible experience. It's a mess because:

  • You never know you messed it up until someone tries to use your library in a way you didn't 100% foresee but that should theoretically work, yet it doesn't. And then your debug-fix-recompile-test cycles go through issues and PRs and the iteration time is up from seconds or minutes to hours or days.
  • C++'s templates being closer to Rust's macros than traits means that one often can (and in fact needs to) pull dirty tricks in order to express type-level computations. Of course it's possible to write completely illegible generic code in both C++ and Rust, but in C++, real-life code that's considered "good" or "high-quality" is empirically much more riddled with unreadable balls of mud than Rust. In Rust, you have to go to great lengths to achieve that level of complexity: only a very small minority of the mainstream crates I know (uom, typenum and diesel) suffer from illegible trait syndrome, while one can find the same in basically every non-trivial C++ library.
  • C++'s templates don't provide a straightforward way to enforce relationships between types as directly as Rust traits do. This is one of the most important features that modern, domain-driven software design needs in order to stand a chance of writing correct, specification-obeying code, and this alone is enough to make Rust traits a better fit for most tasks than C++ templates.

This is a moot argument; C++ can't reify generics at runtime, either. Also, if you are trying to use a statically-typed language in highly dynamic ways, then you are holding it wrong.

10 Likes

Show me, please. Let's consider simple task: combining two functions into one. Common request on URLO. Something like what happens when one coverts from non-generic code

int average_int(int x, int y) {
  return (x + y) / 2;
}

into generic code:

auto average_int(auto x, auto y) {
  return (x + y) / 2;
}

It easy to write, and it works:

int main() {
    std::cout << average(3,5) << std::endl;
    std::cout << average(0,4294967296) << std::endl;
    std::cout << average(1.0,2.0) << std::endl;
}

Try to show how that transformation can be achiever in Rust, I dare you.

Yes. But most of the time it's “unreadable balls of mud which exists and works in C++” vs “perfect solution in Rust which doesn't exist and who knows if it'll ever would exist”. Just compare eigen to all crates which attempt (and fail) to achieve similar goals in Rust.

Precisely my point: simple generics are easier to write in C++ (think average above) and complicated things are just so hard to do in Rust they don't even really exist.

Yup. And that's why it's easier to write generics in C++. It's not always easier to use generics in C++ and they are trying to rectify that with concepts, but it's much, much, MUCH easier to write them.

They are so easy that novadays C++ courses just encourage one to write generic code simply for future-proofing (instead of using concrete int or long type you can just white auto and allow compiler to decide what would happen there… similarly to how one may do that with local variables in Rust).

Now, it's true that this feature (like any feature) can be misused, but I find it really hard to see how one may say something like this: C++'s templates don't provide a straightforward way to enforce relationships between types as directly as Rust traits do. I have expressed relationship between input and output types in my average function and that was easy.

One may argue that it's implicit and then it may be harder to actually use such generics, but that's the other story: yes, life is always about trade-offs and it's true that using generics in Rust is simpler than using them in C++, but writing them? Nope. Rust is much more complicated and convolute.

No, it's not moot. Most languages where it's as hard to write generics as it is in Rust give you that reward as a bonus. Yes, you need to write long-winded and c omplicated descriptions which restrict you generics and easiness of C++'s auto, auto, auto everywhere is just not there, but in exchange you get actual generics in runtime which you can use as, well, generics.

Rust, currently, combines worst sides of two approaches: you have significant extra burden when you are writing generics and yet you don't have generics in runtime, either.

One may argue which approach is best, but what Rust does is definitely worst.

The majority of “easiness of use” for genericized collections in Rust comes not from the type gymnastics that Rust generics impose on you, but, instead on the properties of ownership-and-borrow system. And yes, currently that system is exposed via traits system but it would have worked equally well if it was exposed via C++ style templates.

In fact in many cases Rust programs just give up on the generics and instead implement dozens (sometimes hundreds) of fully-specified implementations via macros. People wouldn't have done that if generics would have been as easy to write as you claim.

That's easy.

By the way, I have a strong feeling that you are being sinister and trying to paint me into a corner by pointing out one very minor aspect of generics where there is room for improvement (namely, generic number literals), and you are using that little problem to show the entirety of generics in Rust in bad light. That's rude, unprofessional, and entirely counter-productive, because anyone having a little experience in the language will see right through it.

If you can't write generic code in Rust, then you don't know the language and you need to learn it. At this point it has become obvious to me that you don't posess enough experience in Rust or strongly-typed languages to be in a position to criticize it.

But optimizing for writing is just the wrong objective function. Langauges should optimize for readability, not for "how many characters it takes to make an integer averaging function generic".

Yeah, and it's a trivial task. That's the least interesting part of generics. Just like "error: expected string, found number" is the least interesting error you can get from a properly designed, high-level type system.

If you want to see an example of what I mean, take a look at crates such as Diesel, which provides extensive compile-time validation of queries and results, ensuring that nonsensical queries cannot be written. That's what the trait system of this language is for.

13 Likes

There's no way to say "allow mutating i only if the binding is a var"

a and b are the same immutable instance of Foo. But i is mutable inside that instance per the var; you are only allowing mutating i because it is a var so clearly that's the way you mention right?

Or do you mean "allow mutating i only if b is a var"? But if b is a var then you simply create new instances with whatever value of i you want, right?

I can tell this is a really valuable example for comparison, thank you, I'm just not fully getting it. Your second example makes perfect sense to me and I did not know Rust requires a shared mutation specifier. Could you include a Rust version in both of your examples? Will really help me see the light I think.

Where do you attach the "immutable" attribute here? Because the instance of Foo is not immutable, and that's the problem. a and b are immutable pointers though.

The point is that locally this isn't clear. Seeing val a doesn't translate in the fact that a won't change, only that you can't directly assign to a.

I guess you mean is a is a var, not b? Apart from this, yeah, that's pretty much it, allow mutating i if a (the binding through which I'm mutating it) is also a var. You could of course create new instances, but that's kinda inefficient.

For the first one a direct translation would be:

struct Foo {
    i: i32
}

let a  = Foo { i : 0 };
a.i += 1; // this would error, you need to declare `a` with `let mut a`
println!("{}", a.i);

The second one would be:

struct Foo {
    i: i32
}

let mut a  = Foo { i : 0 };
let b = &a;
a.i += 1; // This doesn't compile, cannot assign to `a` while it is being borrowed
println!("{}", b.i);
4 Likes

Yes - I meant a and b are both immutable pointers to the same instance (you can't point them to another instance).

I gotcha fully about example 2, but still example 1 I'm not totally sure of yet because I think the analog to a Scala var class field is probably closer to something like:

struct Foo {
    i: Cell<i32>
}

Which I'm pretty sure would allow you to mutate a.i, even if a is not mut, e.g.:

impl Foo {
    fn mutate(&self) {
        self.i.set(self.i.get() + 1);
    }
}

You did mention Cell in your earlier comment though, so maybe this is one of the interior mutability anti-patterns Rust really isn't advocating for?

Well, it "works" so well that there's P0811R3: Well-behaved interpolation for numbers and pointers to add a midpoint function that actually works, which has different implementations for integers and floating-point numbers, and thus is much closer to defining a trait in Rust and implementing it for the different types.

They're only easier to write if you don't need them to actually work reliably. For example, STL has a great talk somewhere about how he needed to change , to , (void) all over the standard library implementation to ensure that none of the templates accidentally picked up an overloaded comma operator (in things like for (auto i = ..., j = ...; i < j; ++i, --j)).

Two-phase name lookup is insanity, and not even implemented consistently across C++ compilers. Letting people use trait methods without writing out where bounds might be fine, but the C++ approach of not catching typos until instantiation time is just blatantly unacceptable.

7 Likes

Most of my views on the original question have been covered (sweet spot of unsafe encapsulation, nudging you to deal with the unhappy path, cultural norms; make it complicated enough and you'll still have headaches).

However I think shared mutability gets an oversized amount of hate in general. Heck, even the official docs say:

interior mutability is something of a last resort

But this is a massive overstatement! If it is that bad, then you should not use

If I proclaimed "Arc and Mutex are last resorts", I'd rightfully get a lot of eye rolls. But pretty much any synchronization is going to involve some sort of shared mutability. The story can't be as simple as "shared mutability bad".

Here's an article about temporarily leveraging shared mutability as well.

[1]

We should still guide newcomers to understand the ownership system and not just go "heck it, Rc<RefCell<_>> for everything" at the first sign of difficulty, and so on. But there should be a bit more nuance than "shared mutability bad, last resort :frowning_face:".


I think part of the reason for the excess of hate is how Rust's naming goes (mut), and how Rust tends to be taught (mutable and immutable, not exclusive and shared). So an introduction to the language might go like

  • &mut mutable, & immutable
  • No chance of conflicts because of this, much advantage, isn't that nice
  • [Weeks of other material]
  • Oh yeah and interior mutability
    • Sorry, sorta lying about that whole "immutable" thing! (We'll leave it up to you to figure out how this doesn't invalidate all the advantages we mentioned.)

And sometimes people even get really upset about it once they find out about it, and ask for ways to ban it. [2]

But it's been there approximately forever, and it's by design even.

Put another way, it’s become clear to me over time that the problems with data races and memory safety arise when you have both aliasing and mutability. The functional approach to solving this problem is to remove mutability. Rust’s approach would be to remove aliasing.

Another good article on the topic.


Anyway, that's my shared vs. exclusive rant for the day :slightly_smiling_face:. Not everyone agrees.


  1. And a ton of OS level operations (like having a File... or allocation) are effectively shared mutability scenarios as well (where the shared mutability happens in the OS). ↩︎

  2. You can't, outside of knowing every concrete type (no generics or erased types) and how they're implemented. But you probably don't actually want to, as per above. ↩︎

2 Likes

Note that math which doesn't work, in some cases have nothing to do with generics. The code would be equally bad (or good, depending on your goals) if it were written without generics.

Note that problem which C++20 solves in that functions still exists in Rust, too, and of course, there's a crate which does the same thing as C++ does.

Only obviously, it couldn't just do what C++ is doing and pick appropriate strategy depending on the types involved in generics, it has to use a pile of macros instead.

And that's what everyone is doing. Everywhere.

Maybe. But at least it works. Generics is Rust just don't work. Or if they work it's just so hard that people give up on them and use piles upon piles of macros instead.

Apparently writers of standard library have to learn Rust, too. And then writers of crates like aforementioned midpoint crate need to learn it. And everyone else who uses macros because generics just don't work (which is, basically almost all popular crates which do something non-trivial with types).

And than maybe that's even, right, but we were talking about how easy or hard to write generics, and, well:

When you say that literally everyone around you, including both novices and experiences developers, both makers of standard Rust library and makers of most popular crates “don't get it” and couldn't see how easy is it to write generics in Rust, but use macros instead… don't you think something doesn't add up in that picture, hmm? Maybe they are not using Rust's generics and use piles of macros because writing generics in Rust is hard and not because all them are idiots?

The story is simple and complex at the same time. As I already wrote: most of the time shared mutability is what you are trying to achieve in your program as the end goal!

Database where changes made by one client are not seen by other clients would be useless.

Forum where everyone can only see what they wrote and couldn't talk to other visitors would be pointless.

And the majority of tasks computer programs are solving have that “shared mutability” intrinsic requirement somewhere.

But accidental shared mutability is bad.

I don't see anything wrong there. Because shared mutability is both the end goal and immense trouble you get the rule similar to the infamous everything should be made as simple as possible, but no simpler.

Yes, shared mutability is a problem, yes, you have to fight it. But no, you can not avoid it completely. Many tasks which you have to deal with in your programs can not be solved without shared mutability. Like in database and forum examples above.

The trick is not to avoid shared mutability but to reduce it as much as possible. But no further.

2 Likes

Thank you, there's too much good discussion in this thread to pick a single answer, so I'll summarize what feels like to me to be the key takeaways:

Result types, misuse resistant API design:

Shared mutability checks:

Uniq access and other topics:

9 Likes

I wrote far too many words in rebuttal to this in a new thread: Rust isn't C++, and that's okay

Relevant here, though, is that it's because Rust trait generics are checked at the definition site (unlike C++ templates, which undergo name resolution and type checking only after substitution) that we have the confidence that "if it compiles, it probably works as intended," as because we have this guarantee, the risk of something compiling but accidentally not providing the expected semantics (i.e. that would make the code work) is very low.

Code is wrong more often than it is right, and the compiler's primary purpose is to take potentially incorrect code and attempt to diagnose why it's wrong. Rust provides the framework for pushing many common errors earlier in the pipeline, and the library ecosystem tends to utilize this. It is of course still possible to communicate an incorrect intent to the compiler, but it's surprisingly difficult to find a scenario where the wrong intent was communicated because of a communication breakdown between developer and machine (e.g. a quirk of implementation in the language and/or library resulting in the intent of the developer being misinterpreted) rather than between developer and developer (potentially reflexively).

10 Likes

Moderator note: This conversation seems to be going in circles.

If you have any proposals for improving Rust generics, open a pre-RFC on the Internals forum. If you would like advice on how to work around Rust's deficiencies, open another topic here.

2 Likes