Even more basics: Loops to threads in Rust

Even more basics: Loops to threads in Rust

·

11 min read

This is a recap summary of my recent learnings,

Loops

There are three main ways to loop in Rust:

  1. for loop:

This can be used to iterate through a range of values e.g

    for i in 0..10 {
        println!("{}", i);
    }

or iterate over the elements of a collection e.g vectors,

    let mut vec = vec![1, 2, 3, 4, 5];
    for i in vec.iter_mut() {
        *i += 1;
    }
    println!("{:?}", vec);

In this example, we use the iter_mut method to get a mutable iterator over the elements of the vector. The for loop then uses this iterator to visit each element and modify it. The * operator is used to dereference the element, allowing us to modify its value.

  1. while loop: We use a while loop to execute a block of code repeatedly as long as a condition is met
    let mut i = 0;
    while i < 10 {
        println!("{}", i);
        i += 1;
    }

while loops can also be used to iterate over elements of a collection e.g:

    let fruits = vec!["apple", "banana", "cherry"];
    let mut index = 0;
    while index < fruits.len() {
        println!("{}", fruits[index]);
        index += 1;
    }

Here fruits is a Vec of strings containing three fruit names. The while loop continues to run as long as the value of the index is less than the length of the fruits vector. On each iteration, the current fruit name is printed to the console and the value of the index is incremented by 1.

  1. loop (infinite loop) The loop keyword is used to create an infinite loop. It also supports optional labels, these can be used to control the loop from within the code.
    let mut outer_counter = 0;
    'outer: loop {
        println!("Outer Loop Iteration: {}", outer_counter);
        outer_counter += 1;
        let mut inner_counter = 0;
        'inner: loop {
            println!(" Inner Loop Iteration: {}", inner_counter);
            inner_counter += 1;
            if inner_counter == 2 {
                continue 'outer;
            }
            if outer_counter == 2 {
                break 'outer;
            }
        }
    }

In this example, we can break out of the main loop by calling break 'outer from within the inner loop

Functions and Closures

Functions

Functions in Rust are declared using the fn keyword, and can accept parameters and return values. They are statically typed, meaning that the type of each parameter and the return value must be specified.

fn add(a: i32, b: i32) -> i32 {
    a + b
}

This above function can be called from anywhere in your code by passing in two integers as arguments:

fn main() {
    let result = add(1, 2);
    println!("The result is: {}", result);
}
// The result is: 3

Functions can also return multiple values using a tuple:

fn divide(a: i32, b: i32) -> (i32, i32) {
    (a / b, a % b)
}

The above function takes two integers as parameters and returns a tuple containing the result of the division and the remainder.

fn main() {
    let (quotient, remainder) = divide(10, 3);
    println!(
        "The quotient is: {} and the remainder is: {}",
        quotient, remainder
    );
}
// The quotient is: 3 and the remainder is: 1

Closures

Closures are anonymous functions and are declared using a shorthand syntax, and can be stored in variables or passed as arguments to functions.

Think of lambdas in python.

E.g a simple closure that takes an integer and returns its square

    let square = |x: i32| -> i32 { x.pow(2) };
    println!("The square of 2 is: {}", square(2));
    // The square of 2 is: 4

Closures can also capture values from their surrounding scope and use them within the closure. e.g:

    let x = 2;
    let mul_x = |y: i32| -> i32 { x * y };
    println!(" 2 * 3 is: {}", mul_x(3));
    //  2 * 3 is: 6

Here the closure mul_x captures the value of x from its surrounding scope and uses it within the closure. When the closure is executed, it multiplies x by its parameter y

Structs

Structs are custom data types that allow you to group related data into a single entity.

Structs are declared using the struct keyword and the fields of a struct can be any primitive type, such as integers or strings, or any other user-defined type, such as other structs. E.g:

struct Employee {
    id: u32,
    name: String,
    role: String,
    salary: f32,
}

This struct above represents an employee and has four fields: id, name, role, and salary.

