Important

Go to the practical exercise.

Info

This Rust introduction class is approximatively following the structure of the official Rust book

Rust in brief

Rust is a general-purpose programming language:

  • typed
  • compiled
  • from low level to high abstraction level

Rust supports multiple programming paradigms.

It is noted for its emphasis on:

  • performance
  • type safety
  • concurrency
  • memory safety

Insights (2025)

Rust exists since 2012, and is in the top 20 of the most popular programming languages in 2025.

260 665 crates (libraries and binaries) in the crates.io index (official Rust package index).

In 2025: Rust development was announced becoming an official part of the Linux kernel development.

Modern user-friendly framework

Use of the tool cargo to compile, run, lint and test the code, manage dependencies and auto-producing documentation. It is also extensible.

Pieces of codes are shared in the form of crates, with an official crates index: https://crates.io/, which the default index cargo is looking for to install dependencies.

Strong conventions, implemented in the default linter clippy (get the linter result with cargo check).

Startup - begin a new project

Info

You will manipulate Cargo in the practical exercises to create and manage your project.

cargo new my-project
cd my-project
ls -al
📂 my-project
├── 📂 src # source code directory
│   └── 📄 main.rs # main entry point (reserved module)
└── 📄 Cargo.toml # project metadata (name, version, dependencies, etc.)
cat src/main.rs
fn main() {
  println!("Hello, world!");
}

Build the project:

cargo build
Info

Unlike C or C++, we don’t need to write the compilation rules in a Makefile, as these are automatically handled by the Rust compiler.

Also, Cargo provides a build process for a release code: cargo build --release (longer compilation time but for better performances).

Run the built target:

cargo run
Hello, world!

Declaring variables

With the let keyword:

//! src/main.rs
//!
//! This is a block of inner line doc comments for the main module (note the "!").

/// The main function.
///
/// This is a block of outer line doc comments associated with the next item (here the function `main`).
/// Doc comments can be organized with section like the example below.
///
/// # Example
///
/// ```rs
/// main();
/// ```
fn main() {
    // A line comment
    let x = 5; // by default a signed integer on 32bits (i32)
    println!("The value of x is: {}", x);

    let y: u8 = 10; // types are specified after the `:` token
    println!("The value of y is: {y}"); // we can also directly put the variable in the placeholder
    println!("Sum of y + 2 is: {}", y + 2); // but an expression must be outside the placeholder
}
Info

println! is a macro to print to the standard output. A macro is a piece of code that is expanded at the compilation time, i.e. code that writes code. println! is what we call a function-like macro, but it is not a function.

Mutable variables

By default, a variable is immutable. If we want later to change it, we need to use the mut keyword:

fn main() {
    let mut x = 5;
    assert_eq!(x, 5); // a new macro to assert the equality between two values!
    x = 6;
    assert_eq!(x, 6);
}

Some common types

We can reuse the variable name to store a different type, but we must use the let keyword again:

fn main() {
    let x: f64 = 5.0; // a float (on the stack)

    let x: bool = true; // a boolean (on the stack)

    let x: char = 'h'; // a character (on the stack)
    let x: &str = "hello"; // a litteral string (on the stack)
    let x: String = String::from("hello"); // a string (on the heap)

    let x: [i32; 3] = [1, 2, 3]; // an array of fix size (on the stack)
    let x: Vec<i32> = Vec::from([1, 2, 3]); // a vector (on the heap)
    let x: Vec<i32> = vec![1, 2, 3]; // the same vector using the `vec!` macro (always on the heap)

    let x: (i32, &str, bool) = (1, "hello", true); // a tuple: known number of elements with heterogenous types (here on the stack)
    let x: (i32, String, bool) = (1, String::from("hello"), true); // a tuple (here on the heap)
}

Functions

Rust supports the functional programming paradigm.

In src/main.rs, main is the first function we have encountered.

To declare a function use the fn key word:

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

A function can take parameters, as arg_name: arg_type:

fn print_hello_name(name: &str) {
    println!("Hello, {name}!");
}

A function can also return a value, where the returned type is specified after the -> token:

fn add_one(x: i32) -> i32 {
    x + 1 // no `;` token
}

