Starting with Rust
A perspective of a C++ Software Engineer.
Starting the journey… Introduction
I have been working mainly in C++ for several years and in this post (and hopefully on others that may follow), I will show how I would have implemented the same functionality or obtain the same behavior using C++ and talk about the differences. The intention is not to be a comparison between languages, but just sharing my view of C++ and taking the opportunity to share my opinions and thoughts while starting to learn Rust.
To start learning Rust, I will mostly use the excellent documentation available, the book and examples. Rust documentation seems to be really well structured and of excellent quality, so let’s hope that lives up to the expectations! I will not explain all the details that are present in the book, but only talk about some aspects of the language.
In this particular post, I will focus on problems that you can have with data types, like implicit conversions, and how they are avoided in each language.
First, a bit on the C++ side regarding project creation. When I create a new C++ project, since we don’t have an utility like Cargo (more on that in a moment), I need to perform some setup tasks. Normally for a minimal project, I use CMake as cross-platform build system and Catch2 for testing. Keep in mind that this project is missing some components like Doxygen, ccache, cppcheck, and so on, but for the scope of this minimal boilerplate project they are not needed and can be added later. This time, I also decided to try out Conan for package management to have a similar experience as in Rust and don’t have the need to install the dependencies in the system (or using other possible methods to manage dependencies in C++). Note that in CMakeLists.txt
, the default C++ standard is set to C++20, so we can use new features like concepts. This will be the base project that I will use when talking about C++ and is available at https://github.com/jmrtt/cpp-base-project.
Starting Rust
What is Rust? Rust is a multi-paradigm programming language that aims for safety, concurrency and speed, and is the most loved language for the past five years Stack Overflow Developer Survey. Also, it is worth to note that it guarantees that a program is memory safe without run-time overhead! Seems interesting so far.
These promises for memory safety and easier concurrency while preventing data races due to the ownership model, grabbed my interest because during my experience, I already had my share part of memory problems, undefined behaviors and data races, just to mention a few of the issues that I saw in the past.
The best way to install Rust is using rustup since it will manage installation and update process. Considering the 6-week release cycle, if you want to stay up-to-date, it is an essential tool.
Creating a project
With Rust installed, we need to do the classic “Hello World!” that is used as a first example in many programming languages since 1972, where we can find the first tutorial that used this example (“A Tutorial Introduction to the Language B” by Professor Brian Kernighan).
To create a new project, do the following in a directory of your choice:
cargo new hello_world
cd hello_world/
Now we have the first Rust project with the following structure:
.
├── Cargo.toml
└── src
└── main.rs
Looking at the files, there is Cargo.toml
, which is the manifest file for Rust's package manager and build system that is cargo
(see https://doc.rust-lang.org/cargo/reference/manifest.html for documentation regarding the format). This will contain the metadata for our project.
If you open main/main.rs
:
You will notice the typical main
function that is a common point of entry in the program for many programming languages and println!
. You will immediately notice an exclamation mark at the end, this means that is a macro (!
is used to distinguish them from normal function calls). Since there is a lot to say about macros, I will get into macros and println!
later on. For now, we know that can use println!
to print to the standard output.
Then to build and run the application use cargo
:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/hello_world`
Hello, world
Note, that cargo will create a debug build by default and if you use cargo build
you can also run the application using the binary available at: ./target/debug/hello_world
.
With this, I already felt a big difference regarding C++: the available tooling by default. The ability to use a command, in this case cargo
, to perform as a package manager (that we will use in the future) and as a build system is a much better experience than have to do the initial setup (or clone an existing boilerplate repository) in the case of C++. This is not exclusive to Rust, since you can also find that in Go, and in my opinion is really good to have these tools already available when you install the language.
You may argue that this is opinionated at some level, but in most of the cases this is good, since it allows you to focus more on the business logic and implementation, and less on the project setup. You can find a stronger example of this, in one of the most popular Java frameworks that is Spring Boot, which is highly opinionated, but really productive for several cases. In Spring Boot you can generate the project with dependencies and tests, by using Spring Initializr.
Immutability
Our first stop: immutability. In C++ objects are mutable by default, but core guidelines recommend that objects should be made immutable by default since as they state:
You can’t have a race condition on a constant. It is easier to reason about a program when many of the objects cannot change their values. Interfaces that promises “no change” of objects passed as arguments greatly increase readability.
So, despite the strong recommendation to put const
everywhere unless it is necessary to modify the value, it is not the default behavior and wasn't changed because since it is a fundamental aspect of the language, it will break backward compatibility. Although this change would improve some aspects, backward compatibility is a much bigger concern.
But, this is an important rule to follow and this is the route that Rust chose, variables are immutable by default. This is one of the features that gives you safety and easy concurrency. So, a new language without the burdens of the past have some advantages.
Before getting into Rust, for C++ const
means read-only, so does not imply that the value is immutable. If we have a const int
for example, it will be immutable but, is possible to have a const
variable that is a reference or pointer to a mutable value.
In Rust, const
really means constant and is a compile-time evaluation. They are always immutable and used to declare constants and compile-time functions (see constant expressions and constexpr
on C++ side).
On the other hand, let
will define a run-time computed value that using mut
can be mutable. Since we need to declare that we want a variable to be mutable, the compiler can complain if we try to change an immutable variable. With let
, is also possible to do shadowing that allows to reuse some variable name. See Variables and Mutability.
There’s a lot more to say, but let’s leave that when we arrive to ownership and smart pointers.
Data types
In Data types we have the expected types: integers, floats, booleans and so on… but, there is something that I want to talk about data types which is implicit type conversions. I don’t like implicit type conversions in C++, because of the surprises that can get you.
Just to mention two examples of implicit conversions in C++, consider an assignment of a long
to an int
that is a potentially lossy conversion since you can have an overflow due to having a bigger value in long
that is too large to be represented by an int
variable. Other example would be truncation when assigning a float
to an int
, so a 5.9 value would be truncated to 5 without any kind of warning. This will happen by default, but we have the possibility to activate compiler flags and add static analysis that will avoid this kind of problems as we will see.
So, I prefer Rust “stronger” type checking as we will see in a moment.
Let’s consider a simple example:
The compiler builds the program successfully and gives the output:
$ ./bin/cpp_base_project
a is less than b
But, we have a problem in that code since we are comparing different types and if we use a static analyzer, like for example clang-tidy
(which I strongly recommend that we should always perform static analysis) it will complain:
warning: narrowing conversion from 'int' to 'float' [bugprone-narrowing-conversions,cppcoreguidelines-narrowing-conversions]
To prevent this situation and similar ones, I also consider a best practice to use compiler flags when possible. They are not enabled by default, but I always set them on the projects that I work:
set(CMAKE_CXX_FLAGS "-Wall -Wextra -Wconversion -Werror")
These flags will enable several warnings: -Wconversion
to check this kind of cases and -Werror
that converts warnings to errors and stops the compilation process. When we try to build with these flags we have:
main.cpp:15:9: error: conversion from ‘int’ to ‘float’ may change value [-Werror=conversion]
15 | if (a < b) {
| ^
Another example for a different situation. If we have the following code:
It compiles successfully and the output will be:
$ ./bin/cpp_base_project
a is now:-2147483648
What has happened here? Several things to talk about, let’s get started… The first is the output, that results from integer overflow where the values warps around. This is due to the assign of a float
value bigger than the maximum value allowed for int
by an implicit narrowing type conversion. Static analyzer will complain about this:
main.cpp:8:15: warning: narrowing conversion from 'int' to 'float' [bugprone-narrowing-conversions,cppcoreguidelines-narrowing-conversions]
float b = std::numeric_limits<int>::max()+1;
^
main.cpp:8:46: warning: overflow in expression; result is -2147483648 with type 'int' [clang-diagnostic-integer-overflow]
float b = std::numeric_limits<int>::max()+1;
^
main.cpp:10:9: warning: narrowing conversion from 'float' to 'int' [bugprone-narrowing-conversions,cppcoreguidelines-narrowing-conversions]
a = b;
^
main.cpp: In function ‘int main()’:
/home/roquette/playground/cpp-base-project/src/main.cpp:8:46: warning: integer overflow in expression of type ‘int’ results in ‘-2147483648’ [-Woverflow]
8 | float b = std::numeric_limits<int>::max()+1;
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~
As you can see from the warnings, it states that we have a narrowing conversion and a overflow in expression. Since we don’t want that to happen, it can be stopped by the compiler with the flags showed above:
main.cpp:8:46: error: integer overflow in expression of type ‘int’ results in ‘-2147483648’ [-Werror=overflow]
8 | float b = std::numeric_limits<int>::max()+1;
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~
main.cpp:10:9: error: conversion from ‘float’ to ‘int’ may change value [-Werror=float-conversion]
10 | a = b;
| ^
So, by default C++ won’t stop you from making overflow or conversion narrowing, but we have compiler flags that we can use, that will prevent from making these errors. And you may ask, why -Wconversion
or some of the other flags are not enabled by default or at least included in -Wall
or -Wextra
? For example, as stated in NewWconversion:
Implicit conversions are very common in C. This tied with the fact that there is no data-flow in front-ends (see next question) results in hard to avoid warnings for perfectly working and valid code. Wconversion is designed for a niche of uses (security audits, porting 32 bit code to 64 bit, etc.) where the programmer is willing to accept and workaround invalid warnings. Therefore, it shouldn’t be enabled if it is not explicitly requested.
Enough of C++, let’s see how it would behave in Rust. Having the example similar to our first one:
Using cargo build
without any additional configuration or flag it will give an error:
error[E0308]: mismatched types
--> src/main.rs:7:12
|
7 | if a < b {
| ^ expected `i32`, found `f32`error: aborting due to previous errorFor more information about this error, try `rustc --explain E0308`.
error: could not compile `hello_world`
And it even indicates that we can run rustc --explain E0308
to have more information regarding the error.
It will also prevent overflow:
With error:
error: this arithmetic operation will overflow
--> src/main.rs:18:18
|
18 | let c: i32 = i32::MAX + 1;
| ^^^^^^^^^^^^ attempt to compute `i32::MAX + 1_i32`, which would overflow
|
= note: `#[deny(arithmetic_overflow)]` on by default
And prevent type conversions:
With the following error:
error[E0308]: mismatched types
--> src/main.rs:24:9
|
24 | a = b;
| ^ expected `i32`, found `f32`error: aborting due to previous error
I really appreciate this behavior of the compiler being more strict by default (which can also be a problem in other contexts as we will see at a later stage), since it prevents common errors that can give problems. That’s why in C++, I like to have all the possible warning compiler flags enabled, to prevent these issues as early as possible.
Using the wrong operator when comparing
Before ending this post, some last words on a simple beginner mistake when performing comparisons on primitive types. Besides the kind of problems that were shown earlier, we are just going to look at using the wrong operator in a simple comparison. We will not enter in other topics like floating point comparison where there is a lot to say. Just to mention an example, see comparing floats.
So let’s consider an example, we are starting to learn a programming language and decide to go with C++ as a first language (there are brave people in this world :)). You create an initial program using the wrong operator to perform the comparison (=
instead of ==
):
You enabled all the flags stated above and give the following error:
main.cpp:8:11: error: suggest parentheses around assignment used as truth value [-Werror=parentheses]
8 | if (a = 10) {
| ~~^~~~
As you see, the compiler tries to be helpful and says that you need parentheses around the “comparison” (which is correctly stated as an assignment in the error message), so you do that if ((a = 10)) {
and the program builds. When is executed it outputs "Equal"... not what expected. clang-tidy
will give a more helpful warning:
main.cpp:8:9: warning: implicit conversion 'int' -> bool [readability-implicit-bool-conversion]
if ((a = 10)) {
Now in Rust, if we have a similar example:
You are stopped at the gate when trying to build with a more meaningful message (similar of what is found in clang-tidy
):
error[E0308]: mismatched types
--> src/main.rs:35:8
|
35 | if a = 10 {
| ^^^^^^ expected `bool`, found `()`
|
help: you might have meant to compare for equality
|
35 | if a == 10 {
| ^^
Regarding comparing different types (using for example int
with float
), both C++ (with compiler flags enabled) and Rust, will give compiler errors and prevent you to continue with those errors.
Conclusion
Until this moment, and from the experience that I have, in my opinion I found that C++ aims for flexibility and wherever possible guarantee backward compatibility (even with C) and Rust will go with safety. Both approaches have advantages and disadvantages, and maybe in the future I will address more about that.
I will continue to learn Rust, and when I have something else to share I will make another post.
If you enjoy working on large-scale projects with global impact and if you like a real challenge, feel free to reach out to us at xgeeks! We are growing our team and you might be the next one to join this group of talented people 😉
Check out our social media channels if you want to get a sneak peek of life at xgeeks! See you soon!