Introduction to Rust

Created March 24, 2023

What is Rust?

Rust is a systems programming language. It is designed to be fast, reliable, and maintainable.

Rust was started in 2006 by Graydon Hoare at Mozilla Research. It was originally designed to be a safer alternative to C++. It has since become a popular language for writing low-level code, such as operating systems and device drivers. Version 1.0 was released in 2015.

Firefox started sponsoring Rust and Firefox Quantum was rewritten in Rust in 2017, which boosted Firefox's performance.

Installing Rust

To install Rust, you can use

rustup

. Rustup is the official tool for installing Rust. It will install the Rust compiler and Cargo, Rust's package manager, and build tool.

curl --proto '=https' --tlsv1.2 -sSf <https://sh.rustup.rs> | sh

What is Cargo?

Cargo is Rust's package manager and build tool. It is used to build, test, and package Rust projects.

If you are coming from a JavaScript background, Cargo is similar to npm. Or if you are coming from a Python background, Cargo is similar to pip.

The compiler that Cargo uses is called rustc. You can use rustc to compile a single file.

Hello World

To create your first Rust project, you can use Cargo's new command. This will create a new project with the name hello_world.

bash cargo new hello_world

Output:

     Created binary (application) `hello_world` package

This will create a new directory called hello_world with the following structure:

hello_world
├── Cargo.toml
└── src
   └── main.rs

The Cargo.toml file contains the project's metadata and is the configuration file for your project. Rust uses semantic versioning, so you can specify the version of Rust that your project requires.

The src directory contains the source code for the project.

The main.rs file is the entry point for the project.

To run the project, you can use the run command which also builds the project.

bash cargo run

Output:

   Compiling
   hello_world v0.1.0 (hello_world)
   Finished dev [unoptimized + debuginfo] target(s) in 0.82s
   Running `target/debug/hello_world`
   Hello, world!

The target directory contains the compiled project. The debug directory contains the unoptimized version of the project.

You can add --release to the run command to build the project in release mode.

cargo run --release

The release directory contains the optimized version of the project.

Variables

To declare a variable, you can use the let keyword. The let keyword is used to create a variable that is immutable by default. This means that the variable cannot be changed once it is created this improves the safety and the performance.

let x = 5;

Rust is a strongly typed language, so you must specify the type of the variable. The type of the variable is inferred from the value that is assigned to the variable.

let x: i32 = 5;

You can initialize multiple variables on the same line.

let (x, y) = (1, 2);

To initialize a mutable variable, you can use the mut keyword.

let mut x = 5;

As in almost all programming languages, you can declare constants with the const keyword.

const MAX_POINTS: u32 = 100_000;

The const keyword is used to create a constant that is valid for the entire time a program runs. Constants are always immutable. The convention for constants is to use all uppercase with underscores between words.

Scope

Variables are scoped to the block that they are declared in. This means that variables declared in a block are only accessible in that block.

fn main() {
    let x = 1;
    let y = 10;
    println!("x = {}, y = {}", x, y);
}

The y variable is only accessible in the block that it is declared in. If you try to access the y variable outside of the block, you will get a compiler error.

error[E0425]: cannot find value `y` in this scope  --> src/main.rs:6:35
| 6 |     println!("x = {}, y = {}", x, y);     |
                    ^ not found in this scope

Shadowing

You can declare a new variable with the same name as a previous variable. This is called shadowing. The new variable shadows the previous variable. The previous variable is no longer accessible.

fn main() {
    let x = 1;
    let x = x + 1;
    println!("x = {}", x);
}

The x variable is shadowed by the x variable on the second line.

The first x variable is no longer accessible.

Memory Safety

Rust is a memory safe language. This means that Rust prevents memory errors such as null pointer dereference, use after free, and double free.

Uninitialized memory is a common source of memory errors. Rust prevents uninitialized memory by requiring you to initialize all variables.

fn main() {
    let x: i32;
    println!("x = {}", x);
}

If you try to use an uninitialized variable, you will get a compiler error.