In that case, the last line (before the end of the function’s scope) does not have a ; token, it means the value is returned to the caller. In the case of a premature return, we can use the return keyword:

fn add_one_if_even(x: i32) -> i32 {
    if x % 2 == 0 {
        return x + 1; // need to use `;` for `return` statement
    }
    x
}

Control flow

As in other languages, Rust can express complex control flows with if, else conditional, and while, for loops, but also match etc.

If expression

Traditional if cascade:

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("{number} is divisible by 4");
    } else if number % 3 == 0 {
        println!("{number} is divisible by 3");
    } else if number % 2 == 0 {
        println!("{number} is divisible by 2");
    } else {
        println!("{number} is not divisible by 4, 3, or 2");
    }
}

Using if in a let statement:

fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("The value of number is: {number}");
}

Loops

Streamlining conditional loops with while:

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");

        number -= 1;
    }
}

Looping through a collection with for:

fn main() {
    let v: Vec<i32> = vec![10, 20, 30];

    //
    // Reading without consuming the vector
    //
    for element in v.iter() {
        // or just `for element in &v`
        // element is type `&i32`
        println!("the value is: {element}");
    }
    println!("The vector is: {v:?}");

    //
    // Reading while consuming the vector
    //
    for element in v.into_iter() {
        // or just `for element in v`
        // element is type `i32`
        println!("the value is: {element}");
    }
    // Cannot do that, v has been consumed and does not exist anymore:
    // println!("The vector is: {v:?}");
}

But wait, what is the difference between i32 and &i32 types, and between .into_iter() and .iter() methods?

Rust is memory safe

In Java or Python, a garbage collector runs in the background to free the unused memory. In C or C++, you must allocate and free the memory yourself, with potential memory safety errors:

Rust uses no garbage collector and describes strict rules for memory management to avoid memory safety errors.

Important - The Stack and the Heap in short

The Stack and the Heap are two ways of storing data in memory.

Stack:

  • Fast memory allocation and data query
  • The size of the data must be known at the compilation time

Heap:

  • Slow memory allocation and data query
  • Can store data of unknown size

Ownership rules:

  • Each value in Rust has an owner.
  • There can only be one owner at a time.
  • When the owner goes out of scope, the value will be dropped.

These rules exist to:

  • Track which parts of code are using which data on the heap
  • Minimize duplicate data on the heap
  • Automatically clean up unused data on the heap

A String in memory

Some data have a known size at the compilation time (i32, f64, bool, char, str - literal strings, etc.) and so they are stored on the stack.

For more complicated type, like String or Vec, they are stored on the heap. However, the pointer to their content is stored on the stack:

let s = String::from("hello");
A String in memory

A scope

{                                  // x and s are not valid here, since they are not yet declared

    let x = 5;                     // x is valid from this point forward
    let s = String::from("hello"); // s is valid from this point forward

}                                  // this scope is now over, and x and s are no longer valid

At the end of the scope:

  1. Free the memory of x: the integer on the stack is freed
  2. Free the memory of s: the String content on the heap is freed, then the String “metadata” on the stack is freed.

Moving

A move is a transfer of ownership from one variable to another ~ there can only be one owner at a time:

let s1 = String::from("hello"); // before the move
let s2 = s1;                    // after the move
println!("{}", s2);             // OK
// println!("{}", s1);          // error: borrow of moved value

Copy data

If you want to “deeply copy” the data, if the data is on the heap, you have to clone it (expensive):

let s1 = String::from("hello");
let s2 = s1.clone();
println!("{}", s1);
println!("{}", s2);
Cloning a String

Scalar types with known size at compilation time implement the Copy trait: instead of being moved, they are copied (cheap operation):

let x = 5;
let y = x;
println!("x = {}", x); // OK
println!("y = {}", y); // OK

Reference and borrowing

This is one of the core feature of Rust. Take the example of String and a function.

Because of the move rule:

fn takes_ownership(some_string: String) {
    println!("This is the string: {}", some_string);
}

fn main() {
    let s = String::from("hello");
    takes_ownership(s);
    // s is no longer valid here
}

If we want to reuse s, we can do that:

fn takes_ownership_and_gives_back(some_string: String) -> String {
    println!("This is the string: {}", some_string);
    some_string
}

