The Developer’s Cry

Yet another blog by a hobbyist programmer

Rust Beginner Tips For C++ Programmers

Like many others, I have been searching for a programming language that is easy to write, easy to read and maintain, has strict typing, delivers high performance, and has a decent ecosystem and popularity/community. In other words: the ideal programming language that will serve me well for at least another 15 to 20 years.

I’ve been trying out “new” languages left and right, most of them being just syntactic sugar for things that you can already do in C++. Don’t get me wrong, some of these languages are really, really nice, but if I feel I can do the same thing fairly quickly in a bunch of lines in C++, then there is little incentive to stick with that new language and invest lots of time in it. Of these “new” languages, Rust simply comes out on top.

You can read all over the web or watch YouTube videos on why Rust is better than other programming languages. But drawing from my own experience, I have a bunch of tips for C++ programmers who are just getting started and (maybe) are having a hard time. These are things I wish someone would have just pointed out.

1. Dealing with the learning curve

Rust is by no means an easy language to learn. If you get stuck climbing the learning curve, know that everything worthwhile takes effort. Learning stuff takes time. As a seasoned C++ programmer you’ll be used to having seen it all, and in general have no difficulty in picking up other languages. I’m telling you Rust is not quite the same. But stick with it, and you will get it.

“One does not simply assign to a variable.”

The difficulty of Rust is in unlearning some things that are second nature in other languages. It begins with move semantics, and all the rest is sort of related to that. As a seasoned C++ programmer you should already have a firm understanding of move semantics, so let me suffice to say that in Rust variable assignments of objects (or: non-primitive types) use move semantics.

Moreover Rust uses immutable types by default. Variables have to be tagged mutable if you wish to assign more than once. This funny feature comes from functional programming. It pays off if you already know and use a bit of functional style in your code.

The Rust handbook is a good reference manual, but it’s not exactly a nice learning book, as it left me with many questions. I did learn a bunch from the Let’s Get Rusty YouTube channel.

Generic, quick tips that would have helped me flatten the learning curve:

I’m not sure a couple of hours per week is going to make you a good Rust programmer soon. The risk of giving up out of frustration is real and totally human. Personally, what I did was immerse myself in Rust for a week straight. The first few days were tough and I wished for a blog post such as this one to point out some clever gotchas.

2. Unlearn OOP

Object oriented programming learns us to put functionality in a class of objects, and reason from the point of view of the object. All too often this leads to a code style where the object becomes the orchestrater of everything that needs to happen. It’s easy to defend this style because it groups together all the code that belongs to making this class work. And so we have an object Car where changing the volume of the car stereo involves calling car->set_volume(11) and adding a passenger is car->add_passenger(person). (Not the greatest example, but …)

This is a style where you are soon going to get stopped by the borrow checker in Rust.

struct Car {
    passengers: Vec<Person>,
    seats: Vec<Seat>,
}

impl Car {
    fn add_passenger(&mut self, person: &Person) {
        let driver = &self.passengers[0];
        self.alloc_seat(person, driver);
        self.passengers.push(person);
    }

    fn alloc_seat(&mut self, person: &str, driver: &str) -> bool {
        if self.seats.len() < self.seats.capacity() {
            // self.take_seat(person);
            println!("{} says: hello {}!", driver, person);
        } else {
            panic!("no more seats available");
        }
    }
}

Which produces the dreaded error:

error[E0502]: cannot borrow `*self` as mutable because it is also borrowed as immutable

    let driver = &self.passengers[0];
                  --------------- immutable borrow occurs here

    self.alloc_seat(person, driver);
    ^^^^^----------^^^^^^^^^^^^^^^^
    |    |
    |    immutable borrow later used by call
        mutable borrow occurs here

The problem is that taking a &mut self parameter is effectively locking the entire struct for the lifetime of the function.

The solution is stop writing OOPy code that treats the object like it’s the center of the world. In this example we don’t need any access to self at all for allocating the seat. The reality is that the mutable access is only necessary for self.seats. In that respect there is actually no good reason for the Car object to be there. For a moment, we can take the entire Car out of the equation. So, we can refactor alloc_seat() to:

fn add_passenger(&mut self, person: &str) {
    let driver = &self.passengers[0];
    Car::alloc_seat(&mut self.seats, person, driver);
    self.passengers.push(person.to_string());
}

fn alloc_seat(seats: &mut Vec<String>, person: &str, driver: &str) {
    if seats.len() < seats.capacity() {
        // self.take_seat(person);
        println!("{}: hello {}!", driver, person);
        return;
    }
    panic!("no more seats available");
}

And behold, the problem is gone. Function alloc_seat() still is grouped under the implementation of Car, but now it is written as a static class method. Note that iff you are a strong advocate of OOP, then you might argue that we changed point of view from the Car to the seats here. By zooming in to the appropriate sub-object, we kind of still solved it in an OOPy way.

The lesson to take away is “do not borrow more than what you really need”.

Other OOPy tips:

3. Lifetimes

Rust is fairly unique in that it tracks lifetimes. You will encounter lifetime issues if you return references. Even when it’s obviously not going to be a problem, the Rust compiler can and will complain, and urge you to add explicit lifetime annotations.

error[E0106]: missing lifetime specifier

fn get_seat(seats: &[String], passengers: &[String]) -> &String {
                    ---------              ---------     ^ expected named lifetime parameter

= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `seats` or `passengers`
help: consider introducing a named lifetime parameter

fn get_seat<'a>(seats: &'a [String], passengers: &'a [String]) -> &'a String {
           ++++         ++                        ++               ++

The trick is to make sure that the returned reference lives at least as long as the container it refers to. It’s nice that the compiler gives us this suggestion, but you have to be careful with just spraying lifetimes around. In this case what I really meant was:

fn get_seat<'a>(seats: &'a [String], passengers: &[String]) -> &'a String {

The syntax of lifetimes is confusing at first, but it helps to read them right-to-left: the lifetime of the returned reference is at least as long as the lifetime of array slice seats; it may not exceed that lifetime.

Also note the explicit lifetime annotation on the function: it’s a generic that says this function can not exceed this lifetime. Meaning that once seats gets destroyed, this function is no longer valid to call, as far as the compiler is concerned.

A word of advice: these lifetime errors tend to pop up because you are doing weird things. In many cases it’s not good style to return references and it seems better to steer clear from lifetime annotations. Rather than returning a reference directly into the vector, we could have simply returned an index (type usize) and avoided the problem altogether. Returning an index works just as good and it keeps the code more easy to read.

In short:

Face the fact, there is no way back

My little experience with Rust so far has been … enlightening. This language is next level. Full stop. It has shown me how to improve my own C++ codes. But honestly, at this point going back to C++ just feels wrong, as if C++ were some kind of dirty hack.

Is Rust a silver bullet? It can be tedious, but despite its rusty name the code tends to be solid, robust, clean, and most importantly, memory safe. Remember that anyone can write bad code in any language. But at least in Rust the compiler will teach that person something about how to write better code.

Rust is looking to be a worthy successor to C++. Highly recommended for all (soon to be former) C++ devs out there!