This is a recap summary of my recent learnings,
Loops
There are three main ways to loop in Rust:
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.
while
loop: We use awhile
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.
loop
(infinite loop) Theloop
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 pythonclasses
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 neededString
: a UTF-8 encoded stringHashMap
: a hash table that stores key-value pairsBTreeMap
: a map implemented as a B-tree, which provides efficient lookups and ordered iterationLinkedList
: a doubly-linked listStack
andQueue
: 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!