We then can create an instance of the Employee struct and access its fields using dot notation.

    let employee = Employee {
        id: 1,
        name: "John Doe".to_string(),
        role: "Developer".to_string(),
        salary: 40000.0,
    };
    println!(
        "Employee {} ({}) is a {} and makes ${:.2} per year",
        employee.name, employee.id, employee.role, employee.salary
    );
    // Employee John Doe (1) is a Developer and makes $40000.00 per year // (yeah not every dev makes the big bucks, lol)

Structs can also have methods, which are functions that are associated with a struct and can operate on its data.

If it helps, you could think of structs as simpler and more lightweight python classes

Methods

Methods are functions that are associated with a struct or an enum (more on these below) and provide a way to encapsulate behaviour that is specific to that data type. Methods can access the fields of the struct or enum they are associated with and can be used to define behaviour that is specific to that data type.

E.g: We define a struct Circle with the radius field, and then define an implementation (impl) block that contains a method area, which calculates the area of the circle using the radius field.

struct Circle {
    radius: f64,
}

impl Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

In the below main function, we create an instance of the Circle struct and call the area method to calculate the area of the circle.

fn main() {
    let c = Circle { radius: 2.0 };
    println!("The area of the circle is: {}", c.area());
}
// The area of the circle is: 12.566370614359172

Traits

Traits are a way to define shared behaviour for types. They allow you to define a set of methods that can be implemented by multiple types, without having to create a common base type or use inheritance. This provides a way to define generic behaviour that can be reused across multiple types.

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

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

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

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

fn print_area<T: Shape>(shape: T) {
    println!("The area of the shape is: {}", shape.area());
}

Here we define a trait Shape that contains a single method, area. We then define two structs, Circle and Rectangle, that implement the Shape trait. This means that instances of the Circle and Rectangle structs can be used wherever a value of type Shape is required.

fn main() {
    let circle = Circle { radius: 2.0 };
    let rectangle = Rectangle {
        width: 3.0,
        height: 4.0,
    };
    print_area(circle);
    print_area(rectangle);
}
// The area of the shape is: 12.566370614359172
// The area of the shape is: 12

Then we create instances of the Circle and Rectangle structs in the main function above, and call the print_area function, which takes a value of type Shape as an argument. The print_area function then calls the area method on the argument, regardless of whether it is a Circle or a Rectangle.

This shows how traits provide a way to define generic behaviour that can be reused across multiple types.

Collections

Rust provides several types of collections to handle different use cases. Some of these are:

  • Vec (vector): a dynamically-sized array that can grow or shrink as needed

  • String: a UTF-8 encoded string

  • HashMap: a hash table that stores key-value pairs

  • BTreeMap: a map implemented as a B-tree, which provides efficient lookups and ordered iteration

  • LinkedList: a doubly-linked list

  • Stack and Queue: data structures that provide push, pop, and other operations with different performance characteristics

Vector

    let mut numbers = Vec::new();
    numbers.push(1);
    numbers.push(2);
    numbers.push(3);

    for number in numbers {
        println!("{}", number);
    }
// 1
// 2
// 3

In the above example, we create a new Vec called numbers and push three values onto it. We then use a for loop to iterate over the values in the Vec and print each one.

String

The String collection in Rust is a growable, UTF-8 encoded string type.

    let mut hello = String::from("Hello, ");
    hello.push_str("world!");

    println!("{}", hello);

    let slice = &hello[7..12];
    println!("{}", slice);
// Hello, world!
// world

Here we create a mutable String hello and append the string "world!" to it. Printing Hello, world!

The slice variable is a string slice that references a portion of the hello string.

HashMap

The HashMap is a collection that stores key-value pairs and provides efficient lookups, insertion, and deletion operations.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    for (key, value) in &scores {
        println!("{}: {}", key, value);
    }

    let team = String::from("Blue");
    let score = scores.get(&team);
    match score {
        Some(s) => println!("{}: {}", team, s),
        None => println!("{} not found", team),
    }
}

