Post

How to Rust

I've been learning a lot of Rust about two years ago, and I'm still learning it. But this is what I know so far, and hopefully you get a good understanding of the language.

How to Rust

For someone like me who comes from C/C++, Rust is really an awesome language. But if you’re really like me, you probably made your Rust compiler yell at you because your code isn’t safe enough. Yeah, it’s that kind of language that forces you to write safe code, otherwise it just won’t compile. In this article, I’ll show you some of the basics of Rust that you need to know, so you won’t be lost whenever you need to correct something in your code.

Just a quick note before we start: I am not a Rust-sensei. I’m still actively learning the language, and this guide is not meant to replace the Rust book nor the Rustlings exercises. Here, I’m just about to tell you the basics of Rust, so you can understand how it works and how you can write some good old basic code with it.

Without further ado, let’s start with something very simple.

The main function

Just like C, Rust has a main function. This is the entry point of your program, where everything will start once executed.

1
2
3
fn main() {

}

For now the program does nothing. We need to add instructions, so it knows what to do. Alright, let’s add our first line of code here.

1
2
3
fn main() {
    println!("Hello World!");
}

Everything should work as intended. Compile it, execute it, and…

1
Hello World!

Tada!

Now you might be wondering. What about other functions? Why does the println one has a ! on it? Those are questions we will handle later on this article. For now, just know this program compiles and say “Hello World!” to the console.

Variable declaration

It’s really as simple as using the let keyword like so.

1
let i = 5;

By default, variables are immutable: you cannot change it after you applied a value. This can be possible again by adding mut.

1
2
let mut i = 5;
i = 6; // This will work with no error

Generally, Rust is quite intelligent and can deduce the type of your variable by its value. But if you plan to do parsing, you must clarify the type like so.

1
let i: i32;

Functions

Remember the main function? Then you know how to create a function with no argument and no return value. But how can you call that kind of function.

Well you can’t just call main as it is supposed to be the entry point. But let’s suppose you created a function that you’d like to call.

1
2
3
4
5
6
7
fn say_hello() {
    println!("Hello World!");
}

fn main() {
    say_hello();
}

And the result will be, you guessed it:

1
Hello World!

Arguments

Of course, functions can ask for a certain number of arguments, just like so.

1
2
3
4
5
6
7
fn show_sum(a: i32, b: i32) {
    println!("{} + {} = {}", a, b, a + b);
}

fn main() {
    show_sum(5, 2);
}

This will print:

1
5 + 2 = 7

Notice the type on the parameters. This is mandatory if you want to create a function that can accept arguments. Otherwise you’ll get an error like this.

1
2
3
4
5
6
error: expected one of `:`, `@`, or `|`, found `,`
 --> src/main.rs:6:14
  |
6 | fn show_sum(a, b) {
  |              ^ expected one of `:`, `@`, or `|`
  |

Return type

Let’s take our sum program again. What if we want to just create a function that returns the sum of two number, and delegate the printing to the main function? Well, it’s really simple.

1
2
3
4
5
6
7
fn sum(a: i32, b: i32) -> i32 {
    return a + b;
}

fn main() {
    println!("{} + {} = {}", 5, 2, sum(5, 2));
}

Removing return and ; on the return function will also work.

1
2
3
fn sum(a: i32, b: i32) -> i32 {
    a + b // This is called an implicit return, and is idiomatic in Rust
}

Conditions

There are two ways to define conditions.

if/else statement

These are typically used to see if a condition is met.

1
2
3
4
5
6
7
8
fn main() {
    let i = 5;
    if i == 0 {
        println!("The number is zero");
    } else {
        println!("The number is not zero");
    }
}

But did you know? These statements can also be used to declare a variable. In other words, this code is perfectly valid.

1
let i = if cond { 5 } else { 2 };

This is actually similar to a ternary condition in other languages.

match

If you know the switch/case statement in C, this is really similar. It allows you to test a variable from a set of values.

1
2
3
4
5
6
7
8
9
10
11
fn main() {
    let i = 5;
    match i {
        1 => println!("One"),
        2 => println!("Two"),
        3 => println!("Three"),
        4 => println!("Four"),
        5 => println!("Five"),
        _ => println!("Other"), // Equivalent to `default` in C
    }
}

You can also test if a variable has a value from a set in the same match arm.

1
2
3
4
5
6
7
8
fn main() {
    let i: u32 = 5;
    match i {
        0 | 1 => println!("Either zero or one"),
        2..=9 => println!("Between two and nine"),
        _ => println!("A lot"),
    }
}

Loops

Rust has three keywords to describe loops. So let’s see each of them.

while

The most common of the three. while is used to loop over a block until a condition is not satisfied.

1
2
3
4
5
6
7
8
fn main() {
    let mut n = 0;
    while n < 10 {
        n += 1;
    }

    println!("{}", n);
}
1
10

for

This is used to iterate on an array of values.

1
2
3
4
5
6
fn main() {
    let a = [1, 2, 3];
    for i in a {
        println!("current pos = {}", i);
    }
}
1
2
3
current pos = 1
current pos = 2
current pos = 3

loop

Here, you will let the program loop indefinitely. You will have to use break to get out of the loop.

1
2
3
4
5
6
7
8
9
10
fn main() {
    let mut i = 0;
    loop {
        i += 1;
        println!("Looping over");
        if i == 100 {
            break;
        }
    }
}

Functions that may fail

There are two types in Rust that are used to define a function that can potentially fail: Option<T> and Result<T, E>.

Option<T>

The name is pretty explicit. This type returns either T or nothing.

1
2
3
4
5
6
7
fn divide(a: i32, b: i32) -> Option<i32> {
    if b == 0 {
        None
    } else {
        Some(a / b)
    }
}

Result<T, E>

Quite the same concept, except that it returns either the type T or the error E.

1
2
3
4
5
6
7
fn divide(a: i32, b: i32) -> Result<i32, String> {
   if b == 0 {
       Err("Division by zero".to_string())
   } else {
       Ok(a / b)
   }
}

How to use them

There are several ways of how those two types can be used.

unwrap()/expect()

These functions can be used on a Option<T> or Result<T, E> type to return the value of T if it exists.

1
2
3
4
fn main() {
    let num: i32 = "42".parse().unwrap();
    println!("{}", num);
}

These functions are known to panic when an error occurs. So never use them in production.

Try operator

The Try operator (known as ?) does the same thing as unwrap() or except(), but propagate the error if it occurs instead of panicking.

1
2
3
4
5
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let num: i32 = "42".parse()?;
    println!("{}", num);
    Ok(())
}