error[E0381]: use of possibly uninitialized variable: `x`  --> src/main.rs:3:25
| 3 |     println!("x = {}", x);     |
                    ^ use of possibly uninitialized `x`

Functions

Functions are declared with the fn keyword.

The fn keyword is used to declare a function that is immutable by default. This means that the function cannot be changed once it is created.

fn hello_world() {
    println!("Hello, world!");
}

The Rust guidelines recommend using snake_case for function names.

To call a function, you can use the function name followed by parentheses.

fn hello_world() {
    println!("Hello, world!");
}
fn main() {
    hello_world();
}

Functions do not have to appear in the same order as they are called. You can call a function before it is declared.

fn main() {
    hello_world();
}
fn hello_world() {
    println!("Hello, world!");
}

Parameters

Functions can have parameters. Parameters are declared in the function signature.

fn hello_world(name: &str) {
    println!("Hello, {}!", name);
}
fn main() {
    hello_world("World");
}

The &str type is a string slice. A string slice is a reference to a string.

String slices are immutable by default. This means that the string slice cannot be changed once it is created.

Return Values

Functions can return values. The return type is declared in the function signature.

fn add(x: i32, y: i32) -> i32 {
    x + y
}
fn main() {
    let sum = add(1, 2);
    println!("sum = {}", sum);
}

The -> i32 part of the function signature declares the return type of the function. The -> i32 part of the function signature is optional.

If the function does not return a value, you can omit the -> i32 part of the function signature.

Modules System

Rust uses a module system to organize code. Modules are declared with the mod keyword.

mod hello_world {
    pub fn hello_world() {
        println!("Hello, world!");
    }
}
fn main() {
    hello_world::hello_world();
}

The use keyword is used to bring a module into scope.

mod hello_world {
    pub fn hello_world() {
        println!("Hello, world!");
    }
}
use hello_world::hello_world;
fn main() {
    hello_world();
}

Scalar Types

There are 4 scalar types in Rust: integers, floating-point numbers, Booleans, and characters.

Types of integers:

The isize and usize types depend on the kind of computer that your program is running on: 64 bits if you’re on a 64-bit architecture and 32 bits if you’re on a 32-bit architecture.

The default integer type is i32. This is usually the fastest, even on 64-bit systems. The default integer type can be changed with the #![feature(default_int_type)] attribute.

#![feature(default_int_type)]
fn main() {
    let x = 1;
    println!("x = {}", x);
}

Floating-point numbers:

The default floating-point type is f64. This type is usually the fastest, even on 32-bit systems. The default floating-point type can be changed with the #![feature(default_float_type)] attribute.

#![feature(default_float_type)]
fn main() {
    let x = 1.0;
    println!("x = {}", x);
}

Booleans:

Characters are always 4 bytes in size and represent a Unicode Scalar Value. Literal characters are specified with single quotes.

Strings do not use the character type. Strings are a collection of characters.

Compound Types

There are 2 compound types in Rust: tuples and arrays.

Tuples

Tuples are a collection of values of different types. Tuples are declared with parentheses.

fn main() {
    let x = (1, 2.0, "three");
    println!("x = {:?}", x);
}

The {:?} format specifier is used to print the tuple.

You can use a dot to access a tuple element.

fn main() {
    let x = (1, 2.0, "three");
    println!("x.0 = {}", x.0);
    println!("x.1 = {}", x.1);
    println!("x.2 = {}", x.2);
}

Arrays

Arrays are a collection of values of the same type. Arrays are declared with square brackets.

fn main() {
    let x = [1, 2, 3];
    println!("x = {:?}", x);
}

The {:?} format specifier is used to print the array.

Arrays are limited to a size of 32 elements. If you need a collection of more than 32 elements, you should use a vector.

You can use a dot to access an array element.

fn main() {
    let x = [1, 2, 3];
    println!("x[0] = {}", x[0]);
    println!("x[1] = {}", x[1]);
    println!("x[2] = {}", x[2]);
}