fn main() {
    let s = String::from("hello");
    let s = takes_ownership_and_gives_back(s);
    println!("s = {}", s);
}

It gets complicated if we have many parameters we want to reuse after, for example:

fn get_length(some_string: String) -> (String, usize) {
    let len = some_string.len();
    (some_string, len)
}

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = get_length(s1);
    println!("s = {}, len = {}", s2, len);
}

References enable borrowing a value without taking ownership:

fn get_length(some_string: &String) -> usize {
    some_string.len()
}

fn main() {
    let s = String::from("hello");
    let len = get_length(&s);
    println!("s = {}, len = {}", s, len);
}

Note the & before the type: this is a reference to the value (the type is &String).

A String reference

Type &String is a read-only reference to a String. If we want to borrow a mutable reference, we need to use &mut:

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
    println!("s = {}", s);
}

But with great power comes great responsibility! If you create two mutable references, the compiler will fail. In fact, the compiler cannot ensure the validity of all the references if at least one is used to modify the data (race condition, caching problem, etc.).

The rule is thus the following: you cannot create another reference (mutable or immutable) in the same scope if you already have a mutable reference.

Info - Further reading
/// Use the `struct` keyword to define a structure
///
/// It has two attributes: a width and a height
struct Rectangle {
    width: u32, // The comma is `,` and not the `;` token
    height: u32,
}

// Use the `impl` keyword to implement methods and associated functions on the structure:

impl Rectangle {
    /// A method
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn set_width(&mut self, new_width: u32) {
        self.width = new_width;
    }

    /// An associated function
    fn new(width: u32, height: u32) -> Self {
        Rectangle { width, height }
    }
}

// You can also declare external function:

fn print_area(rect: &Rectangle) {
    println!("The area of the rectangle is {}.", rect.area());
}

fn main() {
    let rec = Rectangle {
        width: 30,
        height: 50,
    };
    print_area(&rec);

    let mut rec2 = Rectangle::new(30, 50); // The same rectangle but using the constructor we have defined
    print_area(&rec2);

    rec2.set_width(10);
    print_area(&rec2);
}

Enums and matching

Enums are used to define a type with different variant. You can see it as a union of different types.

To declare an enum, use the enum keyword:

enum Color {
    Red, // not the `;` token
    Green,
    Blue,
}

fn main() {
    let red = Color::Red;
}

An enum can contain variants of different types:

enum Stuff {
    Red,
    Number(u32),
    Shape(Rectangle),
}

fn main() {
    let red = Stuff::Red;
    let x = Stuff::Number(10);
    let rect = Stuff::Shape(Rectangle::new(10, 20)); // if we reuse our Rectangle structure
}

Matching

enum StuffName {
    Red,
    Number,
    Shape,
}

enum Stuff {
    Red,
    Number(u32),
    Shape(Rectangle),
}

fn create_enum(stuff_name: StuffName) -> Stuff {
    match stuff_name {
        StuffName::Red => Stuff::Red,
        StuffName::Number => Stuff::Number(10),
        StuffName::Shape => Stuff::Shape(Rectangle::new(10, 20)), // if we reuse our Rectangle structure
    }
}

fn main() {
    let stuff = create_enum(StuffName::Red);

    match stuff {
        Stuff::Red => println!("Red"),
        Stuff::Number(x) => println!("Number: {}", x),
        Stuff::Shape(rect) => println!("Shape: {}x{}", rect.width, rect.height),
    }

}

The Option enum

The Option enum is used to represent the absence of a value. It has two variants, Some(T) and None, where T is any type. Option is directly accessible from the standard library:

fn main() {
    let x: u32 = 2;
    let y: Option<u32> = Some(40);

    match y {
        Some(y) => println!("Can do the sum x + y = {}", x + y),
        None => println!("Cannot do the sum x + y"),
    }
}
Info - Further reading: the Result enum

Structuring a project

Vocabulary

  • Packages: A Cargo feature that lets you build, test, and share crates
  • Crates: A tree of modules that produces a library or executable
  • Modules and use: Let you control the organization, scope, and privacy of paths
  • Paths: A way of naming an item, such as a struct, function, or module

