I finally got around to learn Rust. Well, starting to.

It’s amazing.

I’m comparing this to learning Basic, C, C++, Erlang, Fortran, Go, Javascript, Pascal, Perl, PHP, Prolog and Python. I wouldn’t say I know all these languages well, but I do know C, C++, Go, and Python pretty well.

I can critique all these languages, but I’ve not found anything frustrating or stupid in Rust yet.

Rust is like taking C++, but all the tricky parts are now the default, and checked at compile time.

Copies

With C++11 we got move semantics, so we have to carefully disable the (usually) default-created copy constructor and copy assignments, or if you have to allow copies then every single use of the type has to be very careful to not trigger copies unless absolutely necessary.

And you have to consider exactly when RVO kicks in. And even the best of us will sometimes get it wrong, especially with refactors. E.g. who would have thought that adding this optimization would suddenly trigger a copy of the potentially very heavy Obj object, a copy that was not there before?

--- before.cc   2022-12-28 10:32:50.969273274 +0000
+++ after.cc    2022-12-28 10:32:50.969273274 +0000
@@ -1,5 +1,8 @@
 Obj make_string(int i)
 {
+    if (!i) [[unlikely]] {
+        return "zero as input to function";
+    }
     Obj ret = foo();
     return ret;
  }

But it does:

// Before
make_string(int):
        pushq   %rbx
        movq    %rdi, %rbx
        call    foo()
        movq    %rbx, %rax
        popq    %rbx
        ret

// After
.LC0:
        .string "zero as input to function"
make_string(int):
        pushq   %rbx
        movq    %rdi, %rbx
        subq    $16, %rsp
        testl   %esi, %esi
        je      .L8
        leaq    15(%rsp), %rdi
        call    foo()
        leaq    15(%rsp), %rsi
        movq    %rbx, %rdi
        call    Obj::Obj(Obj const&) // <---- New copy in the common case.
        addq    $16, %rsp
        movq    %rbx, %rax
        popq    %rbx
        ret
.L8:
        movl    $.LC0, %esi
        call    Obj::Obj(char const*) // <--- Expected construction for optimization.
        addq    $16, %rsp
        movq    %rbx, %rax
        popq    %rbx
        ret

Sure, this is not as bad if Obj is movable, but sometimes it isn’t. Sometimes that fact is forgotten. Especially since the move may be disabled in the future due to an added member variable to a transitive dependency not being movable. Hell, the triggering change may actually be:

--- before.cc   2022-12-28 11:00:02.196662142 +0000
+++ after.cc    2022-12-28 11:00:02.196662142 +0000
@@ -7,6 +7,9 @@
     Obj(Obj&&) = default;
     Obj& operator=(const Obj&);
     Obj& operator=(Obj&&) = default;
+
+private:
+    std::array<char, 4096> arr_;
};

It would be fine if make_string() did not optimize for 0 (because RVO). It would be fine if Obj were movable (because move is still cheap, probably), but the combination could blow everything up through a no-copy cascade.

Rust just does the right thing. A copy has to be explicitly cloned.

Sure, you could do this in C++. You could have all your types disable copy assignment and copy constructor, and create a .clone() member function, creating an rvalue that you then use move constructor and move assignment from. But even then you’ll sometimes accidentally copy due to some code you didn’t write, like std::vector.

With Rust there’s no need to be clever with std::move, std::forward, decltype(auto), and other fancy stuff. The only downside is that you won’t feel as proud of yourself for getting it right.

Aliases

Aliases can be tricky to get right. Rust enforces it at compile time. Awesome.

Const

The C++ equivalent of const qualifier is the default, like god intended. C++ (including my code) is full of things that should be const, but you just can’t bother. Like value function args. You know you should do int foo(const int a, const std::string_view b) { … }, but it’s easy to skip, and hard to insist on in code reviews.

I’m pretty strict about “const all the things”, but I’ll admit to not always using it for value function args.