In the above example, we create a new HashMap called scores and insert two key-value pairs into it. We then use a for loop to iterate over the scores HashMap and print each key-value pair.

Finally, we use the get method to look up the score for a team, and use a match expression to handle the case where the team is not found in the HashMap.

BTreeMap

The BTreeMap collection is a map implementation that uses a B-tree data structure to store its items.

Items in a BTreeMap are stored in sorted order, and the order is preserved when iterating over the items.

use std::collections::BTreeMap;

fn main() {
    let mut movie_ratings = BTreeMap::new();
    movie_ratings.insert(String::from("The Matrix"), 9);
    movie_ratings.insert(String::from("Arcane"), 8);
    movie_ratings.insert(String::from("Interstellar"), 10);

    for (movie, rating) in &movie_ratings {
        println!("{}: {}", movie, rating);
    }

    let movie = String::from("The Matrix");
    let rating = movie_ratings.get(&movie);
    match rating {
        Some(r) => println!("{}: {}", movie, r),
        None => println!("{} not found", movie),
    }
}

In the above example, we create a new BTreeMap called movie_ratings and insert three key-value pairs into it.

We can use a get method for look up just like in the HashMap.

Enums

Enums allow for the definition of a set of named values that can be used in your code. They are useful for representing values that have a limited and well-defined set of possibilities.

Methods and traits can also be used with enums:

Let's define an enum Movie (I like Christopher Nolan movies, lol!)

enum Movie {
    TheDarkKnight,
    Inception,
    Interstellar,
    Dunkirk,
    Memento,
}

Next, let's implement a trait for our enum. For this example, let's create a Summary trait that will print a summary of each movie.

trait Summary {
    fn summarize(&self) -> String;
}

We can then implement the Summary trait for our Movie enum.

impl Summary for Movie {
    fn summarize(&self) -> String {
        match self {
            Movie::TheDarkKnight => String::from("The Dark Knight is a 2008 superhero film."),
            Movie::Inception => String::from("Inception is a 2010 science fiction action film."),
            Movie::Interstellar => String::from("Interstellar is a 2014 science fiction film."),
            Movie::Dunkirk => String::from("Dunkirk is a 2017 war film."),
            Movie::Memento => String::from("Memento is a 2000 neo-noir mystery film."),
        }
    }
}

Since we can also add methods to our enum, let's add a duration method that will return the duration of each movie in minutes.

impl Movie {
    fn duration(&self) -> i32 {
        match self {
            Movie::TheDarkKnight => 152,
            Movie::Inception => 148,
            Movie::Interstellar => 169,
            Movie::Dunkirk => 106,
            Movie::Memento => 113,
        }
    }
}

We can then use these implementantions as follows:

fn main() {
    let dark_knight = Movie::TheDarkKnight;
    println!("{}", dark_knight.summarize());
    println!("Duration: {} minutes", dark_knight.duration());
}
// The Dark Knight is a 2008 superhero film.
// Duration: 152 minutes

Threads

Threads are a way of running multiple parts of your program simultaneously. In Rust, one way to achieve this is by using the std::thread module.

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

This creates a new thread that runs the code inside the closure, while the main thread continues to run the code outside the closure. The handle.join().unwrap() line is used to wait for the spawned thread to finish before ending the program.

You will notice that move wasn't used in the closure, that's because the closure didn't take any value from the surrounding scope.

If the closure captures values from its environment, it needs to be moved to ensure that those values are still available when the closure is executed within the thread (more on this at a later point).

E.g:

use std::thread;

fn main() {
    let val = 5;
    let handle = thread::spawn(move || {
        println!("The value is: {}", val);
    });

    handle.join().unwrap();
}

Wrap-Up

And with that, this marks the end of this recap, while there are a lot of basics here, there are many more complex topics to dive into within each of these areas. But for now, this should ideally provide me with a decent foundation to continue with this whole thing.

But for now, don't be afraid to keep exploring and learning!