Managing a Rust project with Cargo

Creating a new project:

cargo new my-project

Produces:

📂 my-project
├── 📂 src # source code directory
│   └── 📄 main.rs # main entry point (reserved module)
└── 📄 Cargo.toml # project metadata (name, version, dependencies, etc.)

Modules and visibility levels

When the code is growing, we would like to separate logics into different modules.

For example:

📂 my-project
├── 📂 src
│   ├── 📄 main.rs
│   ├── 📄 mountain.rs # module mountain
│   └── 📂 city # the city contains may independant logics
│       ├── 📄 mod.rs # city module entry point (the module name is `city`)
│       ├── 📄 opera.rs
│       └── 📄 house.rs
└── 📄 Cargo.toml

Let’s visit the module from a bottom-up view:

---
title: Crate structure
---
graph TD
    main["main.rs (crate root)"]
    mountain["mountain.rs (mod mountain)"]
    city_mod["city/mod.rs (city entry)"]
    opera["opera.rs (mod opera)"]
    house["house.rs (mod house)"]

    main --> mountain
    main --> city_mod

    city_mod --> opera
    city_mod --> house

By default, any functions, structures, etc. are private and only their module can access them. To enable the use outside their module, we have to use the pub keyword:

In src/mountain.rs:

pub struct Mountain {
    name: String,
    height: u32,
}

impl Mountain {
    pub fn new(name: String, height: u32) -> Self { ... }
    /// Public getters can be used by any crate
    pub fn name(&self) -> &str { ... }
    pub fn height(&self) -> u32 { ... }
}

In src/city/house.rs:

pub struct House { ... }

impl House {
    pub fn new(inhabitants: u32) -> Self { ... }
    pub fn inhabitants(&self) -> u32 { ... }
}

In src/city/opera.rs:

pub struct Opera { ... }

impl Opera {
    pub fn new(name: String) -> Self { ... }
    pub fn name(&self) -> &str { ... }
}

In order to consider a module in the crate, we have to declare its existence by the mod keyword:

In src/city/mod.rs:

//! The opera and house modules are private module
//! and can be used only in the module that declares them
//! i.e. in city/mod.rs (module city)
mod opera;
mod house;

// As the module `house` is declared, we can directly use the `House` structure with `house::House`
// If we want to import directly `House` without everytime writting `house::House` we can do:
use house::House;

pub struct City { ... }

impl City {
    pub fn new(name: String) -> Self { ... }

    pub fn set_opera(&mut self, name: String) { ... }

    pub fn add_house(&mut self, number_of_inhabitants: u32) { ... }

    pub fn opera(&self) -> Option<&opera::Opera> { ... }

    pub fn houses(&self) -> &Vec<House> { ... }
}
Tip - Idiomatic: the builder pattern

To make the City code more idiomatic, instead of creating an empty City and then adding the opera and the houses, a more idiomatic way is to create a builder type, see: Rust Design Patterns - Creational - Builder section.

In src/main.rs:

mod city;
mod mountain;

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

    // Use `::` to access an element in the city module's scope
    let mut city = city::City::new("Simcity".to_string());
    city.set_opera("The Opera".to_string());
    city.add_house(4);

    match city.opera() {
        Some(opera) => println!("The Opera is {}", opera.name()),
        None => println!("There is no Opera"),
    }

    for (numero, house) in city.houses().iter().enumerate() {
        println!("House {numero} has {} inhabitants", house.inhabitants());
    }

    // Cannot do this because module city::house is private and only city can access it
    // let a_house: city::house::House;
}
Info - Additional visibility levels

There are other visibility levels such as pub(super) and pub(crate). See the Rust Cheat Sheet - Organizing Code.

Unit testing

Rust elegantly supports unit testing. To test elements of a module, just create a private inner module, like:

src/rectangle.rs:

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn new(width: u32, height: u32) -> Self {
        Rectangle { width, height }
    }

    fn area(&self) -> u32 {
        self.width * self.height
    }
}

#[cfg(test)] // required to define a test module
mod tests {
    use super::*; // to import elements from the parent module

    #[test] // required to define a test function
    fn area() {
        let rec = Rectangle::new(10, 20);
        assert_eq!(rec.area(), 200);
    }
}