Notice the return value of main(). That’s because the Try operator will work only on functions that return an Option<T> or a Result<T, E>. The following code, for example, will not compile.

1
2
3
4
fn main() {
    let num: i32 = "42".parse()?; // Fail here because `main` doesn't return a `Result<T, E>`
    println!("{}", num);
}

Of course, Box<dyn std::error::Error> on the Result<T, E> means that we will treat any possible error. But if you know exactly what error you’re about to propagate, it’s best to use the hardcoded error type instead.

if let/while let

if let is used to check if a variable checks a certain pattern.

1
2
3
4
5
6
7
8
fn main() {
    let num = Some(5);
    if let Some(i) = num {
        println!("Some: {}", i);
    } else {
        println!("None");
    }
}

This is the equivalent to writing this:

1
2
3
4
5
6
7
fn main() {
    let num = Some(5);
    match num {
        Some(i) => { println!("Some: {}", i); },
        None => { println!("None"); },
    }
}

while let on the other hand will loop over a code until a pattern is no longer verified.

1
2
3
4
5
6
fn main() {
    let mut stack = vec![1, 2, 3];
    while let Some(i) = stack.pop() {
        println!("{}", i);
    }
}

The equivalent would be this:

1
2
3
4
5
6
7
8
9
fn main() {
    let mut stack = vec![1, 2, 3];
    loop {
        match stack.pop() {
            Some(i) => { println!("{}", i); },
            None => break,
        }
    }
}

Combinator functions

There are two known combinator functions, each usable in different ways: map() and and_then(). Basically, they both return Option<T> or Result<T, E>, but they work differently.

map() treats a function that can return the value T, and turns it into a Option<T> or Result<T, E>.

1
let num = "42".parse().map(|x| x * 2);

and_then() treats a function that can return either Option<T> or Result<T, E>, and is used to chain operations that can fail.

1
2
3
4
5
6
7
let num = "42".parse().and_then(|x| {
    if x == 0 {
        None
    } else {
        Some(100 / x)
    }
});

Using the wrong function when trying to identify the return value will result in your Option<T> or Result<T, E> being nested. So check what you’re trying to return and use what’s appropriate, otherwise it will be a hell to deal with.

Default values

There are many functions that can be used when an Option<T> is None or a Result<T, E> is Err(E). So let’s focus with four of them: or(), or_else(), get_or_insert(), and get_or_insert_with().

or() and or_else() both returns an Option<T> or a Result<T, E> (and thus are chainable), and gives you a default value if the base variable is either None or Err(E).

1
2
3
let mut test: Option<i32> = None;
test = test.or(Some(42));
test = test.or_else(|| Some(5)); // This won't change `test`

get_or_insert() and get_or_insert_with(), on the other hand, do almost the same thing, except that they return a &mut T, and so are not chainable.

1
2
3
4
5
let mut test: Option<i32> = None;
let num1 = test.get_or_insert(42);
println!("{num1}");
let num2 = test.get_or_insert_with(|| 42); // `num2` will be equal to `num1`
println!("{num2}");

In addition (and before I forget), here in this code, the line order is quite important. Since those two functions return a &mut T, which is called a mutable borrow, you can only do one of this at a time. The following code will not work.

1
2
3
4
5
let mut test: Option<i32> = None;
let num1 = test.get_or_insert(42);
let num2 = test.get_or_insert_with(|| 42); // The error starts here, `num1` can't be exploited anymore
println!("{num1}");
println!("{num2}");

