Port a project from C++ to Rust
by Baduit
Article::Article
As you may know, I started learning Rust recently and I like to practice on a project to learn. Fortunately, I had the perfect pet project for that: Topflight. It’s a programming language I created as a joke and the goal was to be simple but with one of the worst syntax ever. I’m pretty proud of the result.
Here’s what a “Hello world” can look like:
1
2
STORE hello STRING("Hello, World!")
PRINT hello
Or how to print number from 0 to 9:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Print range 0 to 9
STORE i INTEGER(0)
STORE one INTEGER(1)
STORE nine INTEGER(9)
STORE line_return STRING("\n")
<LoopContent>
PRINT i
PRINT line_return
ADD i one i
COMPARE_LESS_OR_EQUAL i nine keep_going
CALL_IF LoopContent keep_going
</LoopContent>
CALL LoopContent
In this article, I won’t explain point by point how I rewrote it, instead I will talk about the differences I noticed between the Rust version and its C++ equivalent. My remarks will be about the code itself, but also about the developer experience.
Note that I won’t go into too much detail, and I will focus my explanations on the Rust side and not on the C++ concepts that I will use for comparisons.
Setup the project
It’s obvious, but in Rust it is so much simpler, you just need one command, and you are ready to go. Cargo takes care of everything, no need to write a CMakeLists.txt or something similar like Meson then setup a package manager like [Conan](https://docs.conan.io/2/introduction.html or Vcpkg.
File organization
In C++ you can organize your files in an infinite number of ways. Even if you follow some templates, there are several of them.
In Rust it is stricter, but thanks to it, it is hard to make a mess and it probably one of the reason that there is less boiler plate code to setup a project.
For example, if you create an executable, your entry file will be called main.rs
, if you want to write a library it will be lib.rs
. Also, the way you organize your files will have a direct impact on how your modules are defined.
In C++ you can do whatever the heck you want and let’s be honest, if you are not rigorous, it can easily be a mess.
Compiler errors
C++ compiler errors are not human friendly, often it is hard to see the origin of the error. If there is a lot of templates, it looks like the compiler is throwing up at you. With some experience you quickly learn to recognize the error.
Note that it is a good thing to always look at the first error to understand the problem. Another trick you can use is to use different compilers like Gcc and Clang or even Msvc if your code is cross platform.
With Rust, errors are often clear, that’s nice. Maybe it is not always the case, but atleast on my little pet projects, it was.
Reflection
We can’t say that C++ nor Rust have compile time reflection, but there are way in both language to do achieve the same result (at least in some case).
In Rust you can use macros. Macros are very powerful and you can have access to an AST and also modify it to add code. For example, what I did in this project, was to create a macro that for some struct it was automatically generating a function depending on the field in the struct.
With C++, there are 3 ways to have reflection:
- Use a external tool that parse your code, then generate some new code
- Use a lot of macro and potentially a lot of boilerplates code
- Use libraries like Boost::Pfr or my own library Aggreget with a lot of templates and that only works with aggregate (to be short: default constructor, no virtual and no private or protected attributes). That’s the solution I used.
The tooling
Rust tooling works well and is trivial to install. Like you don’t even need to compile you project to be checked because the VSCode extension will tell you when you have errors with the compiler error message, you can start your unit tests directly in the files, auto-completion is amazing.
C++ tooling is more fragmented and because C++ syntax is context dependent it needs to parse everything to be right, if you are using templates then it is a nightmare. Tools requires to be tightly linked to your build system and often on big project it will struggle.
Static analyzer
It is similar as the tooling; in rust you have Clippy that is setup with cargo and rustc that works well. In C++ you need to install it, sometimes pay a license, it needs a lot of setup, to know a bit about how your system is built etc.
Enums/variant
Enums in rust are awesome, it can do the same as a C++ enum and a std::variant (a type safe union) but directly integrated in the language with pattern matching for example, this means no need to have templates everywhere or use std::visit.
Exhaustive pattern matching
There is no real equivalent for match in C++, but that’s one of the best feature of Rust. It’s like a switch that must be exhaustive (otherwise it does not compile) that works with any conditions, works with slices and also can do destructuring and replace stuff like std::visit in C++.
Returning values
In Rust you can also use the return keyword as in C++, but that’s not the idiomatic way (except in some cases like checking a precondition of a function). Instead you just don’t put a semicolon ;
at the end of the expression. It allows something else that is so powerful: control flows are expressions, and they can return values without exiting the function.
It means we can do this:
1
2
3
4
5
6
7
8
9
10
11
12
fn is_laughting(my_str: &str) -> bool {
if my_str.is_empty() {
return false;
}
let b = match my_str {
"lol" => true,
"rofl" => true,
_ => false,
};
b
}
and you can even shorten it like the by returning the result of the match:
1
2
3
4
5
6
7
8
9
10
11
fn is_laughting(my_str: &str) -> bool {
if my_str.is_empty() {
return false;
}
match my_str {
"lol" => true,
"rofl" => true,
_ => false,
}
}
There are also a lot of other features associated with it, especially if you are working with slices or tuples, someone could write a whole article just about that.
Error management
Let’s look at what we commonly have to handle errors in C++:
- Error codes
- Exception
- std::expected or other similar classes (it’s variant containing either the return value or an error)
In Rust there is no exception and return codes are discouraged, instead they heavily used a Result type, it’s the same thing as std::excepted but with better language support. Why? The first reason is that a Result is basically an enum for either the return value or an error and enums in Rust are really well integrated. The second reason is the error propagation operator ?.
What is it? It’s the thing that allow you to do this
1
2
3
4
5
6
7
fn read_lines(filename: &str) -> io::Result<io::Lines<io::BufReader<File>>>
{
let file = File::open(filename)?; // Here you can see the operator "?"
// No need to check the result of open, it if failed, the read_lines function would return the same error
// "file" is now already unwrapped so we can use it
Ok(io::BufReader::new(file).lines())
}
Basically, when you call a function that return a result, if the result contains an error it will make the current function returning the same error making you cascade return if you can’t handle the error in this function. Otherwise if the result is a is a success, it unwraps it.
That’s one of my favorite feature in Rust. It so clean to use, yet it does not make your code verbose.
I really advise you to use a library like anyhow or thiserror to create your errors, it makes creation and using errors really easy and practical.
You can also panic for unrecoverable errors, it’s the equivalent of using std::terminate.
Iterators
In C++ an iterator does not know its end that’s why we almost use another iterator as a sentinel. The algorithm of the standard library all takes a pair of iterators. With C++ 20 there is a new addition that comes into the standard: ranges. To make it short and simple, it is just a very cool wrapper around pair of iterators, so even if the usage is not that different and underneath it works the same way.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
std::vector<int> ints {1, 2, 3, 4, 5};
// Manually use iterator
for (auto it = ints.cbegin(); it != ints.cend(); ++it) {
int i = *it;
// Do stuff
}
// Range base for loop with c++11
for (int i: ints) {
// Do stuff
}
// Count the number of even argument
auto is_even = [](int i){ return (i % 2) == 0;};
int nb_even = std::count_if(ints.cbegin(), ints.cend(), is_even);
// Iterate of even elements with ranges
for (int i: ints | std::views::filter(is_even)) {
// Do stuff with even elements
}
In rust, the iterator returns an optional, if it is empty, it means we are at the end and it also advance the iterator at the same time. We can also compose operations as the C++ 20 ranges, but instead of using the pipe operator |
the syntax used the same syntax as a method.
1
2
3
4
5
6
7
8
9
10
11
let ints = vec![1, 2, 3, 4, 5];
for i in ints.iter() {
print!("{}", i);
}
// Output is 12345
let even_iter = ints.as_slice().iter().filter(|i| *i % 2 == 0);
for i in even_iter {
print!("{}", i);
}
// Output is 24
Another difference is that when you implement iterators in C++, it means that you must create a class satisfying a name requirement (and if you don’t the compiler is vomiting on you with thousands of line of error) or satisfy a concept (much better). In Rust you must implement a trait.
Destructive move
Destructive move mean that when a value is moved, its resources is released and it’s not possible to use it anymore.
For example, in Rust:
1
2
3
let first_str = String::from("Reblochon");
let second_str = first_str; // Here's the move
// Now we can't use first_str anymore
Whereas in C++:
1
2
3
std::string first_str = "Reblochon";
std::string second_str = std::move(first_str);
// Here we can still use first_str
For the standard library, it’s in the specification that all classes should still be in a valid state after the move, but everywhere else, there is no guaranty of that so be careful and read the doc or look at the code.
Shadowing
Shadowing is a bad practice in C++ as it makes unclear which variable we are really using but there is no such problem in Rust, quite the contrary. Code like this is quite common:
1
2
3
4
5
6
let input : &str = "true";
let input : bool = match input {
"true" => true,
"false" => false,
_ => panic!("OMG I can't recover from this :("),
};
In this code, the old input is destroyed (resources releases) and now it is replaced by a new variable with the same name.
Generics
Here’s a simple example with templates in C++
1
2
3
4
5
template <typename T>
void print(const T& t)
{
std::cout << t.size() << std::endl;
}
I just declared a template type T and then in the body of my function I hope that t has a member function size()
otherwise it won’t compile.
With concepts I can make sure that T has a method size returning something I can put into a stream.
1
2
3
4
5
6
7
8
9
10
11
12
template <typename T>
concept HasSize =
requires(const T& t, std::ostream& os)
{
{ os << t.size() };
};
template <HasSize T>
void print(const T& t)
{
std::cout << t.size() << std::endl;
}
Compiler explorer link success Compiler explorer link error
And now if it fails, I will have a message saying that it cant call the print function because the type I tried to use does not satisfy the concept HasSize
.
In Rust generics has the opposite logic, you can only use the property of a type that your type have. For example, if you want to use the operator+ between 2 variable using generics you need to use a where clause saying that your type can use it.
Here’s an example of using generics in Rust
1
2
3
4
5
6
7
8
9
10
11
12
use std::fmt::Debug;
fn print_in_option<T>(t: &T)
where T: Debug // Where clause to assert that it implement the Debug trait
{
println!("{:?}", t);
}
fn main() {
let vec = vec![1, 2, 3];
print_in_option(&vec);
}
But Lena how can you write about rust and not talk about the borrow checker?!?
I often read about people fighting with the compiler about lifetime, but I did not have problem with the compiler and especially not about lifetime issue. There is maybe some places where I make copies that I could have avoided, but honestly on this point it did not feel different between C++ and Rust except that Rust compiler checked that I was not doing wrong stuff. I really saw the compiler as my friend and not the enemy.
Maybe it was because the project is fairly simple and I did not need to annotate the lifetime because it could deduce it, but even when I had to write an iterator for another project which needed to do it, it seemed logical to me.
My thought about it is that most dev that complains about it (I’m talking about the concept here, not the syntax) are either doing complicated stuff where lifetime is not explicit, or they never had to think about lifetime before. Feel free to comment about it if you had trouble with the borrow checker, I’m very curious about it.
Article::~Article
That’s a lot of different things, some simple things like the file organization, some stuff that are just better in Rust like the build system or the enum and some others that are just different like the iterators.
One other big difference I noticed is that when I coded in rust it is way easier to have a more functional way because the language and the standard library has better built in support for it.
There is probably a lot more differences I forgot or did not see yet, because even if I have some experience in C++, I am still a newbie in Rust, so feel free to tell me about what other differences you noticed.
Sources
I used mainly Rust by Example
and the online version of The Rust Programming Language
- https://doc.rust-lang.org/rust-by-example/generics/where.html
- https://doc.rust-lang.org/book/ch19-06-macros.html
- https://doc.rust-lang.org/rust-by-example/custom_types/enum.html
- https://doc.rust-lang.org/rust-by-example/flow_control/match.html
- https://doc.rust-lang.org/rust-by-example/expression.html
- https://doc.rust-lang.org/rust-by-example/fn.html
- https://en.cppreference.com/w/cpp/utility/expected
- https://doc.rust-lang.org/rust-by-example/error.html
- https://en.cppreference.com/w/cpp/iterator
- https://doc.rust-lang.org/book/ch13-02-iterators.html
- https://doc.rust-lang.org/rust-by-example/scope/lifetime.html
- https://doc.rust-lang.org/rust-by-example/generics.html