Then you can run the tests with the cargo test command. The #[cfg(test)] attribute at the top of the tests module tells the compiler to not include the tests in the final binary.

Further reading

Refer to the Rust book chapter on unit tests.

Traits and generics

Defining Shared Behaviour with Traits

Both the rectangle and the circle have a perimeter:

struct Rectangle {
    width: u32,
    height: u32,
}

struct Circle {
    radius: f32,
}

trait Perimeter {
    fn perimeter(&self) -> f32;
}

impl Perimeter for Rectangle {
    fn perimeter(&self) -> f32 {
        2.0 * (self.width + self.height) as f32
    }
}

impl Perimeter for Circle {
    fn perimeter(&self) -> f32 {
        2.0 * std::f32::consts::PI * self.radius
    }
}

fn print_perimeter(shape: &impl Perimeter) {
    println!("The perimeter is {}", shape.perimeter());
}

fn main() {
    let rec = Rectangle {
        width: 10,
        height: 20,
    };
    print_perimeter(&rec);

    let circle = Circle { radius: 10.0 };
    print_perimeter(&circle);
}

Some traits can be derived i.e. implemented automatically for a given type.

Examples:

  • The Clone trait: to be able to clone an instance of your structure
  • The PartialEq trait to be able to compare two instances of your structure with == and !=
#[derive(PartialEq, Clone)]  // The derive macro is a procedural macro
struct Rectangle { ... }

fn main() {
    let rec1 = Rectangle { ... };
    let rec2 = rec1.clone(); // method `clone` exists because `Rectangle` implements the `Clone` trait
    assert!(rec1 == rec2); // it works because `Rectangle` implements the `PartialEq` trait
}
Info - Further reading

Generics

In the above code we can change the function print_perimeter:

fn print_perimeter(shape: &impl Perimeter) {
    println!("The perimeter is {}", shape.perimeter());
}

to:

fn print_perimeter<T: Perimeter>(shape: &T) {
    println!("The perimeter is {}", shape.perimeter());
}

This means that a generic type T must implement the trait Perimeter.

So what is the difference? In that simple example, nothing. But in the following:

fn print_perimeter_of_same_types<T: Perimeter>(shape_a: &T, shape_b: &T) { ... }

fn print_perimeter_of_different_types(shape_a: &impl Perimeter, shape_b: &impl Perimeter) { ... }

/// Same as above with another way of declaring generic bounds
fn print_perimeter_of_different_types_prime<T, U>(shape_a: &T, shape_b: &U)
where
    T: Perimeter, // the where clause is usefull if T or U must implement a lot of traits
    U: Perimeter,
{ ... }


fn main() {
    let rec_a = Rectangle { ... }
    let rec_b = Rectangle { ... }
    let circle = Circle { ... }

    print_perimeter_of_same_types(&rec_a, &rec_b);
    print_perimeter_of_different_types(&rec_a, &circle);

    // Cannot do that, because `Rectangle` and `Circle` are two different types:
    // print_perimeter_of_same_types(&rec_a, &circle);
}

Actually, we have already seen the use of generics with vectors Vec<T>:

let v_of_ints: Vec<i32> = Vec::new();
let v_of_strings: Vec<String> = Vec::new();

Another useful type from the standard library is HashMap<K, V>

use std::collections::HashMap; // Require to import the `HashMap` type

fn main() {
    // Same for HashMap<K, V>
    let map_of_ints: HashMap<String, i32> = HashMap::new();
    // Same but without declaring the type:
    let map_of_ints = HashMap::<String, i32>::new();
}
Info - Further reading

See also Rust Book - Generics chapter.

Resources

The following hyperlinks provide to open resources to learn idiomatic Rust.

* Beginner

** Intermediate

*** Advanced

Standard library: https://doc.rust-lang.org/std/index.html

Packages and libraries indices

Learning

Conventions and idiomatic

Selection of cargo extensions

  • clippy - code linter with for idiomatic code
  • cargo-nextest - a next-generation test runner for Rust
  • cargo-llvm-cov - easily use LLVM source-based code coverage
  • cargo-release - release workflow (auto validating-versioning-publishing)

Blog posts and Tutorials

YouTube