And it can’t even be used for for loops. E.g. for (const int i = 0; i < 10; i++) { does not compile. In Rust i is immutable in for i in 1..10 {.

Ownership

The object ownership is incredibly thought through. Rust really takes in the 50 years of C/C++ of sloppy manual ownership and… solves it.

In large code bases you have to be careful to have a good ownership model, and maintain it. C++11 makes it easier to have a right way. E.g. std::unique_ptr<> means owned, raw pointer means not owned. But those are conventions only.

GC’d languages like Go and Java just give up on thinking about ownership, and it easily becomes a big mess.

In C++ some people give up and use std::shared_ptr<>, which makes as much of a mess as it does in Go and Java.

Ownership anarchy in Go also makes slices… surprising. Basically Go is a bunch of puzzle pieces that don’t fit together, and ownership is just one place this bites you.

Unicode

C++ just has not solved this. At least it has the honestly to basically call it out of scope. Python3 seems to solve it nicely with str. Why… just why did Go half-ass this? string and []byte is kind of the same, but iterate differently, and indexing is wrong. Go strings are like a database without a schema. It’s weak typing pretending to be strong typing. []rune? Really?

A thought through language

The one strength Go has is the standard library. It does most things, in a pretty good API. But like with other aspects of Go, for every year that passes it becomes more obvious that it has a shitty foundation. E.g. Context is bolted on. It’s great as a feature, but clearly bolted on. Arch-specific code? Bolted on. Generics? Bolted on.

Learning Rust makes everything else about Go seem like a missed opportunity. This is what Go could have been. Something actually thought through, and good.

Like there’s no excuse for having null pointers in a language designed this century. Go has two types of nil (the second being an interface that has a type but is a nil pointer). And pretending to design for parallelism, but not solving data races is a pretty big WTF.

Go is clearly a design from someone who’s never looked at anything anybody else has been doing for the last 30 years. It doesn’t matter how smart you are, nobody is smarter than everybody.

Go feels like a language “designed” by someone who doesn’t care about making the best language for everyone, but just one that works according to the way they happen to already think.

I’ve complained about Go before, but that was 9 years ago and I now find those complaints superficial. Go’s problem are much deeper. I still stand by the complaints, though.

C++ was designed in a different time, with different requirements. C compatibility simply prevented these modern innovations. C++ is C done right. Rust is programming done right.

I didn’t mean for this to be a rant about other languages, but the way to talk about one language is to compare it to others.

So far at least Rust feels like coming home.

ChatGPT

The way I’ve been learning Rust is to read the rust programming language while writing the wp tool, and asking followup questions to ChatGPT.

ChatGPT has been a very helpful mentor. Like with everything else it’s mostly correct, but sometimes gives you something plausible looking but wrong. E.g. it tells me that I can close a child process with:

child.stdin.as_mut().unwrap().close().expect("failed to close stdin");

but you can’t (maybe I’m missing something, but ChatGPT’s example code does not compile). And it takes me a while to even figure out that it’s wrong. Google to the rescue, finding this stackoverflow question.

But it’s been very helpful for other questions. Like example syntax for struct lifetime, or convert from Vec<u8> to [u8]. Or if I kind of remember something from the book, but not exactly. ChatGPT has usually gotten it right, and is faster than finding the right chapter.

It’s like having a private tutor, that only lies like 10% of the time. Like here:

ChatGPT getting C++ wrong

If I were in school now I’d use ChatGPT as a tutor for any subject. It’s a force multiplier for learning things.

And yes, I’d probably use it as a co-author, too. But while it creates risk of cheating, we should not dismiss it as a learning tool. In this aspect it’s the next generation’s Wikipedia.

Back to programming

I’m a bit tired of Go not being type safe, and playing fast and loose with correctness. Again and again.

Basically Rust is what a designed modern language looks like. Go is a language haphazardly thrown together and “Yeah, I guess it kinda works”.