Macros

Remember the ! that we can see at the end of the println!() function? Well to be honest, it’s actually not a function. In fact, this little character is what allows us to make a difference between a function and a macro.

I’m not gonna tell you how to create one, as it is an advanced topic, and thus out of the scope of this article. But what I can do is show you a few of the macros that can be useful for you.

  • println!(): Of course the basic one. It just allows you to print any text you want in the console.
  • vec![]: Put any values you want inside the [] (as long as they are the same type), and you can get a Vec of those values (it’s similar to creating an array basically).
  • assert!(): This macros (and its variants) are mostly used for unit testing. They simply test if a condition is true, and panic with a message if not.
  • panic!(): Something you shouldn’t use in production, but it’s interesting to know it exists. Basically, it makes your program panic.

Of course, the only case the last macro of the list is allowed is when you’re making a debug build, for testing purpose. For a release build, this shouldn’t be allowed.

Enums

Okay I have something to tell you about the Option<T> and Result<T, E> types. Those were actually examples of enums. So basically, if you understand how to use these types, you pretty much understand how to use enum types.

But then, how do you create them? Well if you’re like me and come from C/C++, despite the difference and one particular consideration, you shouldn’t be lost.

1
2
3
4
5
enum TestEnum {
    UnitVariable,
    StructVariable { a: i32, b: String },
    TupleVariable(String, String),
}

Of course, you can even declare them the same way you would with C/C++ enums, but…

1
2
3
4
enum TestEnum {
    FirstVariable = 10,
    SecondVariable, // PLOT TWIST: SecondVariable is not 11. In fact, this won't even compile.
}

…but then you’ll have to explicitely declare EACH variable with a value.

Another difference with the C/C++ enums: you can also implement methods on enums. This things is mostly used for structs, so we will talk about it in this section. But just know it’s also possible here.

Actually, speaking of…

Structs

Okay, there are many things to say when talking about structs. So let’s talk about them point by point.

Types of structs

We can tell there are three types of structs that you can create in Rust.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct FieldStruct {
    name: String,
    age: u8,
}

struct TupleStruct(i32, f32, bool);

struct UnitStruct;

let field = FieldStruct {
    name: "Hello".to_string(),
    age: 26,
};

let tuple = TupleStruct(12, 5.42, true);

let unit = UnitStruct;

Some might tell there are also new type structs, which are like tuple structs but with only one element. But the difference is mostly semantic: they are still treated the same way as tuple structs in practice.

From now on, we will mostly use field structs in the next sub-sections. But just know that it’s also possible to use the following features with tuple structs and unit structs.

Methods

Now this is my favorite part with Rust. Remember when we talked about enums, when I said you can implements methods on them? Well, here’s how you can do it with structs. As always, remember that this works exactly the same way as enums.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
pub struct Point {
    x: f32,
    y: f32,
}

impl Point {
    pub fn new(x_coord: f32, y_coord: f32) -> Self {
        Self {
            x: x_coord,
            y: y_coord,
        }
    }

    pub fn add(&self, other: &Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }

    pub fn modify_point(&mut self, new_x: f32, new_y: f32) {
        self.x = new_x;
        self.y = new_y;
    }

    pub fn get_x(&self) -> f32 {
        self.x
    }

    pub fn get_y(&self) -> f32 {
        self.y
    }
}

You might notice the pub keywords here. If we consider the fact that this structure is declared in a non-main file, then yes this keyword is important. For example, let’s consider that this structure is declared in a file called point.rs.

1
2
3
4
5
6
7
8
9
10
11
12
mod point;

fn main() {
    let mut point = Point::new(1.0, 2.0);
    let to_add = Point::new(3.0, 4.0);

    let result = point.add(&to_add);
    println!("Result: ({}, {})", result.get_x(), result.get_y());

    point.modify_point(5.0, 6.0);
    println!("Modified: ({}, {})", point.get_x(), point.get_y());
}

Update syntax

Sometimes you might want to copy some fields from another structure. Well this syntax can help you with it.

1
2
3
4
5
6
7
8
struct Point3D {
    x: f32,
    y: f32,
    z: f32,
}

let p1 = Point3D { x: 1.0, y: 2.0, z: 3.0 };
let p2 = Point3D { x: 4.0, .. p1 }; // Here, only x is different between p1 and p2.

Destructor

Every struct can implement their own destructor by implementing the trait Drop.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Point {
    x: f32,
    y: f32,
}

impl Point {
    // Member functions
}

impl Drop for Point {
    fn drop(&mut self) {
        println!("Destroying Point");
    }
}

Here, Drop is an example of traits, another useful thing in Rust. We won’t talk much about it though, as it is beyond the scope of this article.

Conclusion

And now you are ready to write simple programs in Rust. Have fun, and don’t be afraid to break more stuff. It is by doing so that you really learn.

This post is licensed under CC BY 4.0 by the author.

© . Some rights reserved.

Using the Chirpy theme for Jekyll.