Control Flow

if Expressions

The if keyword is used to create an if expression.

fn main() {
    let x = 1;
    if x == 1 {
        println!("x is 1");
    }
}

if-else Expressions

The else keyword is used to create an if-else expression.

fn main() {
    let x = 1;
    if x == 1 {
        println!("x is 1");
    } else {
        println!("x is not 1");
    }
}

if-else if-else Expressions

The else if keyword is used to create an if-else if-else expression.

fn main() {
    let x = 1;
    if x == 1 {
        println!("x is 1");
    } else if x == 2 {
        println!("x is 2");
    } else {
        println!("x is not 1 or 2");
    }
}

You can assign the result of an if expression to a variable.

fn main() {
    let x = 1;
    let y = if x == 1 {
        2
    } else {
        3
    };
    println!("y = {}", y);
}

Note that there is a semicolon after the if expression when it is assigned to a variable.

We can't use return in an if expression.

There is no ternary operator in Rust. The if expression is the closest thing to a ternary operator.

fn main() {
    let x = 1;
    let y = if x == 1 { 2 } else { 3 };
    println!("y = {}", y);
}

loop Expressions

The loop keyword is used to create a loop expression.

fn main() {
    loop {
        println!("Hello, world!");
    }
}

The break keyword is used to break out of a loop expression.

fn main() {
    loop {
        println!("Hello, world!");
        break;
    }
}

To break a nested loop expression, you can use a label.

fn main() {
    'outer: loop {
        'inner: loop {
            println!("Hello, world!");
            break 'outer;
        }
    }
}

while Expressions

The while keyword is used to create a while expression.

fn main() {
    let mut x = 1;
    while x <= 3 {
        println!("x = {}", x);
        x += 1;
    }
}

There is no do-while loop in Rust. But you can use a loop expression with a break expression.

fn main() {
    let mut x = 1;
    loop {
        println!("x = {}", x);
        x += 1;
        if x > 3 {
            break;
        }
    }
}

for Expressions

The for keyword is used to create a for expression.

fn main() {
    for x in 1..4 {
        println!("x = {}", x);
    }
}

The iter method is used to iterate over a collection.

fn main() {
    let x = [1, 2, 3];
    for y in x.iter() {
        println!("y = {}", y);
    }
}

The enumerate method is used to iterate over a collection and get the index of each element.

fn main() {
    let x = [1, 2, 3];
    for (i, y) in x.iter().enumerate() {
        println!("i = {}, y = {}", i, y);
    }
}

The for can use a range.

fn main() {
    for x in 1..4 {
        println!("x = {}", x);
    }
}

The for can use a range with a step.

fn main() {
    for x in (1..4).step_by(2) {
        println!("x = {}", x);
    }
}

Strings

There are at least 6 different string types in Rust. But we will focus on the most common string types.

String slices

String slices are a reference to a string. String slices are declared with double quotes.

fn main() {
    let x = "Hello, world!";
    println!("x = {}", x);
}

A literal string always a borrowed string slice.

fn main() {
    let x = "Hello, world!";
    let y = "Hello, world!";
    println!("x = {}", x);
    println!("y = {}", y);
    println!("x == y: {}", x == y);
}

String type

The String type is a heap-allocated string. The String type is declared with the String::new function.

fn main() {
    let x = String::new();
    println!("x = {}", x);
}

The String type is declared with the to_string method.

fn main() {
    let x = "Hello, world!".to_string();
    println!("x = {}", x);
}

The String type is declared with the String::from function.

fn main() {
    let x = String::from("Hello, world!");
    println!("x = {}", x);
}

A borrowed string slice can be converted to a String type.

fn main() {
    let x = "Hello, world!".to_string();
    println!("x = {}", x);
}

&str type

The &str type is a borrowed string slice. The &str type is declared with the & operator.

fn main() {
    let x = "Hello, world!";
    println!("x = {}", x);
}

A String type can be converted to a borrowed string slice.

