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:
- install
rustup
from the web and use that to install the compiler. - use
cargo
which is the build system. Norustc
in sight, really. - install
vscode rust-analyzer
. I could not live without it! This plugin shows what types are inferred as you type, so you don’t have to guess whether this variable is a&str
, aString
, a&String
, or maybe even a&&String
. - you shall not return integers as error codes from functions.
Use
Option<T>
orResult<T, E>
. Any error codes themselves areenum
s. - do not return references from functions. Functions do not return
&str
; return aString
instead. Returning references is like returning raw pointers in C++; you can do it there, but it doesn’t seem safe. You can return references in Rust, but you will encounter lifetime issues (more on that later). - do not take
&String
as a function parameter, take&str
. The&str
is not aconst char*
; instead it is a (immutable) slice. A slice is a small structure with a pointer and a length. - do not take
&Vec<T>
as a function parameter, take&[T]
. The latter is an immutable array slice, since you are not going to change the vector instance, it’s nicer to take the slice as a parameter. - make a
String
with"blah".to_owned()
rather than.to_string()
. The latter is a general conversion method, while.to_owned()
takes and copies the string literal into theString
. .copy()
makes a bitwise copy ….clone()
makes a deep copy. For objects likeString
andVec
you’ll nearly always want a deep copy.dbg!(x)
will steal ownership (because move semantics!). Writedbg!(&x)
instead.- Rust code lends itself to becoming deeply nested quickly. Remember clean coding rules, break functions up into smaller functions.
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:
- Rust traits are like “interfaces” in other languages, somewhat comparable
to an abstract base class in C++. Like with duck typing, if you implement
this, then it can do that. Traits like
Copy
,Clone
,Default
are already provided traits that you can just tag on a struct with#[derive()]
. If you invent your own trait then you’ll have to write animpl
implementation for it.
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:
- stay away from lifetimes as much as possible, at least until you fully grasp the concept. Chances are you are doing strange things, especially in the beginning.
- if you wish to return a reference into a
Vec
, just return the index instead. - do not resort to using an
unsafe
block as a workaround. There usually is a better solution.
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!