fn main() {
    let x = "Hello, world!".to_string();
    let y = &x;
    println!("x = {}", x);
    println!("y = {}", y);
}

A vector of String types example.

fn main() {
    let x = vec!["Hello, world!".to_string(), "Hello, world!".to_string()];
    println!("x = {:?}", x);
}

Ownership

Ownership is a Rust feature that allows you to manage memory. The Rust compiler ensures that memory is always valid.

Each value in Rust has a variable that's called its owner. There can only be one owner at a time. When the owner goes out of scope, the value will be dropped.

There is only one owner of a value. The value is moved to the new owner.

When the owner goes out of scope, the value will be dropped.

fn main() {
    let x = 1;
    let y = x;
    println!("x = {}", x); // error here
    println!("y = {}", y); // value moved here
}

Stack and Heap

Stack:

Heap:

Clone

The clone method is used to clone a value.

fn main() {
    let x = 1;
    let y = x.clone();
    println!("x = {}", x);
    println!("y = {}", y);
}

References and Borrowing

References allow you to refer to some value without taking ownership of it.

fn main() {
    let x = 1;
    let y = &x;
    println!("x = {}", x);
    println!("y = {}", y);
}

The & operator is used to create a reference.

Under the hood, the & operator is a borrow method.

References are immutable by default. The &mut operator is used to create a mutable reference.

fn main() {
    let mut x = 1;
    let y = &mut x;
    *y += 1;
    println!("x = {}", x);
    println!("y = {}", y);
}

To dereference a reference, use the * operator.

fn main() {
    let mut x = 1;
    let y = &mut x;
    *y += 1;
    println!("x = {}", x);
    println!("y = {}", y);
}

Structs

A struct is a custom data type that you can name and shape however you want.

struct Point {
    x: i32,
    y: i32,
}

Note that there is a comma after the last field.

Instanciating a struct.

```rs
fn main() {
    let x = Point { x: 1, y: 2 };
    println!("x = {:?}", x);
}

An associated function is a function that is associated with a struct.

impl Point {
    fn new(x: i32, y: i32) -> Self {
        Self { x, y }
    }
}

fn main() {
    let x = Point::new(1, 2);
    println!("x = {:?}", x);
}

The Self type is an alias for the type that the impl block is for.

Methods

Methods are functions that are associated with a struct.

impl Point {
    fn new(x: i32, y: i32) -> Self {
        Self { x, y }
    }

    fn distance(&self) -> f64 {
        ((self.x.pow(2) + self.y.pow(2)) as f64).sqrt()
    }
}

fn main() {
    let x = Point::new(1, 2);
    println!("x = {:?}", x);
    println!("x.distance() = {}", x.distance());
}

Traits

Traits are similar to interfaces in other languages.

trait HasArea {
    fn area(&self) -> f64;
}

impl HasArea for Point {
    fn area(&self) -> f64 {
        0.0
    }
}

fn main() {
    let x = Point::new(1, 2);
    println!("x = {:?}", x);
    println!("x.area() = {}", x.area());
}

Rundown of the code above:

A hands on example of traits.

trait Car {
    fn new() -> Self;
    fn drive(&self);
}

struct Toyota {
    model: String,
}

impl Car for Toyota {
    fn new() -> Self {
        Self {
            model: "Corolla".to_string(),
        }
    }

    fn drive(&self) {
        println!("The {} is driving.", self.model);
    }
}

fn main() {
    let x = Toyota::new();
    x.drive();
}

Copy

The Copy trait is used to indicate that a type can be copied.

#[derive(Copy, Clone)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let x = Point { x: 1, y: 2 };
    let y = x;
    println!("x = {:?}", x);
    println!("y = {:?}", y);
}

Debug

The Debug trait is used to indicate that a type can be printed.

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let x = Point { x: 1, y: 2 };
    println!("x = {:?}", x);
}

Collections

Collections are data structures that can group multiple values into a single type.

Vectors

Vectors are resizable arrays. They can grow or shrink in size.

fn main() {
    let mut x = vec![1, 2, 3];
    x.push(4);
    println!("x = {:?}", x);
}

The push and pop methods are used to add and remove elements from the end of a vector.

fn main() {
    let mut x = vec![1, 2, 3];
    x.push(4);
    x.pop();
    println!("x = {:?}", x);
}

The !vec macro is used to create a vector.

Hash Maps

Hash maps are key-value stores. In some languages, they are also known as dictionaries or associative arrays.

fn main() {
    let mut x = HashMap::new();
    x.insert("one", 1);
    x.insert("two", 2);
    println!("x = {:?}", x);
}

Common methods on hash maps:

fn main() {
    let mut x = HashMap::new();
    x.insert("one", 1);
    x.insert("two", 2);
    println!("x = {:?}", x);
    println!("x.get(\"one\") = {:?}", x.get("one"));
    x.remove("one");
    println!("x = {:?}", x);
    println!("x.len() = {:?}", x.len());
}

Enums

Enums are types that have a few definite values.

enum Color {
    Red,
    Green,
    Blue,
}

fn main() {
    let x = Color::Red;
    println!("x = {:?}", x);
}

Enums can also have data associated with them.

enum Color {
    Red,
    Green,
    Blue,
    RgbColor(u8, u8, u8),
    CmykColor { cyan: u8, magenta: u8, yellow: u8, black: u8 },
}

fn main() {
    let x = Color::RgbColor(0, 0, 0);
    println!("x = {:?}", x);
}

Option

The Option enum is used to indicate that a value may or may not be present.

fn main() {
    let x = Some(1);
    println!("x = {:?}", x);
}

The match expression is used to handle the different cases of an enum.

fn main() {
    let x = Some(1);
    match x {
        Some(i) => println!("x = {:?}", i),
        None => println!("x is empty"),
    }
}

To create a None value, use the None keyword.

fn main() {
    let x: Option<i32> = None;
    println!("x = {:?}", x);
}

Result

The Result enum is used to indicate that a function may or may not succeed.

#[must_use]
enum Result<T, E> {
    Ok(T),
    Err(E),
}

The Result enum has two variants: Ok and Err. The Ok variant indicates that the function succeeded. The Err variant indicates that the function failed.

use std::fs::File;

fn main() {
    let x = File::open("hello.txt");
    println!("x = {:?}", x);
}

You can unwrap a Result to get the value inside the Ok variant.

use std::fs::File;

fn main() {
    let x = File::open("hello.txt").unwrap();
    println!("x = {:?}", x);
}

You can also expect a Result to get the value inside the Ok variant.

use std::fs::File;

fn main() {
    let x = File::open("hello.txt").expect("Failed to open hello.txt");
    println!("x = {:?}", x);
}

Closures

A closure is a function that can capture its environment.

The type and the arguments of a closure are defined in the same way as a function.

fn main() {
    let x = |a: i32, b: i32| -> i32 { a + b };
    println!("x(1, 2) = {}", x(1, 2));
}

A closure will borrow a reference:

fn main() {
    let x = 1;
    let y = |a: i32| -> i32 { a + x };
    println!("y(2) = {}", y(2));
}

Here is an example on how to use a closure to sort a vector.

fn main() {
    let mut x = vec![1, 3, 2];
    x.sort_by(|a, b| a.cmp(b));
    println!("x = {:?}", x);
}

Rundown of the code above:

Threads

Threads are used to run multiple tasks simultaneously.

use std::thread;

fn main() {
    thread::spawn(|| {
        println!("Hello from a thread!");
    });
    println!("Hello from the main thread!");
}

The thread::spawn function takes a closure as an argument and returns a JoinHandle. A JoinHandle is a handle to a thread that can be used to wait for the thread to finish.

The more threads you have, the more context-switching you have to do. This can slow down your program if you have too many threads.

Conclusion

This is the end of the Rust tutorial. I hope you enjoyed it. If you have any questions, feel free to ask them in the community forum.

As the next steps, I recommend you to: