Lecture 1 / 12
Lecture 01 · Fundamentals

Introduction to Rust & Setup

What is Rust?

Rust is a modern systems programming language focused on performance, memory safety, and thread safety without a garbage collector. It is designed to provide the control of a low-level language (like C or C++) while ensuring that the programmer cannot make common mistakes that lead to crashes or security vulnerabilities.

Created by Mozilla and now governed by the Rust Foundation, Rust introduces a unique concept called the Ownership System. This system allows the compiler to manage memory automatically and safely at compile-time, meaning your program doesn't need a "garbage collector" to pause execution and clean up memory.

Why Rust?

  • Zero-cost Abstractions: You can use high-level programming features (like iterators and closures) without any performance penalty compared to writing it manually in a low-level way.
  • Memory Safety: Rust prevents "Null Pointer" errors and "Buffer Overflows" by checking your code during compilation. If it compiles, it is memory-safe.
  • Fearless Concurrency: Rust's type system prevents "Data Races" (where two threads try to change the same piece of data at once), making multi-threaded programming much safer.
  • Growing Ecosystem: From high-performance web servers (Actix, Axum) to WebAssembly (WASM) and embedded systems, Rust is versatile.

Rust consistently ranks as one of the most loved programming languages in the Stack Overflow Developer Survey because it gives developers confidence that their code will run safely and efficiently.

Installation & Toolchain

The best way to install Rust is via rustup—the official toolchain installer. It not only installs the compiler but also manages different versions of Rust and its essential tools.

terminal
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustc --version
cargo --version

After installation, restart your terminal. You can update Rust anytime using rustup update.

Windows users: The installer will guide you through setting up the Visual C++ Build Tools if needed. This is required because Rust relies on these tools to link your code into an executable.

Recommended IDE Setup

While you can use any text editor, the industry standard for Rust is Visual Studio Code with the following extension:

  • rust-analyzer: This is essential. It provides autocomplete, type hints, and real-time error checking as you type.

Understanding Cargo

In Rust, you rarely call the compiler (rustc) directly. Instead, you use Cargo, Rust's all-in-one build system and package manager.

Cargo handles three main tasks:

  • Building your code: Compiling your project and managing dependencies.
  • Downloading Crates: "Crates" are Rust libraries. Cargo downloads them from crates.io automatically.
  • Managing Projects: It organizes your folder structure and handles versioning.

Your First Rust Program

Every Rust program starts with a main() function — this is the entry point of your application.

main.rs
fn main() {
    println!("Hello, Rust Mastery!");
    println!("Welcome to safe systems programming!");
}
Output
Hello, Rust Mastery!
Welcome to safe systems programming!

Breaking Down the Code

  • fn keyword: Used to declare a function. In Rust, functions are the primary way to group logic.
  • println!: Notice the !. This tells Rust that println! is a Macro, not a regular function. Macros are used when a function needs to do complex things, like varying the number of arguments it can take.
  • Semicolons (;): Every statement in Rust must end with a semicolon. If you omit it, Rust might treat the line as a return value.
🎯 Exercise 1.1

Create your first project using Cargo. Run cargo new hello_rust in your terminal, then modify src/main.rs to print your name and your goal for learning Rust. Run it using cargo run.

Experiment with build modes. Try using cargo run --release. Notice that it takes longer to compile, but the resulting binary is highly optimized for speed.

Pro Tip: Use cargo check frequently. It checks if your code will compile without actually building the binary, which is much faster and helps you catch errors quickly.

Lecture 02 · Fundamentals

Variables & Ownership

Introduction to Variables

Variables are used to store data in memory. In Rust, variables are safe, predictable, and designed to prevent many common programming errors.

Rust focuses heavily on memory safety and performance, which is why variables and ownership are such important concepts in the language.

Immutability by Default

In Rust, variables are immutable by default. This means their values cannot be changed after they are created.

Immutability helps make programs safer and easier to understand because values remain predictable.

let x = 5;
// x = 6; // Error!

let mut y = 10;
y = 11; // OK

In the example above:

  • x is immutable and cannot be changed
  • y is mutable because it uses the mut keyword

Why Rust Uses Immutability

Rust encourages immutability because it reduces bugs and makes concurrent programming safer.

Immutable variables cannot accidentally change during program execution, making debugging easier.

Variable Declaration Syntax

Variables in Rust are declared using the let keyword.

let name = "Rust";
let age = 5;
let is_fast = true;

Rust automatically infers the data type in many situations.

Explicit Type Annotation

You can explicitly define variable types when needed.

let score: i32 = 100;
let price: f64 = 19.99;

Type annotations are useful when Rust cannot infer the type automatically.

Common Rust Data Types

Type Description Example
i32 32-bit integer 10
f64 64-bit floating point 3.14
bool Boolean value true
char Single Unicode character 'A'
&str String slice "Hello"

Mutable Variables

Mutable variables allow values to change during program execution.

let mut counter = 0;

counter = counter + 1;

println!("{}", counter);

Use mutable variables only when necessary to maintain code safety and clarity.

Shadowing Variables

Rust allows variables to be redefined using the same name. This feature is called shadowing.

let number = 5;

let number = number + 1;

println!("{}", number);

Unlike mutation, shadowing creates a completely new variable.

Difference Between Mutation and Shadowing

Mutation Shadowing
Uses mut Uses another let
Changes the same variable Creates a new variable
Cannot change type Can change type

Introduction to Ownership

Ownership is one of Rust’s most important concepts. It allows Rust to manage memory safely without needing a garbage collector.

Each value in Rust has a variable that's called its owner. When the owner goes out of scope, the value is dropped.

The Three Ownership Rules

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

Ownership and Scope

Scope defines where a variable is valid.

{
    let language = "Rust";

    println!("{}", language);
}

// language no longer exists here

Once the block ends, Rust automatically cleans up the variable from memory.

Ownership Transfer

When assigning certain types to another variable, ownership moves to the new variable.

let s1 = String::from("hello");
let s2 = s1;

// println!("{}", s1); // Error

After the move, s1 is no longer valid because ownership transferred to s2.

Cloning Data

If you want both variables to remain valid, use clone().

let s1 = String::from("Rust");
let s2 = s1.clone();

println!("{}", s1);
println!("{}", s2);

The clone() method creates a deep copy of the data.

Copy Types

Simple types such as integers are copied automatically instead of moved.

let x = 5;
let y = x;

println!("{}", x);
println!("{}", y);

Both variables remain valid because integers implement the Copy trait.

References and Borrowing

Rust allows borrowing data using references so ownership does not transfer.

fn print_text(text: &String) {
    println!("{}", text);
}

let message = String::from("Hello");

print_text(&message);

println!("{}", message);

The ampersand & creates a reference to the value.

Benefits of Ownership

  • Prevents memory leaks
  • Prevents dangling pointers
  • Improves memory safety
  • Eliminates the need for garbage collection

Common Beginner Mistakes

  • Trying to use variables after ownership moves
  • Forgetting to use mut for mutable variables
  • Confusing references with copies
  • Using values outside their scope

Practice Exercise

Create a mutable variable called count and increase its value.

Then create a String variable and experiment with ownership transfer and cloning.

let mut count = 1;

count = count + 1;

println!("{}", count);

let name = String::from("Rust");
let copied_name = name.clone();

println!("{}", copied_name);

Summary

In this lecture, you learned:

  • How Rust variables work
  • The difference between mutable and immutable variables
  • How ownership manages memory safely
  • How values move and clone in Rust
  • How borrowing and references work
Lecture 03 · Fundamentals

Data Types & Functions

⏱ ~25 minBeginnerRequires: Lecture 02

Rust is statically typed: every value has a type known at compile time. In this lecture you will learn the built-in types, how type inference works, and how to write functions that take and return values.

Scalar Types

A scalar type holds a single value. Rust has four primary scalar types.

  • Integers — signed i8, i16, i32, i64, i128, isize and unsigned u8, u16, u32, u64, u128, usize. The default is i32. usize/isize match the pointer size of the machine and are used for indexing.
  • Floating point — f32 and f64. The default is f64.
  • Boolean — bool, either true or false.
  • Character — char, a single Unicode scalar value written with single quotes, e.g. 'z' or '😀'. It is 4 bytes wide.
main.rs
fn main() {
    let age: u8 = 25;          // 0..=255
    let temperature = -3.5;    // inferred as f64
    let is_ready = true;       // bool
    let initial = 'R';         // char (single quotes!)
    let big = 1_000_000;       // underscores improve readability

    println!("{age} {temperature} {is_ready} {initial} {big}");
}

Type Inference and Annotations

You rarely need to write types for simple values because the compiler infers them. Annotations are required when the compiler cannot decide, for example when parsing a string:

main.rs
fn main() {
    let guess: u32 = "42".parse().expect("not a number");
    // Without ': u32' the compiler cannot know which numeric type to parse into.
    println!("{}", guess + 1); // 43
}
Integer overflow

In a debug build, arithmetic that overflows (for example 255u8 + 1) panics. In a release build it silently wraps around. When overflow is possible, use explicit methods such as checked_add, wrapping_add or saturating_add.

main.rs
fn main() {
    let x: u8 = 250;
    println!("{:?}", x.checked_add(10));    // None  (would overflow)
    println!("{}",   x.saturating_add(10)); // 255   (clamped)
    println!("{}",   x.wrapping_add(10));   // 4     (wraps around)
}

Compound Types

Compound types group several values into one type. Rust has two built-in ones: tuples and arrays.

Tuples

A tuple has a fixed length and may contain different types. Access elements by index or destructure them.

main.rs
fn main() {
    let person: (&str, u32, f64) = ("Ada", 36, 1.68);

    let (name, age, height) = person;      // destructuring
    println!("{name} is {age} and {height}m tall");
    println!("first field: {}", person.0); // index with a dot
}

Arrays

An array has a fixed length and every element has the same type. Arrays live on the stack. When you need a growable list, use Vec<T> (covered in the Collections lecture).

main.rs
fn main() {
    let primes = [2, 3, 5, 7, 11];          // [i32; 5]
    let zeros = [0u8; 4];                   // [0, 0, 0, 0]

    println!("{}", primes[0]);              // 2
    println!("{}", primes.len());           // 5
    // primes[10] would panic at runtime: index out of bounds
}

Functions

Functions are declared with fn and use snake_case names. Every parameter must have a type annotation, and the return type follows ->.

main.rs
fn add(a: i32, b: i32) -> i32 {
    a + b            // no semicolon: this expression is the return value
}

fn greet(name: &str) {
    println!("Hello, {name}!");   // returns () (the "unit" type)
}

fn main() {
    let sum = add(10, 20);
    println!("{sum}");            // 30
    greet("Rust");
}

Statements vs. Expressions

This is one of the most important ideas in Rust. A statement performs an action and returns nothing. An expression evaluates to a value. Adding a semicolon to the end of an expression turns it into a statement.

main.rs
fn square(x: i32) -> i32 {
    x * x        // expression: returns the value
}

// fn broken(x: i32) -> i32 {
//     x * x;    // ERROR: the semicolon makes this a statement, so the function returns ()
// }

fn main() {
    let y = {
        let a = 3;
        a + 1        // the block is an expression; y becomes 4
    };
    println!("{} {}", y, square(y)); // 4 16
}

Returning Multiple Values

Rust functions return one value, but that value can be a tuple. Callers destructure it.

main.rs
fn divide(a: f64, b: f64) -> (f64, bool) {
    if b == 0.0 {
        (0.0, false)             // could not divide
    } else {
        (a / b, true)
    }
}

fn main() {
    let (value, ok) = divide(10.0, 4.0);
    println!("{value} {ok}");    // 2.5 true
}
Looking ahead

Returning a (value, bool) pair works, but idiomatic Rust uses Option<T> or Result<T, E> for this. You will meet both in the Error Handling lecture.

⚠️ Common Mistakes
  • Adding a semicolon after the final expression of a function that should return a value (expected i32, found ()).
  • Using double quotes for a char. "a" is a &str; 'a' is a char.
  • Mixing numeric types: let x: i32 = 5; let y: i64 = x; does not compile. Convert explicitly with x as i64 or i64::from(x).
  • Forgetting that arrays have a fixed length. Use a Vec when the size changes.
💡 Pro Tips
  • Prefer i32 and f64 unless you have a reason to pick another size; they are fast and the defaults.
  • Use i64::from(x) for lossless conversions and try_from for conversions that might fail. Reserve as for cases where truncation is acceptable.
  • Run cargo clippy regularly. It flags many of the mistakes above with clear explanations.
🎯 Exercise 3.2

Write a small program that:

  1. Defines fn check_pass(grade: f64) -> bool that returns true when the grade is 50.0 or higher.
  2. Defines fn average(scores: [f64; 3]) -> f64 that returns the mean of three scores.
  3. Defines fn min_max(values: [i32; 5]) -> (i32, i32) that returns the smallest and largest value as a tuple.
  4. In main, calls all three and prints the results.

Structs, which let you model a Student properly, arrive in Lecture 07.

What is the default type of the literal 3.14?

  1. f32
  2. f64
  3. i32

Floating-point literals default to f64 because it is about as fast as f32 on modern CPUs but far more precise.

Why does this fail? fn double(x: i32) -> i32 { x * 2; }

  1. Parameters need mut
  2. The semicolon turns the last expression into a statement, so the function returns ()
  3. i32 cannot be multiplied

Without a trailing semicolon, x * 2 is the block's value. With one, the block evaluates to the unit type (), which does not match i32.

Which declaration is a valid Rust array of five integers?

  1. let a = [1, 2, 3, 4, 5];
  2. let a = (1, 2, 3, 4, 5);
  3. let a = <1, 2, 3, 4, 5>;

Square brackets create an array. Parentheses create a tuple, which is a different compound type.

Lecture 04 · Fundamentals

Control Flow

Introduction to Control Flow

Control flow determines how a program makes decisions and repeats actions. In Rust, control flow structures allow programs to react dynamically to different conditions.

The most common control flow tools are:

  • if expressions
  • else statements
  • Loops
  • match expressions

If Expressions

Rust uses if expressions to execute code only when a condition is true.

let condition = true;

let number = if condition { 5 } else { 6 };

In this example:

  • If condition is true, number becomes 5
  • Otherwise, number becomes 6

If Expressions Return Values

Unlike many programming languages, Rust’s if statements are expressions. This means they can return values.

let age = 18;

let status = if age >= 18 {
    "Adult"
} else {
    "Minor"
};

println!("{}", status);

Both branches must return the same type.

Boolean Conditions

Conditions inside an if statement must evaluate to a boolean value.

let number = 10;

if number > 5 {
    println!("Number is greater than 5");
}

Rust does not automatically convert integers into booleans.

Using Else Statements

The else keyword defines code that executes when the condition is false.

let logged_in = false;

if logged_in {
    println!("Welcome back!");
} else {
    println!("Please log in");
}

Else If Conditions

Use else if when checking multiple conditions.

let score = 85;

if score >= 90 {
    println!("Grade A");
} else if score >= 75 {
    println!("Grade B");
} else {
    println!("Grade C");
}

Conditions are checked from top to bottom.

Infinite Loops

The loop keyword creates an infinite loop that continues until manually stopped.

loop {
    println!("Running forever");
}

Infinite loops are useful for game engines, servers, and continuous processes.

Breaking Out of Loops

Use the break keyword to stop a loop.

let mut counter = 0;

loop {
    counter += 1;

    if counter == 5 {
        break;
    }
}

println!("Loop ended");

Returning Values from Loops

Rust loops can return values using break.

let mut counter = 0;

let result = loop {
    counter += 1;

    if counter == 10 {
        break counter * 2;
    }
};

println!("{}", result);

Output:

20

While Loops

The while loop repeats while a condition remains true.

let mut number = 3;

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

    number -= 1;
}

println!("LIFTOFF!");

For Loops

The for loop is commonly used to iterate through collections or ranges.

for number in 1..5 {
    println!("{}", number);
}

The range 1..5 includes 1 up to 4.

Inclusive Ranges

Use ..= to include the final value in the range.

for number in 1..=5 {
    println!("{}", number);
}

This prints numbers from 1 to 5.

Looping Through Arrays

You can iterate through array elements using a for loop.

let fruits = ["Apple", "Banana", "Orange"];

for fruit in fruits {
    println!("{}", fruit);
}

The Match Expression

The match expression is Rust’s powerful pattern matching system.

let number = 2;

match number {
    1 => println!("One"),
    2 => println!("Two"),
    3 => println!("Three"),
    _ => println!("Other"),
}

The underscore _ acts as a default case.

Why Match is Powerful

The match statement is safer and more expressive than many traditional switch statements.

  • Checks all possible cases
  • Supports pattern matching
  • Improves readability
  • Works with enums and complex data types

Nested Control Flow

Control flow structures can be nested inside one another.

let age = 20;
let has_ticket = true;

if age >= 18 {
    if has_ticket {
        println!("Entry allowed");
    }
}

Common Beginner Mistakes

  • Using non-boolean values as conditions
  • Creating infinite loops accidentally
  • Forgetting to update loop counters
  • Using mismatched return types in if expressions
  • Missing the default case in match

Practice Exercise

Create a program that:

  • Checks whether a number is even or odd
  • Prints numbers from 1 to 10 using a loop
  • Uses match to display the name of a weekday
let number = 8;

if number % 2 == 0 {
    println!("Even");
} else {
    println!("Odd");
}

Summary

In this lecture, you learned:

  • How Rust control flow works
  • How to use if expressions
  • How loops operate in Rust
  • How to use while and for loops
  • How powerful match expressions are
Lecture 05 · Fundamentals

Loops & Iterators

Looping with 'for'

The for loop is the most idiomatic way to iterate in Rust. Instead of using a counter and indexing into a collection, Rust uses the IntoIterator trait, which allows the loop to safely traverse elements without risk of "out of bounds" errors.

main.rs
for i in 1..=5 {
    println!("{}", i);
}

Range Syntax: Rust uses ranges to create sequences of numbers efficiently.

  • 1..5 → 1, 2, 3, 4 (Exclusive: stops before 5)
  • 1..=5 → 1, 2, 3, 4, 5 (Inclusive: includes 5)
  • 0..10 → Common for indexing arrays from 0 to 9
main.rs
// Advanced range operations
for i in 0..10 { ... }           // Standard 0 to 9
for i in (1..=10).step_by(2) { ... } // 1, 3, 5, 7, 9
for i in (1..100).rev() { ... }     // Countdown from 99 to 1

Iterating over Arrays, Vectors & Strings

When iterating over collections, you must decide how you want to handle Ownership. There are three main ways to create an iterator:

  • iter(): Borrows each element immutably (&T). The original collection stays intact.
  • iter_mut(): Borrows each element mutably (&mut T). Allows you to change values inside the loop.
  • into_iter(): Consumes the collection and takes ownership of the elements (T). The original collection is destroyed.
main.rs
let fruits = ["Apple", "Banana", "Mango"];

// 1. Basic iteration (borrowing)
for fruit in fruits {
    println!("I like {}", fruit);
}

// 2. Iterating with the index using .enumerate()
for (index, fruit) in fruits.iter().enumerate() {
    println!("Fruit # {}: {}", index, fruit);
}

while Loop

The while loop is used when the number of iterations is not known in advance, and you want to continue as long as a specific condition remains true.

main.rs
let mut number = 1;

while number <= 5 {
    println!("Number: {}", number);
    number += 1; // Must manually increment to avoid infinite loop
}

The Powerful 'loop' Keyword

loop creates an infinite loop. Unlike while, it doesn't check a condition at the start. It is often used for server listening loops or retry logic where you want the program to run until a specific break condition is met.

main.rs
let mut counter = 0;

loop {
    counter += 1;
    
    if counter > 10 {
        break; // Exit the loop immediately
    }
    
    if counter % 2 == 0 {
        continue;     // Skip the rest of this iteration
    }
    
    println!("Counter: {}", counter);
}

Returning Values from Loops

One of Rust's most unique features is that loop is an expression. This means you can return a value from a loop by passing it to the break keyword.

main.rs
let mut counter = 0;

let result = loop {
    counter += 1;
    
    if counter == 10 {
        break counter * 2;   // This value is assigned to 'result'
    }
};
println!("Final Result: {}", result); // Result: 20

Iterators in Rust (Functional Approach)

Rust’s real power lies in its iterator system. Iterators are lazy, meaning they do nothing until you call a "consuming" method like collect() or a for loop.

Commonly used adapter methods:

  • map(): Transforms each element.
  • filter(): Keeps only elements that match a condition.
  • sum(): Adds all elements together.
  • collect(): Transforms the iterator back into a collection (like a Vec).
main.rs
let numbers = vec![1, 2, 3, 4, 5];

// 1. Summing elements
let sum: i32 = numbers.iter().sum();

// 2. Chaining methods: Filter and then Map
let squared_evens: Vec = numbers.iter()
    .filter(|&x| x % 2 == 0) // Keep only even numbers
    .map(|x| x * x)            // Square them
    .collect();                // Turn back into a Vector

println!("Sum: {}", sum);
println!("Squared Evens: {:?}", squared_evens);

Pro Tips:

  • Prefer for loops over while when possible — they are more idiomatic and prevent infinite loops caused by forgetting to increment a counter.
  • When in doubt, use iter(). Only use into_iter() if you specifically need to destroy the original collection to move its data.
  • Remember that map and filter are lazy. If you don't call collect() or a loop, the code inside them will never even run!
Lecture 06 · Core Concepts

Ownership & Borrowing

Introduction to Ownership

Ownership is one of the most unique and important concepts in Rust. It is the system that allows Rust to manage memory safely without using a garbage collector.

Instead of automatically cleaning unused memory at random times, Rust follows strict ownership rules at compile time. These rules prevent memory leaks, dangling pointers, and data races.

Understanding ownership is essential because almost every Rust program depends on it.

The Three Ownership Rules

Rust ownership is based on three core rules:

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

These rules make memory management automatic and safe.

Variable Scope

A variable is only valid inside the scope where it is declared.

fn main() {

    {
        let name = "Rust";
        println!("{}", name);
    }

    // name is no longer valid here
}

When the scope ends, Rust automatically frees the memory associated with the variable.

Ownership Transfer

Some values are moved instead of copied. After a move occurs, the original variable can no longer be used.

fn main() {

    let s1 = String::from("hello");

    let s2 = s1;

    // println!("{}", s1);
}

In this example, ownership of the string moves from s1 to s2. The variable s1 becomes invalid after the move.

Why Rust Uses Moves

Rust uses move semantics to prevent multiple variables from trying to free the same memory location.

This eliminates common bugs such as:

  • Double free errors
  • Dangling pointers
  • Memory corruption

Copy Types

Simple primitive values are copied instead of moved because they are stored directly on the stack.

fn main() {

    let x = 10;

    let y = x;

    println!("{}", x);
    println!("{}", y);
}

Integers, booleans, characters, and floating-point numbers usually implement the Copy trait.

Cloning Data

If you want to create a deep copy of heap data, you can use the clone() method.

fn main() {

    let s1 = String::from("Rust");

    let s2 = s1.clone();

    println!("{}", s1);
    println!("{}", s2);
}

Unlike moves, cloning creates a completely separate copy of the data.

Ownership and Functions

Passing a variable to a function may transfer ownership depending on the type.

fn print_string(text: String) {
    println!("{}", text);
}

fn main() {

    let msg = String::from("Hello");

    print_string(msg);

    // msg is no longer valid here
}

The function takes ownership of the string parameter.

Returning Ownership

Functions can also return ownership back to the caller.

fn give_back(s: String) -> String {
    s
}

This allows ownership to move safely between functions.

References & Borrowing

Instead of transferring ownership, you can "borrow" a value using references (&).

fn calculate_length(s: &String) -> usize {
    s.len()
}

Borrowing allows a function to use a value without taking ownership of it.

This means the original variable remains valid after the function call.

fn main() {

    let text = String::from("Rust");

    let length = calculate_length(&text);

    println!("Length: {}", length);

    println!("{}", text);
}

Mutable References

By default, references are immutable. To modify borrowed data, you must use mutable references.

fn change(text: &mut String) {
    text.push_str(" language");
}

Mutable references allow safe modification while still preventing dangerous memory access.

fn main() {

    let mut name = String::from("Rust");

    change(&mut name);

    println!("{}", name);
}

Borrowing Rules

Rust enforces strict borrowing rules to guarantee memory safety.

  • You can have multiple immutable references at the same time.
  • You can only have one mutable reference at a time.
  • You cannot combine mutable and immutable references simultaneously.

Multiple Immutable References

let s = String::from("hello");

let r1 = &s;
let r2 = &s;

println!("{}, {}", r1, r2);

This is allowed because immutable references only read data.

Single Mutable Reference

let mut s = String::from("hello");

let r1 = &mut s;

Rust prevents multiple mutable references because simultaneous modification could cause data races.

Dangling References

Rust prevents dangling references at compile time.

// This code will not compile

fn dangle() -> &String {

    let s = String::from("hello");

    &s
}

The compiler stops this because the local variable would be destroyed after the function ends.

Slices and Borrowing

Slices are references to parts of collections without taking ownership.

let word = &text[0..4];

Slices are heavily used with strings and arrays.

Benefits of Ownership

  • Memory safety without garbage collection.
  • Prevention of data races.
  • Better performance.
  • Automatic resource cleanup.
  • Safer concurrent programming.

Common Beginner Mistakes

  • Trying to use variables after ownership has moved.
  • Creating multiple mutable references.
  • Confusing cloning with borrowing.
  • Forgetting to use mut for mutable references.
  • Returning references to local variables.

Summary

Ownership and borrowing are the foundation of Rust’s memory safety model. Instead of relying on garbage collection, Rust uses compile-time rules to manage memory safely and efficiently.

Although ownership may feel difficult at first, mastering it allows developers to write high-performance and reliable systems software with confidence.

Lecture 07 · Core Concepts

Structs & Enums

Introduction to Structs

Structs are custom data types that allow you to group related values together. They are similar to objects or classes in other programming languages, but Rust structs only store data.

Structs help organize code and make programs easier to understand and maintain.

Defining a Struct

A struct is created using the struct keyword.

struct User {
    username: String,
    email: String,
    active: bool,
}

This struct stores information related to a user account.

Creating Struct Instances

You can create instances of a struct by providing values for each field.

let user1 = User {
    username: String::from("rustacean"),
    email: String::from("[email protected]"),
    active: true,
};

Each field must receive a value of the correct type.

Accessing Struct Fields

Struct fields are accessed using dot notation.

println!("{}", user1.username);

This prints the value stored inside the username field.

Mutable Structs

To modify struct fields, the struct instance must be mutable.

let mut user1 = User {
    username: String::from("rustacean"),
    email: String::from("[email protected]"),
    active: true,
};

user1.active = false;

Without the mut keyword, Rust will prevent modifications.

Struct Update Syntax

Rust provides a convenient way to create new struct instances using existing values.

let user2 = User {
    email: String::from("[email protected]"),
    ..user1
};

The ..user1 syntax copies remaining fields from another struct instance.

Tuple Structs

Tuple structs are structs without named fields.

struct Color(u8, u8, u8);

let black = Color(0, 0, 0);

Tuple structs are useful when field names are unnecessary.

Unit-Like Structs

Rust also supports structs without fields.

struct Logger;

These are commonly used for traits or marker types.

Methods on Structs

Methods are defined using impl blocks.

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

impl Rectangle {

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

The &self parameter refers to the current struct instance.

Associated Functions

Associated functions belong to a struct but do not use self.

impl Rectangle {

    fn square(size: u32) -> Rectangle {

        Rectangle {
            width: size,
            height: size,
        }
    }
}

These functions are often used as constructors.

Introduction to Enums

Enums allow a value to be one of several possible variants. They are extremely powerful and widely used in Rust.

enum Direction {
    Up,
    Down,
    Left,
    Right,
}

The variable can only hold one variant at a time.

Enums With Data

Enum variants can also store additional data.

enum Message {
    Text(String),
    Number(i32),
    Quit,
}

This makes enums much more flexible than traditional enums in many other languages.

The Option Enum

Rust uses the Option enum to represent optional values safely.

let some_number = Some(5);

let no_value: Option<i32> = None;

The Option type helps prevent null pointer errors.

Pattern Matching

Pattern matching allows you to check enum variants and execute different code depending on the result.

match some_option {

    Some(i) => println!("{}", i),

    None => println!("None"),
}

The match statement is one of Rust’s most powerful control flow features.

Match Expressions

Every possible pattern must be handled in a match expression.

let number = 3;

match number {

    1 => println!("One"),

    2 => println!("Two"),

    3 => println!("Three"),

    _ => println!("Other"),
}

The underscore _ acts as a default case.

The if let Syntax

When you only care about one pattern, if let provides a shorter alternative.

if let Some(x) = some_option {
    println!("{}", x);
}

This is cleaner than writing a full match expression for simple cases.

The Result Enum

Rust also uses enums for error handling through the Result type.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

This allows programs to safely handle operations that may fail.

Benefits of Structs and Enums

  • Improve code organization.
  • Create custom data models.
  • Provide safer error handling.
  • Reduce bugs caused by invalid states.
  • Support powerful pattern matching.

Common Beginner Mistakes

  • Forgetting to make structs mutable.
  • Confusing tuple structs with tuples.
  • Ignoring all enum variants in match statements.
  • Trying to use null instead of Option.
  • Moving ownership accidentally when updating structs.

Practice

Practice

Create a struct called Book with fields for title, author, and pages.

Then create an enum called Status with variants:

  • Available
  • Borrowed
  • Reserved

Use a match statement to print different messages for each status.

Summary

Structs and enums are fundamental building blocks in Rust. Structs organize related data, while enums represent multiple possible states safely and efficiently.

Combined with pattern matching, these features allow Rust developers to write expressive, safe, and maintainable programs.

Lecture 08 · Core Concepts

Collections & Strings

Introduction to Collections

Collections are data structures used to store multiple values together. Rust provides several powerful collection types that help developers manage and organize data efficiently.

Unlike arrays, many collections in Rust are dynamic, meaning they can grow or shrink during program execution.

The most commonly used collections include:

  • Vectors
  • Strings
  • Hash Maps

These collections are stored on the heap, allowing flexible memory usage.

Vectors (Dynamic Arrays)

Vectors are dynamic arrays that can store multiple values of the same type. They are one of the most commonly used collections in Rust.

let mut v = vec![1, 2, 3];

v.push(4);

The vec! macro creates a vector containing initial values.

The push() method adds new elements to the end of the vector.

Creating Empty Vectors

You can also create an empty vector and add elements later.

let mut numbers: Vec<i32> = Vec::new();

numbers.push(10);
numbers.push(20);

Because the vector starts empty, Rust requires an explicit type declaration.

Accessing Vector Elements

Vector elements can be accessed using indexing or the safer get() method.

let first = v[0];

match v.get(1) {
    Some(value) => println!("{}", value),
    None => println!("No value found"),
}

Using get() is safer because it prevents runtime crashes caused by invalid indexes.

Iterating Through Vectors

You can loop through vector elements using a for loop.

for value in &v {
    println!("{}", value);
}

The &v creates an immutable reference so ownership is not moved.

Modifying Vector Elements

Mutable references allow vector elements to be modified during iteration.

for value in &mut v {
    *value += 1;
}

The * operator dereferences the mutable reference to access the actual value.

Vector Memory and Ownership

Vectors follow Rust’s ownership rules. When a vector is assigned to another variable, ownership moves unless the vector is cloned.

let v1 = vec![1, 2, 3];

let v2 = v1;

// v1 is no longer valid

To create a full copy, use the clone() method.

let v2 = v1.clone();

Strings in Rust

Rust provides two main string types:

  • String
  • &str (string slice)

A String is growable and heap-allocated, while &str is an immutable reference to string data.

Creating Strings

let name = String::from("Rust");

let language = "Programming";

The first variable creates an owned string, while the second is a string slice.

Appending to Strings

Strings can grow dynamically using methods such as push_str() and push().

let mut text = String::from("Hello");

text.push_str(" World");

text.push('!');

The final string becomes Hello World!.

String Concatenation

Strings can be combined using the + operator or the format! macro.

let s1 = String::from("Hello");

let s2 = String::from(" Rust");

let s3 = s1 + &s2;

The + operator moves ownership of the first string.

The format! macro is often cleaner and safer.

let message = format!("{} {}", s1, s2);

String Slices

String slices allow references to parts of a string without taking ownership.

let text = String::from("Rustacean");

let slice = &text[0..4];

println!("{}", slice);

This slice contains the value Rust.

UTF-8 and Strings

Rust strings are stored as UTF-8 encoded text. This allows support for international languages and Unicode characters.

Because UTF-8 characters may use multiple bytes, direct indexing into strings is not allowed.

// This is invalid in Rust

// let ch = text[0];

Instead, Rust encourages safer approaches such as iterators.

Iterating Over Characters

for ch in text.chars() {
    println!("{}", ch);
}

The chars() method iterates through Unicode characters safely.

Hash Maps

Hash maps store data as key-value pairs.

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert("Alice", 90);

scores.insert("Bob", 85);

Hash maps are useful for fast lookups and storing related data.

Accessing Hash Map Values

match scores.get("Alice") {

    Some(score) => println!("{}", score),

    None => println!("No score found"),
}

The get() method returns an Option because the key may not exist.

Ownership and Collections

Collections often take ownership of inserted values.

let name = String::from("Rust");

scores.insert(name, 100);

// name is no longer valid

This behavior prevents invalid memory access and ensures safety.

Benefits of Rust Collections

  • Memory safety
  • High performance
  • Flexible dynamic storage
  • Powerful ownership tracking
  • Efficient data management

Common Beginner Mistakes

  • Trying to index strings directly.
  • Forgetting ownership transfer in collections.
  • Using indexing without checking bounds.
  • Confusing String and &str.
  • Mutating immutable vectors.

Practice

Practice

Create a vector that stores five numbers and write a loop that prints only even numbers.

Then create a string and append additional text using push_str().

Summary

Collections and strings are essential building blocks in Rust. Vectors provide dynamic storage, strings allow safe UTF-8 text handling, and hash maps organize key-value data efficiently.

Mastering these collections is important for building real-world Rust applications, APIs, command-line tools, and systems software.

Lecture 09 · Advanced

Error Handling

Introduction to Error Handling

Error handling is an essential part of writing reliable and safe programs. Rust provides a powerful system that helps developers handle errors explicitly instead of ignoring them.

Unlike many languages that rely heavily on exceptions, Rust encourages developers to handle failures using types such as Result<T, E> and Option<T>.

  • Improves application stability
  • Prevents unexpected crashes
  • Encourages safer programming practices
  • Makes failures more predictable

Panic vs Result

Rust groups errors into two categories:

  • Recoverable errors using Result<T, E>
  • Unrecoverable errors using panic!

Recoverable errors are situations the program can handle gracefully, while unrecoverable errors indicate serious bugs or invalid states.

Understanding panic!

The panic! macro immediately stops program execution when something goes critically wrong.

fn main() {
    panic!("Something went wrong!");
}

When a panic occurs, Rust prints an error message and stack trace.

Common Causes of Panic

  • Accessing invalid array indexes
  • Calling unwrap() on an error value
  • Invalid assumptions in the program
  • Unexpected runtime states

Recoverable Errors with Result

The Result enum represents operations that may succeed or fail.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Ok contains a successful value, while Err contains error information.

Reading Files with Result

Many standard library functions return a Result.

use std::fs::File;

fn main() {

    let file = File::open("data.txt");

    match file {

        Ok(f) => println!("File opened!"),

        Err(error) => println!("Error: {:?}", error),
    }
}

Pattern Matching with Result

Rust commonly uses match expressions to handle different outcomes.

let result: Result<int, string> = Ok(10);

match result {

    Ok(value) => println!("Value: {}", value),

    Err(msg) => println!("Error: {}", msg),
}

The unwrap() Method

The unwrap() method extracts the successful value from a Result.

let file = File::open("notes.txt").unwrap();

If the operation fails, unwrap() causes a panic.

The expect() Method

The expect() method works like unwrap() but allows a custom error message.

let file = File::open("config.txt")
    .expect("Failed to open config file");

Propagating Errors

Instead of handling errors immediately, functions can return them to the caller.

use std::fs::File;
use std::io::Error;

fn open_file() -> Result<File, Error> {

    let file = File::open("data.txt")?;

    Ok(file)
}

The ? Operator

The ? operator simplifies error propagation.

If the operation succeeds, the value is returned. If it fails, the error is automatically returned from the function.

fn divide(a: f64, b: f64) -> Result<f64, string> {

    if b == 0.0 {

        return Err("Cannot divide by zero".to_string());
    }

    Ok(a / b)
}

Option vs Result

Rust also provides the Option<T> enum for values that may or may not exist.

Type Purpose
Option<T> Represents missing values
Result<T, E> Represents recoverable errors

Using Option

fn find_number(numbers: Vec<i32>, target: i32) -> Option<usize> {

    for (index, value) in numbers.iter().enumerate() {

        if *value == target {

            return Some(index);
        }
    }

    None
}

Handling Multiple Error Types

Large applications may encounter different kinds of errors.

use std::num::ParseIntError;

fn parse_number(input: &str) -> Result<i32, ParseIntError> {

    input.parse::<i32>()
}

Creating Custom Errors

Developers can define custom error types using enums.

enum AppError {
    NotFound,
    InvalidInput,
}

Error Handling Best Practices

  • Use Result for recoverable errors
  • Avoid unnecessary panics
  • Use descriptive error messages
  • Prefer the ? operator for cleaner code
  • Handle errors close to where they occur

Common Mistakes

  • Overusing unwrap()
  • Ignoring error values
  • Using panic for recoverable situations
  • Returning unclear error messages
  • Not propagating errors properly

Mini Practice

Try creating the following:

  • A function returning Result<T, E>
  • A file-reading example with match
  • A custom error enum
  • A function using the ? operator
  • An Option example returning Some or None
Lecture 10 · Advanced

Modules & Crates

⏱ ~25 minIntermediateRequires: Lecture 09

As programs grow you need to organise code, hide implementation details, and reuse other people's work. Rust does this with modules (inside a crate) and crates (the unit of compilation and sharing), managed by Cargo.

Packages, Crates and Modules

  • Crate — the smallest unit the compiler builds. Either a binary crate (has main, root file src/main.rs) or a library crate (root file src/lib.rs).
  • Package — one or more crates described by a Cargo.toml.
  • Module — a namespace inside a crate that groups related items and controls their privacy.

Defining Modules

Everything in a module is private by default. Mark items pub to expose them to code outside the module.

main.rs
mod bank {
    pub struct Account {
        pub owner: String,
        balance: f64,            // private field: only code inside 'bank' can touch it
    }

    impl Account {
        pub fn new(owner: &str) -> Account {
            Account { owner: owner.to_string(), balance: 0.0 }
        }

        pub fn deposit(&mut self, amount: f64) {
            if amount > 0.0 {
                self.balance += amount;
            }
        }

        pub fn balance(&self) -> f64 {
            self.balance
        }
    }
}

fn main() {
    let mut acc = bank::Account::new("Ada");
    acc.deposit(50.0);
    println!("{} has {}", acc.owner, acc.balance());
    // acc.balance = 1_000_000.0;   // ERROR: field `balance` is private
}

Keeping balance private guarantees that the only way to change it is through deposit, which enforces the rules.

Paths and use

Items are addressed with paths. Start from the crate root with crate::, from the current module with self::, or from the parent with super::. Bring a path into scope with use.

main.rs
mod shapes {
    pub mod circle {
        pub fn area(r: f64) -> f64 { std::f64::consts::PI * r * r }
    }
    pub fn describe(r: f64) -> String {
        format!("area = {:.2}", self::circle::area(r))
    }
}

use shapes::circle;                 // now 'circle::area' is shorter
use std::collections::HashMap;      // from the standard library
use std::io::{self, Read};          // nested import: 'io' and 'Read'

fn main() {
    println!("{}", circle::area(2.0));
    println!("{}", shapes::describe(2.0));
    let _m: HashMap<String, i32> = HashMap::new();
}

Splitting Modules Across Files

A module can live in its own file. Declaring mod bank; tells the compiler to load it from src/bank.rs (or src/bank/mod.rs).

src/main.rs
// src/main.rs
mod bank;                    // loads src/bank.rs
use bank::Account;

fn main() {
    let acc = Account::new("Ada");
    println!("{}", acc.balance());
}
src/bank.rs
// src/bank.rs
pub struct Account { balance: f64 }

impl Account {
    pub fn new(_owner: &str) -> Self { Account { balance: 0.0 } }
    pub fn balance(&self) -> f64 { self.balance }
}

Cargo and External Crates

Cargo downloads dependencies from crates.io, builds them, and locks exact versions in Cargo.lock. Add a dependency from the command line or by editing Cargo.toml.

terminal
cargo add rand            # adds the latest compatible version to Cargo.toml
cargo build               # downloads and compiles everything
cargo run                 # build + run
cargo doc --open          # build and open documentation for your crate and its dependencies
Cargo.toml
[package]
name = "my_app"
version = "0.1.0"
edition = "2021"

[dependencies]
rand = "0.8"
serde = { version = "1", features = ["derive"] }
main.rs
use rand::Rng;

fn main() {
    let roll = rand::thread_rng().gen_range(1..=6);
    println!("You rolled a {roll}");
}
Semantic versioning

rand = "0.8" means “any version compatible with 0.8”, so Cargo may use 0.8.5 but never 0.9. Commit Cargo.lock for applications so every build uses identical versions.

⚠️ Common Mistakes
  • Forgetting pub on a function or struct field, then getting “is private” errors from another module.
  • Writing mod bank; but not creating src/bank.rs (or src/bank/mod.rs).
  • Making everything pub "to make errors go away". That removes the encapsulation modules are meant to provide.
  • Adding a crate to code with use without listing it in Cargo.toml.
💡 Pro Tips
  • Expose a small public API and keep helpers private. You can always make something public later, but you cannot take it back without breaking users.
  • Use pub(crate) for items that should be shared inside your crate but not exported.
  • Run cargo tree to see your dependency graph, and cargo audit (from the cargo-audit tool) to check for known vulnerabilities.
🎯 Exercise 10.1
  1. Create a new project with cargo new inventory.
  2. Add a module item in src/item.rs with a pub struct Item that has a public name and a private quantity.
  3. Provide pub fn new, pub fn add_stock(&mut self, n: u32) and pub fn quantity(&self) -> u32.
  4. In main.rs, create an item, add stock, and print it. Confirm that writing to quantity directly does not compile.
  5. Add the rand crate and give each new item a random starting quantity between 1 and 10.

What is the default visibility of an item in a module?

  1. Public
  2. Private to the module (and its children)
  3. Public inside the same crate only

Everything is private unless marked pub. Child modules can see their parents' private items, but not the other way around.

Which file does mod parser; in src/main.rs load?

  1. src/parser.rs or src/parser/mod.rs
  2. parser.toml
  3. src/parser/main.rs

The compiler looks for the module in a file named after it, or in a directory with a mod.rs.

What does super:: refer to in a path?

  1. The crate root
  2. The parent module
  3. The standard library

super goes up one level, crate starts at the root, and self means the current module.

Lecture 11 · Advanced

Traits & Generics

⏱ ~30 minIntermediateRequires: Lecture 10

Generics let one function or type work with many types. Traits describe behaviour a type can have. Together they give Rust abstraction with zero runtime cost.

Generics

Without generics you would write largest_i32, largest_f64, and so on. A type parameter T lets you write the logic once.

main.rs
fn largest<T: PartialOrd>(items: &[T]) -> &T {
    let mut largest = &items[0];
    for item in items {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    println!("{}", largest(&[34, 50, 25, 100, 65]));      // 100
    println!("{}", largest(&[1.5, 9.25, 3.0]));           // 9.25
    println!("{}", largest(&['y', 'm', 'a', 'q']));       // y
}

T: PartialOrd is a trait bound: it says “T can be any type that supports comparison with >”. Without it the compiler rejects item > largest.

Structs and enums can be generic too:

main.rs
struct Point<T> {
    x: T,
    y: T,
}

impl<T: std::fmt::Display> Point<T> {
    fn show(&self) {
        println!("({}, {})", self.x, self.y);
    }
}

fn main() {
    Point { x: 1, y: 2 }.show();
    Point { x: 1.5, y: 2.5 }.show();
}

Traits: Shared Behaviour

A trait declares methods that types can implement. It is similar to an interface in other languages.

main.rs
trait Summary {
    fn author(&self) -> String;

    // default implementation (can be overridden)
    fn summarize(&self) -> String {
        format!("Read more from {}...", self.author())
    }
}

struct Article { title: String, writer: String }
struct Tweet   { handle: String, text: String }

impl Summary for Article {
    fn author(&self) -> String { self.writer.clone() }
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.writer)   // override
    }
}

impl Summary for Tweet {
    fn author(&self) -> String { format!("@{}", self.handle) }
    // uses the default summarize()
}

fn main() {
    let a = Article { title: "Rust 2024".into(), writer: "Ada".into() };
    let t = Tweet { handle: "ferris".into(), text: "Hello".into() };
    println!("{}", a.summarize());   // Rust 2024 by Ada
    println!("{}", t.summarize());   // Read more from @ferris...
    let _ = t.text;
}

Traits as Parameters

Accept “anything that implements Summary” using impl Trait or the equivalent trait bound syntax:

main.rs
fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

// Equivalent, and necessary when several parameters must share one type:
fn notify_twice<T: Summary>(a: &T, b: &T) {
    println!("{} | {}", a.summarize(), b.summarize());
}

// Several bounds, written with 'where' for readability:
fn debug_and_show<T>(value: T)
where
    T: std::fmt::Debug + std::fmt::Display,
{
    println!("{value} / {value:?}");
}

Trait Objects: Dynamic Dispatch

Generics are resolved at compile time (static dispatch), which is fast but requires one concrete type per use. When you need a collection of different types behind one trait, use a trait object with dyn.

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

struct Circle { r: f64 }
struct Rect { w: f64, h: f64 }

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

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { r: 1.0 }),
        Box::new(Rect { w: 2.0, h: 3.0 }),
    ];

    let total: f64 = shapes.iter().map(|s| s.area()).sum();
    println!("total area = {total:.2}");   // 9.14
}
Static vs. dynamic dispatch
Generics / impl Traitdyn Trait
ResolvedCompile timeRun time (vtable)
SpeedFastest, can inlineSmall indirection cost
Mixed types in one VecNoYes
Binary sizeLarger (one copy per type)Smaller

Derivable Traits

The standard library traits Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord and Default can be generated with #[derive(...)].

main.rs
#[derive(Debug, Clone, PartialEq, Default)]
struct Config {
    name: String,
    retries: u32,
}

fn main() {
    let a = Config { name: "svc".into(), retries: 3 };
    let b = a.clone();
    println!("{:?}", a);          // Config { name: "svc", retries: 3 }
    println!("{}", a == b);       // true
    println!("{:?}", Config::default());
}
⚠️ Common Mistakes
  • Calling a method on a generic T without a bound: “no method named … found for type parameter T”. Add the trait to the bound.
  • Trying to put different types in a Vec without Box<dyn Trait>.
  • Implementing a foreign trait for a foreign type (e.g. Display for Vec<i32>). This is forbidden by the orphan rule; wrap the type in your own struct instead.
  • Using dyn Trait with a trait that has generic methods or returns Self (not object-safe).
💡 Pro Tips
  • Start with generics and impl Trait. Reach for dyn Trait only when you truly need runtime polymorphism.
  • Prefer where clauses once a signature has more than one or two bounds.
  • Implement std::fmt::Display for your types so they print nicely, and From to make conversions ergonomic.
🎯 Exercise 11.1
  1. Define a trait Describe with a method fn describe(&self) -> String.
  2. Implement it for two structs of your choice (for example Dog and Car).
  3. Write a generic function print_all<T: Describe>(items: &[T]).
  4. Build a Vec<Box<dyn Describe>> holding both a dog and a car, and print every description.
  5. Derive Debug and Clone on both structs.

What does fn show<T: Display>(x: T) mean?

  1. x can be any type that implements Display
  2. T must be a string
  3. The function can only be called once

T: Display is a trait bound restricting T to types that implement Display.

When do you need Box<dyn Trait> rather than generics?

  1. Whenever a trait is involved
  2. When a collection must hold different concrete types behind one trait
  3. When you want faster code

A generic Vec<T> holds one type. A Vec<Box<dyn Trait>> can hold any mix of implementors, at the price of dynamic dispatch.

Which line creates a default Clone implementation?

  1. #[derive(Clone)]
  2. impl Clone;
  3. use Clone;

derive asks the compiler to generate the standard implementation when all fields are themselves Clone.

Lecture 12 · Capstone

Capstone Project: Secure CLI

⏱ ~90 minProjectRequires: Lectures 01–11

You will build hashsum, a small command-line tool that computes the SHA-256 checksum of a file and can verify a download against an expected hash. It is a real-world security task and it exercises ownership, error handling, traits, modules and testing.

🎯 What you will practice
  • Parsing arguments with clap
  • Reading files in chunks with std::io::Read
  • Hashing with the sha2 crate
  • A custom error enum with Display and From
  • Proper exit codes so the tool works in scripts
  • A unit test with a known-answer vector

Acceptance Criteria

  1. hashsum file.txt prints <64 hex chars> file.txt and exits with code 0.
  2. hashsum file.txt --verify <hash> prints OK file.txt and exits 0 when the hash matches (case-insensitive), otherwise prints a clear mismatch message to stderr and exits 1.
  3. A missing or unreadable file produces a readable error (no panic, no stack trace) and exit code 1.
  4. Files larger than memory work: the tool reads in fixed-size chunks.
  5. cargo test passes, including a test that hashes "abc".

Milestone 1 — Project Setup

terminal
cargo new hashsum
cd hashsum
cargo add clap --features derive
cargo add sha2 hex

Verify that cargo run prints “Hello, world!” before moving on.

Milestone 2 — Parse Arguments

With clap’s derive API, a struct is your command-line interface. Doc comments become the help text.

main.rs
use clap::Parser;
use std::path::PathBuf;

/// Compute (and optionally verify) the SHA-256 checksum of a file.
#[derive(Parser)]
#[command(name = "hashsum", version, about)]
struct Cli {
    /// File to hash
    file: PathBuf,

    /// Expected hex digest. Exit code is 1 if it does not match.
    #[arg(short, long)]
    verify: Option<String>,
}

fn main() {
    let cli = Cli::parse();
    println!("{:?} {:?}", cli.file, cli.verify);
}

Try cargo run -- --help and cargo run -- notes.txt -v abc.

Milestone 3 — Hash a Stream

Write the hashing logic against the Read trait, not against File. That makes it trivially testable with in-memory bytes.

main.rs
use sha2::{Digest, Sha256};
use std::io::{self, Read};

fn sha256_reader(mut reader: impl Read) -> io::Result<String> {
    let mut hasher = Sha256::new();
    let mut buf = [0u8; 8192];             // fixed 8 KiB chunk
    loop {
        let n = reader.read(&mut buf)?;
        if n == 0 {
            break;                         // end of input
        }
        hasher.update(&buf[..n]);          // hash only the bytes actually read
    }
    Ok(hex::encode(hasher.finalize()))
}

Milestone 4 — Errors

An enum models every way the program can fail. Implementing From<io::Error> lets the ? operator convert automatically.

main.rs
use std::fmt;

#[derive(Debug)]
enum HashError {
    Io(io::Error),
    Mismatch { expected: String, actual: String },
}

impl fmt::Display for HashError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            HashError::Io(e) => write!(f, "I/O error: {e}"),
            HashError::Mismatch { expected, actual } => write!(
                f,
                "checksum mismatch\n  expected: {expected}\n  actual:   {actual}"
            ),
        }
    }
}

impl std::error::Error for HashError {}

impl From<io::Error> for HashError {
    fn from(e: io::Error) -> Self {
        HashError::Io(e)
    }
}

Milestone 5 — Complete Solution

Try each milestone yourself first. Then compare with the finished program.

src/main.rs
use clap::Parser;
use sha2::{Digest, Sha256};
use std::fmt;
use std::fs::File;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

/// Compute (and optionally verify) the SHA-256 checksum of a file.
#[derive(Parser)]
#[command(name = "hashsum", version, about)]
struct Cli {
    /// File to hash
    file: PathBuf,

    /// Expected hex digest. Exit code is 1 if it does not match.
    #[arg(short, long)]
    verify: Option<String>,
}

#[derive(Debug)]
enum HashError {
    Io(io::Error),
    Mismatch { expected: String, actual: String },
}

impl fmt::Display for HashError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            HashError::Io(e) => write!(f, "I/O error: {e}"),
            HashError::Mismatch { expected, actual } => write!(
                f,
                "checksum mismatch\n  expected: {expected}\n  actual:   {actual}"
            ),
        }
    }
}

impl std::error::Error for HashError {}

impl From<io::Error> for HashError {
    fn from(e: io::Error) -> Self {
        HashError::Io(e)
    }
}

fn sha256_reader(mut reader: impl Read) -> io::Result<String> {
    let mut hasher = Sha256::new();
    let mut buf = [0u8; 8192];
    loop {
        let n = reader.read(&mut buf)?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }
    Ok(hex::encode(hasher.finalize()))
}

fn sha256_file(path: &Path) -> Result<String, HashError> {
    Ok(sha256_reader(File::open(path)?)?)
}

fn run(cli: &Cli) -> Result<(), HashError> {
    let digest = sha256_file(&cli.file)?;

    match &cli.verify {
        Some(expected) if !expected.eq_ignore_ascii_case(&digest) => Err(HashError::Mismatch {
            expected: expected.clone(),
            actual: digest,
        }),
        Some(_) => {
            println!("OK  {}", cli.file.display());
            Ok(())
        }
        None => {
            println!("{digest}  {}", cli.file.display());
            Ok(())
        }
    }
}

fn main() -> ExitCode {
    let cli = Cli::parse();
    match run(&cli) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("error: {e}");
            ExitCode::FAILURE
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn hashes_abc() {
        // Known-answer test vector from the SHA-256 specification (FIPS 180-4)
        let digest = sha256_reader(&b"abc"[..]).unwrap();
        assert_eq!(
            digest,
            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
        );
    }

    #[test]
    fn empty_input_has_known_hash() {
        let digest = sha256_reader(&b""[..]).unwrap();
        assert_eq!(
            digest,
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
    }
}

Run and Verify

terminal
cargo test
cargo run -- Cargo.toml
cargo run -- Cargo.toml --verify 0000   # prints a mismatch and exits with code 1
echo $?                                  # (PowerShell: $LASTEXITCODE)
💡 Why this design is "secure"
  • It never loads the whole file into memory, so large inputs cannot exhaust RAM.
  • It never uses unwrap() on user-controlled input: every failure becomes a typed error.
  • It reports a mismatch with a non-zero exit code, so scripts and CI can rely on it.
  • SHA-256 is collision-resistant. (MD5 and SHA-1 are not safe for verifying downloads.)
⚠️ Limits to be aware of

A checksum proves a file was not accidentally corrupted. It only proves authenticity if you obtained the expected hash from a trusted channel (for example the vendor’s HTTPS site). For stronger guarantees use digital signatures.

Challenge Version

When the guided version works, extend it without looking at hints:

  1. Add --algo sha256|sha512 using a clap::ValueEnum.
  2. Accept several files and print one line per file.
  3. Read a SHA256SUMS file (format: hash filename) and verify every entry, like sha256sum -c.
  4. Hash stdin when the file argument is -.
  5. Add an integration test in tests/cli.rs that runs the compiled binary (hint: the assert_cmd crate).
Lecture 13 · Advanced

Lifetimes

⏱ ~30 minAdvancedRequires: Lecture 06

Every reference in Rust has a lifetime: the region of code in which it is valid. Most of the time the compiler works it out for you. Lifetime annotations are how you explain the relationships it cannot infer.

The Problem Lifetimes Solve

Lifetimes exist to prevent dangling references — references to data that has already been dropped.

main.rs
fn main() {
    let r;                      // declared, not yet initialised
    {
        let x = 5;
        r = &x;                 // r borrows x
    }                           // x is dropped here
    // println!("{}", r);       // ERROR: `x` does not live long enough
}

The borrow checker compares how long r lives with how long x lives and rejects the program. In C or C++ this would compile and read freed memory.

Annotating Function Signatures

Consider a function that returns the longer of two string slices:

main.rs
// ERROR: missing lifetime specifier
// fn longest(a: &str, b: &str) -> &str {
//     if a.len() > b.len() { a } else { b }
// }

The returned reference might come from a or b, and the compiler cannot know which. We state the relationship with a lifetime parameter 'a:

main.rs
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

fn main() {
    let s1 = String::from("a long string");
    let result;
    {
        let s2 = String::from("short");
        result = longest(&s1, &s2);
        println!("{result}");        // OK: both inputs are alive here
    }
    // println!("{result}");         // ERROR if uncommented: s2 no longer lives
}

&'a str reads “a reference that is valid for at least 'a”. The signature says: the result is valid only as long as both inputs are valid (the shorter of the two lifetimes).

Annotations never change how long data lives

Lifetime annotations are descriptions, not instructions. They tell the compiler how the lifetimes of inputs and outputs relate so it can verify your code is safe. They have no effect at runtime.

Lifetimes in Structs

A struct that holds a reference must declare how long that reference lives:

main.rs
struct Excerpt<'a> {
    part: &'a str,
}

impl<'a> Excerpt<'a> {
    fn first_word(&self) -> &str {
        self.part.split_whitespace().next().unwrap_or("")
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first = novel.split('.').next().unwrap();
    let ex = Excerpt { part: first };
    println!("{}", ex.first_word());     // Call
}   // 'ex' cannot outlive 'novel'

Lifetime Elision

You did not write lifetimes for earlier functions like fn first_word(s: &str) -> &str because the compiler applies three elision rules:

  1. Each reference parameter gets its own lifetime.
  2. If there is exactly one input lifetime, it is assigned to all output references.
  3. If there is a &self or &mut self parameter, its lifetime is assigned to all output references.

When the rules cannot determine the output lifetime (as in longest), you must annotate.

The 'static Lifetime

'static means the data can live for the entire program. String literals have this lifetime because they are stored in the binary:

main.rs
let s: &'static str = "I live for the whole program";
⚠️ Do not reach for 'static to silence errors

If the compiler suggests 'static it usually means the real fix is to own the data (String instead of &str) or to restructure so the borrow is shorter.

Owning vs. Borrowing: a Design Decision

main.rs
// Borrowing: cheap, but tied to the lifetime of the source
struct ParserBorrowed<'a> { input: &'a str }

// Owning: no lifetime parameter, easier to move around and return
struct ParserOwned { input: String }

Start with owned data. Introduce borrowed fields when profiling shows copying is a real cost.

⚠️ Common Mistakes
  • Returning a reference to a local variable: fn make() -> &String { let s = String::new(); &s }. The value is dropped when the function returns. Return the String itself.
  • Annotating every reference with the same 'a out of habit. Over-constraining lifetimes makes valid code fail to compile.
  • Thinking 'a extends how long a value lives. It only describes a relationship.
  • Storing a reference in a struct and then trying to return the struct from the function that owns the data.
💡 Pro Tips
  • Read compiler lifetime errors from the bottom: they point to the variable that is dropped too early.
  • If a function takes one reference and returns one reference, you almost never need annotations.
  • When you are fighting lifetimes in a struct, switch the field to an owned type and see whether the design gets simpler.
🎯 Exercise 13.1
  1. Write fn first_longer<'a>(a: &'a str, b: &'a str) -> &'a str that returns a unless b is strictly longer.
  2. Create a struct Highlight<'a> holding a &'a str and a method len(&self) -> usize.
  3. Deliberately write a program where a reference outlives its owner. Read the error message and fix it two ways: by moving the declaration, and by cloning into an owned String.

What does a lifetime annotation like 'a do at runtime?

  1. Keeps the data alive longer
  2. Nothing — it is checked at compile time only
  3. Adds a reference counter

Lifetimes are erased during compilation. They only help the borrow checker prove references are valid.

Why does fn longest(a: &str, b: &str) -> &str fail to compile?

  1. Strings cannot be compared
  2. The compiler cannot tell whether the output borrows from a or b
  3. Functions cannot return references

Elision rule 2 needs exactly one input lifetime. With two inputs the output is ambiguous, so you must annotate.

What is the lifetime of a string literal?

  1. 'static
  2. The enclosing function
  3. It has none

Literals are baked into the executable, so they are valid for the entire run of the program.

Lecture 14 · Advanced

Smart Pointers (Box, Rc, RefCell)

⏱ ~35 minAdvancedRequires: Lecture 13

A smart pointer is a data structure that acts like a pointer but carries extra metadata and behaviour, such as automatic cleanup or reference counting. This lecture covers the three you will use most: Box, Rc and RefCell.

Box<T>: Heap Allocation

Box stores a value on the heap and keeps a pointer to it on the stack. It has a single owner and is freed automatically when it goes out of scope.

main.rs
fn main() {
    let b = Box::new(5);        // the integer 5 lives on the heap
    println!("{}", *b + 1);     // dereference with *  -> 6
}                               // b is dropped, heap memory freed

The classic use is a recursive type. Without a pointer the compiler cannot compute the type’s size, because a list would contain a list that contains a list…

main.rs
enum List {
    Cons(i32, Box<List>),   // Box has a known size, so List does too
    Nil,
}

use List::{Cons, Nil};

fn sum(list: &List) -> i32 {
    match list {
        Cons(value, rest) => value + sum(rest),
        Nil => 0,
    }
}

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
    println!("{}", sum(&list));   // 6
}

Box<dyn Trait> (from the Traits lecture) is another common use.

Rc<T>: Shared Ownership

Sometimes a value legitimately has several owners, for example nodes in a graph. Rc (reference counted) keeps a count of owners and frees the data when the count reaches zero.

main.rs
use std::rc::Rc;

fn main() {
    let a = Rc::new(String::from("shared data"));
    println!("count = {}", Rc::strong_count(&a));   // 1

    let b = Rc::clone(&a);                          // cheap: increments the count
    println!("count = {}", Rc::strong_count(&a));   // 2

    {
        let c = Rc::clone(&a);
        println!("count = {} ({c})", Rc::strong_count(&a)); // 3
    }                                               // c dropped

    println!("count = {} ({b})", Rc::strong_count(&a));    // 2
}
⚠️ Rc is single-threaded

Rc is not safe to share between threads. Its thread-safe sibling is Arc (atomic reference counting), used in the Concurrency lecture. The compiler enforces this: sending an Rc to another thread is a compile error.

Rc<T> only gives shared, read-only access. To mutate shared data we combine it with RefCell.

RefCell<T>: Interior Mutability

Normally the borrow rules (one &mut or many &) are checked at compile time. RefCell moves that check to runtime, letting you mutate data through a shared reference. If you violate the rules, the program panics.

main.rs
use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);

    data.borrow_mut().push(4);            // temporary mutable borrow
    println!("{:?}", data.borrow());      // [1, 2, 3, 4]
}
main.rs
use std::cell::RefCell;

fn main() {
    let cell = RefCell::new(5);
    let first = cell.borrow_mut();
    let second = cell.borrow_mut();       // PANIC: already mutably borrowed
    println!("{first} {second}");
}

The Combination: Rc<RefCell<T>>

Multiple owners that can all mutate the same value:

main.rs
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Account { balance: i32 }

fn main() {
    let shared = Rc::new(RefCell::new(Account { balance: 100 }));

    let owner_a = Rc::clone(&shared);
    let owner_b = Rc::clone(&shared);

    owner_a.borrow_mut().balance += 50;
    owner_b.borrow_mut().balance -= 30;

    println!("{:?}", shared.borrow());    // Account { balance: 120 }
}
Choosing a pointer
TypeOwnersMutability checksThread-safe
Box<T>OneCompile timeIf T is
Rc<T>ManyCompile time (read-only)No
RefCell<T>OneRuntimeNo
Arc<Mutex<T>>ManyRuntime (locking)Yes

Reference Cycles and Weak

If two Rc values point at each other, their counts never reach zero and the memory leaks. Break cycles with Weak<T>, a non-owning reference created with Rc::downgrade and turned back into an Option<Rc<T>> with upgrade(). A typical use is a tree where children hold strong pointers to nothing and a weak pointer to their parent.

main.rs
use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade().map(|p| p.value)); // Some(5)
    println!("branch strong = {}, weak = {}", Rc::strong_count(&branch), Rc::weak_count(&branch)); // 1, 1
    println!("children = {}", branch.children.borrow().len());
}
⚠️ Common Mistakes
  • Using Rc when plain ownership or a borrow would do. Each Rc adds a counter and hides who really owns the data.
  • Holding a borrow_mut() guard across another borrow() of the same RefCell, causing a runtime panic. Keep guards short-lived.
  • Creating Rc cycles, which leak memory. Use Weak for back-references.
  • Using Rc<RefCell<T>> across threads. Use Arc<Mutex<T>> instead.
💡 Pro Tips
  • Prefer Rc::clone(&a) over a.clone(). It makes it obvious you are copying a pointer, not the data.
  • If you can restructure to avoid interior mutability, do. RefCell trades compile-time guarantees for runtime panics.
  • Use try_borrow_mut() when you cannot be sure the cell is free; it returns a Result instead of panicking.
🎯 Exercise 14.1
  1. Define a binary tree enum Tree { Leaf, Node(Box<Tree>, i32, Box<Tree>) } and write fn sum(t: &Tree) -> i32.
  2. Create an Rc<RefCell<Vec<String>>> log shared between two structs, each pushing messages. Print the final log and Rc::strong_count.
  3. Trigger a RefCell double-borrow panic on purpose, then fix it by limiting the scope of the first borrow.

Why does a recursive enum need Box?

  1. To make it faster
  2. So the compiler can compute a finite size for the type
  3. To allow mutation

Without indirection the type would contain itself and have infinite size. A Box is a fixed-size pointer.

What happens when the last Rc to a value is dropped?

  1. Nothing; the value leaks
  2. The value is freed
  3. The program panics

When the strong count reaches zero the value is dropped and its memory released.

When does RefCell check borrowing rules?

  1. At compile time
  2. At runtime
  3. Never

RefCell enforces the same rules as the borrow checker but dynamically, panicking on violation.

Lecture 15 · Advanced

Concurrency (Threads & Channels)

⏱ ~35 minAdvancedRequires: Lecture 14

Rust’s ownership rules catch data races at compile time. This is often called fearless concurrency: if your threaded program compiles, it is free of data races (though not necessarily of deadlocks or logic bugs).

Spawning Threads

std::thread::spawn starts an OS thread running a closure. It returns a JoinHandle; calling join() waits for the thread and gives back its result.

main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=3 {
            println!("worker: step {i}");
            thread::sleep(Duration::from_millis(50));
        }
        42                                  // the thread's return value
    });

    println!("main: waiting...");
    let answer = handle.join().unwrap();    // blocks until the worker finishes
    println!("worker returned {answer}");
}

If main returns before a spawned thread finishes, the thread is killed. Always join threads whose work you need.

Moving Data into Threads

A spawned thread might outlive the function that created it, so it cannot borrow local variables. Use move to transfer ownership into the closure.

main.rs
use std::thread;

fn main() {
    let numbers = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("sum = {}", numbers.iter().sum::<i32>());
    });

    // println!("{:?}", numbers);   // ERROR: value moved into the closure
    handle.join().unwrap();
}

Message Passing with Channels

“Do not communicate by sharing memory; share memory by communicating.” An mpsc channel (multiple producer, single consumer) moves values from senders to a receiver.

main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    for id in 0..3 {
        let tx = tx.clone();                       // one sender per producer
        thread::spawn(move || {
            tx.send(format!("hello from worker {id}")).unwrap();
        });
    }
    drop(tx);                                      // close the original sender

    for message in rx {                            // ends when ALL senders are dropped
        println!("{message}");
    }
}

Sending moves the value, so the sender can no longer use it. That is exactly what prevents two threads from touching the same data.

Shared State with Arc<Mutex<T>>

Sometimes threads genuinely need the same data. Mutex allows one thread at a time to access it, and Arc (atomic Rc) lets several threads own it.

main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = Vec::new();

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut n = counter.lock().unwrap();   // lock; unlocked when 'n' goes out of scope
            *n += 1;
        }));
    }

    for h in handles {
        h.join().unwrap();
    }

    println!("final count = {}", *counter.lock().unwrap());   // always 10
}
RwLock and atomics

For read-heavy data use RwLock (many readers or one writer). For a single integer counter, std::sync::atomic::AtomicUsize is faster than a mutex.

The Send and Sync Traits

These marker traits are how the compiler knows what is safe:

  • Send — a value of this type can be moved to another thread.
  • Sync — a value can be referenced from several threads (&T is Send).
  • Rc<T> is neither, which is why using it across threads is a compile error. Arc<T> is both (when T is).

Scoped Threads

Since Rust 1.63, thread::scope lets threads borrow local data because the scope guarantees they finish before it ends. No move, Arc or manual join is needed.

main.rs
use std::thread;

fn main() {
    let data = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = data.split_at(3);

    let (a, b) = thread::scope(|s| {
        let t1 = s.spawn(|| left.iter().sum::<i32>());
        let t2 = s.spawn(|| right.iter().sum::<i32>());
        (t1.join().unwrap(), t2.join().unwrap())
    });

    println!("{a} + {b} = {}", a + b);   // 6 + 15 = 21
}
⚠️ Common Mistakes
  • Forgetting drop(tx): the receiving for loop never ends because one sender is still alive.
  • Holding a MutexGuard longer than needed (for example while doing slow I/O), serialising all threads.
  • Locking two mutexes in different orders in different threads, which can deadlock. Always acquire locks in a consistent order.
  • Calling .unwrap() on lock() without understanding poisoning: if a thread panics while holding the lock, later lock() calls return Err.
💡 Pro Tips
  • Prefer channels when work flows one way (pipelines, workers). Prefer Arc<Mutex> for genuinely shared state.
  • For CPU-bound data parallelism, use the rayon crate: vec.par_iter().map(...) parallelises with one line.
  • Keep critical sections tiny: lock, copy out what you need, unlock.
🎯 Exercise 15.1
  1. Spawn four threads that each compute the sum of a quarter of a Vec<u64> of 1 million numbers and send the partial sum through a channel. Add the results in main.
  2. Re-implement the same task with thread::scope and compare how much simpler it is.
  3. Write a deliberately racy-looking program using Rc across threads. Read the compiler error and explain which trait is missing.

Why is move needed in thread::spawn(move || ...)?

  1. To make the thread faster
  2. The thread may outlive the current function, so it must own the data it uses
  3. To enable channels

Borrowed locals could be dropped while the thread is still running, so the closure must take ownership.

Which combination lets several threads mutate one counter?

  1. Rc<RefCell<i32>>
  2. Arc<Mutex<i32>>
  3. Box<i32>

Arc provides thread-safe shared ownership and Mutex provides exclusive, synchronised access.

When does a for msg in rx loop stop?

  1. After 1 message
  2. When all senders have been dropped
  3. Never

The receiver iterator ends once the channel is closed, which happens when every Sender is dropped.

Lecture 16 · Advanced

Closures & Advanced Iterators

⏱ ~30 minAdvancedRequires: Lecture 05

Closures are anonymous functions that can capture their environment. Combined with iterators they let you express data processing as readable pipelines that compile down to the same machine code as hand-written loops.

Closures

A closure is written with pipes around its parameters. Types are usually inferred.

main.rs
fn main() {
    let add_one = |x: i32| x + 1;
    let multiply = |a, b| a * b;                 // types inferred from first use

    let offset = 10;
    let add_offset = |x| x + offset;             // captures 'offset' from its environment

    println!("{} {} {}", add_one(5), multiply(3, 4), add_offset(5));   // 6 12 15
}

How Closures Capture

A closure captures each variable in the least restrictive way that works: by shared reference, by mutable reference, or by value. The compiler then implements one or more of three traits:

TraitCan be calledCaptures
FnMany timesOnly reads captured values
FnMutMany timesMutates captured values
FnOnceOnceMoves captured values out
main.rs
fn main() {
    let mut count = 0;
    let mut tick = || { count += 1; count };   // FnMut: mutates 'count'
    tick();
    tick();
    println!("{}", tick());                    // 3

    let name = String::from("Ada");
    let consume = move || name;                // FnOnce: moves 'name' out when called
    let owned = consume();
    println!("{owned}");
}

Use move to force the closure to take ownership, which is required when returning a closure or sending it to a thread.

Closures as Parameters and Return Values

main.rs
fn apply_twice<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
    f(f(x))
}

fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n
}

fn make_boxed(n: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x * n)       // use Box<dyn Fn> when different closures may be returned
}

fn main() {
    println!("{}", apply_twice(|x| x * 3, 2));   // 18
    let add5 = make_adder(5);
    println!("{}", add5(10));                    // 15
    println!("{}", make_boxed(4)(6));            // 24
}

Iterators Are Lazy

An iterator implements Iterator and produces values through next(). Adaptors such as map and filter build a new iterator but do nothing until a consumer such as collect, sum or a for loop drives it.

main.rs
fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6];

    let evens_squared: Vec<i32> = numbers
        .iter()
        .filter(|&&n| n % 2 == 0)     // keep even numbers
        .map(|&n| n * n)              // square them
        .collect();                   // consume into a Vec

    println!("{:?}", evens_squared);  // [4, 16, 36]
}

The Adaptors You Will Use Daily

main.rs
fn main() {
    let words = vec!["apple", "bob", "cat", "dodo"];

    // enumerate: pair each item with its index
    for (i, w) in words.iter().enumerate() {
        println!("{i}: {w}");
    }

    // zip: walk two iterators in lockstep
    let prices = [1.5, 0.5, 2.0, 3.25];
    let total: f64 = words.iter().zip(prices.iter()).map(|(_, p)| p).sum();
    println!("total = {total}");                                     // 7.25

    // fold: reduce to a single value
    let total_len = words.iter().fold(0, |acc, w| acc + w.len());
    println!("{total_len}");                                         // 15

    // any / all / find / position
    println!("{}", words.iter().any(|w| w.starts_with('b')));        // true
    println!("{:?}", words.iter().position(|&w| w == "cat"));        // Some(2)

    // take / skip / rev / chain
    let first_two: Vec<_> = words.iter().take(2).collect();
    println!("{:?}", first_two);                                     // ["apple", "bob"]

    // flat_map, collecting into other containers
    use std::collections::HashMap;
    let lengths: HashMap<&str, usize> = words.iter().map(|w| (*w, w.len())).collect();
    println!("{}", lengths["dodo"]);                                 // 4
}
⚡ Zero-cost abstraction

Iterator chains are optimised by the compiler into tight loops, often as fast as hand-written ones and sometimes faster because bounds checks can be elided. You do not pay for the readability.

collect into Result

A powerful trick: collecting an iterator of Result stops at the first error.

main.rs
fn main() {
    let good: Result<Vec<i32>, _> = "1,2,3".split(',').map(|s| s.parse::<i32>()).collect();
    let bad:  Result<Vec<i32>, _> = "1,x,3".split(',').map(|s| s.parse::<i32>()).collect();

    println!("{:?}", good);        // Ok([1, 2, 3])
    println!("{}", bad.is_err());  // true
}

Writing Your Own Iterator

Implement Iterator by defining Item and next. You get every adaptor for free.

main.rs
struct Countdown(u32);

impl Iterator for Countdown {
    type Item = u32;

    fn next(&mut self) -> Option<u32> {
        if self.0 == 0 {
            None
        } else {
            self.0 -= 1;
            Some(self.0 + 1)
        }
    }
}

fn main() {
    let v: Vec<u32> = Countdown(5).filter(|n| n % 2 == 1).collect();
    println!("{:?}", v);   // [5, 3, 1]
}
⚠️ Common Mistakes
  • Forgetting that iterators are lazy: v.iter().map(|x| println!("{x}")); prints nothing because nothing consumes it. (The compiler warns: unused iterator adaptors are lazy.)
  • Mixing up iter() (borrows, yields &T), iter_mut() (yields &mut T) and into_iter() (consumes, yields T).
  • Collecting into a Vec in the middle of a chain just to loop again. Keep chaining.
  • Capturing a variable by reference in a closure that is returned, instead of using move.
💡 Pro Tips
  • Annotate the target type of collect() (Vec<_>, HashMap<_, _>, String…) or use the turbofish: collect::<Vec<_>>().
  • Use iter().copied() or .cloned() to turn &T items into T when T: Copy/Clone.
  • Prefer sum(), max(), min_by_key() over manual loops: they are clearer and equally fast.
🎯 Exercise 16.1
  1. Given let text = "the quick brown fox jumps over the lazy dog";, use one iterator chain to build a Vec<String> of the upper-cased words longer than 3 letters.
  2. Count how many times each word appears using fold or a for loop over a HashMap.
  3. Write fn compose(f: impl Fn(i32) -> i32, g: impl Fn(i32) -> i32) -> impl Fn(i32) -> i32 that returns g(f(x)).
  4. Implement an iterator Fibonacci and print the first 10 numbers with .take(10).

Which closure trait lets you call the closure only once?

  1. Fn
  2. FnMut
  3. FnOnce

FnOnce closures move something out of their environment, so they cannot be called again.

What does (1..4).map(|x| x * 2); do on its own?

  1. Returns [2, 4, 6]
  2. Nothing — iterators are lazy until consumed
  3. Panics

map only creates a new iterator. A consumer like collect or sum is needed to run it.

Which method turns an iterator into a single accumulated value?

  1. fold
  2. zip
  3. enumerate

fold repeatedly applies a closure to an accumulator and each item, producing one result.

Lecture 17 · Professional

Async Rust (Tokio)

⏱ ~35 minAdvancedRequires: Lecture 15

Threads are great for CPU-bound work, but a server with 10,000 mostly-idle connections should not need 10,000 threads. Async lets one thread juggle many tasks that spend their time waiting on I/O.

Futures and async/.await

An async fn returns a future: a value representing a computation that will finish later. Futures are lazy; they do nothing until they are .awaited or handed to a runtime.

main.rs
async fn double(x: u32) -> u32 {
    x * 2
}

async fn compute() -> u32 {
    let a = double(5).await;     // .await pauses THIS task, letting others run
    let b = double(a).await;
    a + b                        // 10 + 20 = 30
}

Rust’s standard library defines the Future trait but ships no runtime. You pick one. Tokio is the de-facto standard.

Setting Up Tokio

terminal
cargo new async_demo
cd async_demo
cargo add tokio --features full
main.rs
use tokio::time::{sleep, Duration};

#[tokio::main]                       // turns main into a synchronous entry point that starts the runtime
async fn main() {
    println!("start");
    sleep(Duration::from_millis(500)).await;   // does NOT block the thread
    println!("done after 500ms");
}

Running Things Concurrently

Awaiting futures one after another is sequential. To overlap waits, use tokio::join! or spawn tasks.

main.rs
use tokio::time::{sleep, Duration, Instant};

async fn fetch(id: u32) -> String {
    sleep(Duration::from_millis(300)).await;     // pretend network call
    format!("result {id}")
}

#[tokio::main]
async fn main() {
    let start = Instant::now();

    // Sequential: ~600ms
    let a = fetch(1).await;
    let b = fetch(2).await;
    println!("{a}, {b} in {:?}", start.elapsed());

    // Concurrent: ~300ms
    let start = Instant::now();
    let (c, d) = tokio::join!(fetch(3), fetch(4));
    println!("{c}, {d} in {:?}", start.elapsed());
}

Spawning Tasks

tokio::spawn runs a future as an independent task on the runtime’s thread pool, returning a JoinHandle. Tasks are very cheap (a few hundred bytes), so spawning thousands is fine.

main.rs
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let mut handles = Vec::new();

    for id in 0..5u64 {
        handles.push(tokio::spawn(async move {
            sleep(Duration::from_millis(100 * (5 - id))).await;
            id * id
        }));
    }

    for h in handles {
        println!("task result = {}", h.await.unwrap());   // JoinHandle::await returns Result
    }
}

Because a spawned task may run on another thread, the future must be Send and 'static. That is why we use async move.

Channels Between Tasks

main.rs
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<String>(8);     // bounded: applies back-pressure

    for i in 0..3 {
        let tx = tx.clone();
        tokio::spawn(async move {
            tx.send(format!("message {i}")).await.unwrap();
        });
    }
    drop(tx);

    while let Some(msg) = rx.recv().await {
        println!("{msg}");
    }
}

Timeouts and select!

main.rs
use tokio::time::{sleep, timeout, Duration};

#[tokio::main]
async fn main() {
    let slow = sleep(Duration::from_secs(5));

    match timeout(Duration::from_millis(200), slow).await {
        Ok(_) => println!("finished"),
        Err(_) => println!("timed out"),          // this branch runs
    }

    tokio::select! {
        _ = sleep(Duration::from_millis(100)) => println!("timer won"),
        _ = sleep(Duration::from_millis(500)) => println!("slow timer won"),
    }
}
⚠️ Never block inside async code

Calling std::thread::sleep, reading a big file with std::fs, or doing heavy CPU work inside an async task stalls the worker thread and starves every other task on it. Use tokio::time::sleep, tokio::fs, or move the work to tokio::task::spawn_blocking.

main.rs
let result = tokio::task::spawn_blocking(|| expensive_cpu_work()).await.unwrap();

Async vs. Threads

ThreadsAsync tasks
Best forCPU-bound workMany concurrent I/O waits
Cost per unit~MBs of stack, OS scheduling~hundreds of bytes
Blocking callsFineHarmful
ComplexityLowerHigher (Send, pinning, colouring)
⚠️ Common Mistakes
  • Forgetting .await: the future is created but never runs. The compiler warns “unused implementer of Future that must be used”.
  • Holding a std::sync::MutexGuard across an .await. Use tokio::sync::Mutex or drop the guard first.
  • Using blocking APIs inside async functions (see warning above).
  • Awaiting in a loop when the operations are independent; use join!, join_all or spawn.
💡 Pro Tips
  • Use async only when you have many concurrent I/O operations. A CLI that makes three sequential HTTP calls does not need it.
  • Add #[tokio::test] to write async tests.
  • Use tracing for structured logs that follow a request across tasks.
🎯 Exercise 17.1
  1. Write an async fn slow_square(n: u64) -> u64 that sleeps 200 ms and returns n * n.
  2. Time how long it takes to compute the squares of 1..=10 sequentially, then concurrently with tokio::spawn. Explain the difference.
  3. Wrap the whole concurrent run in tokio::time::timeout of 1 second and handle the Err case.

What does calling an async fn without .await do?

  1. Runs it immediately
  2. Creates a future that does nothing until polled
  3. Spawns a new thread

Futures are lazy. Without .await or a spawn, the body never executes.

Why is std::thread::sleep bad inside an async task?

  1. It is deprecated
  2. It blocks the worker thread, stalling other tasks
  3. It cannot take a Duration

Async tasks share worker threads. Blocking one prevents all others scheduled on that thread from progressing.

Which macro runs two futures concurrently and waits for both?

  1. tokio::join!
  2. tokio::select!
  3. tokio::main!

join! waits for all futures. select! returns as soon as the first one finishes.

Lecture 18 · Professional

Final Project — Rust Web Service

⏱ ~120 minProjectRequires: All previous lectures

Build a small but real JSON REST API for a task list using Axum on top of Tokio. You will combine async, shared state, traits, error handling and serialisation — the exact stack used for production Rust services.

What You Will Build

MethodPathPurposeSuccess status
GET/tasksList all tasks200
POST/tasksCreate a task from {"title": "..."}201
POST/tasks/:id/doneMark a task complete200 (404 if missing)
DELETE/tasks/:idDelete a task204 (404 if missing)

Acceptance Criteria

  1. All four routes behave as in the table, returning JSON bodies where applicable.
  2. Unknown IDs return 404, not a panic or a 500.
  3. Empty or whitespace-only titles are rejected with 400.
  4. State is shared safely between requests with no data races (the compiler will insist).
  5. A handler test using tower::ServiceExt::oneshot proves POST /tasks followed by GET /tasks returns the new task.

Milestone 1 — Project Setup

terminal
cargo new task_api
cd task_api
cargo add [email protected]
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json
cargo add tower --dev --features util
cargo add http-body-util --dev

Milestone 2 — Data Model and Shared State

Handlers run concurrently, so shared state must be Send + Sync. We wrap it in Arc<Mutex<_>>. The lock is never held across an .await, so the standard-library mutex is fine and fast.

main.rs
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};

#[derive(Clone, Serialize)]
struct Task {
    id: u64,
    title: String,
    done: bool,
}

#[derive(Deserialize)]
struct NewTask {
    title: String,
}

#[derive(Default)]
struct Db {
    next_id: u64,
    tasks: Vec<Task>,
}

type Shared = Arc<Mutex<Db>>;

Milestone 3 — Handlers and Router

Each handler is an ordinary async fn. Axum uses extractors in the parameter list (State, Path, Json) and anything implementing IntoResponse as the return type. Returning Result<_, StatusCode> makes error handling idiomatic.

main.rs
use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::{delete, get, post},
    Json, Router,
};

async fn list(State(db): State<Shared>) -> Json<Vec<Task>> {
    Json(db.lock().unwrap().tasks.clone())
}

async fn create(
    State(db): State<Shared>,
    Json(input): Json<NewTask>,
) -> Result<(StatusCode, Json<Task>), StatusCode> {
    let title = input.title.trim().to_string();
    if title.is_empty() {
        return Err(StatusCode::BAD_REQUEST);
    }

    let mut db = db.lock().unwrap();
    db.next_id += 1;
    let task = Task { id: db.next_id, title, done: false };
    db.tasks.push(task.clone());
    Ok((StatusCode::CREATED, Json(task)))
}

async fn complete(
    State(db): State<Shared>,
    Path(id): Path<u64>,
) -> Result<Json<Task>, StatusCode> {
    let mut db = db.lock().unwrap();
    let task = db
        .tasks
        .iter_mut()
        .find(|t| t.id == id)
        .ok_or(StatusCode::NOT_FOUND)?;
    task.done = true;
    Ok(Json(task.clone()))
}

async fn remove(State(db): State<Shared>, Path(id): Path<u64>) -> StatusCode {
    let mut db = db.lock().unwrap();
    let before = db.tasks.len();
    db.tasks.retain(|t| t.id != id);
    if db.tasks.len() < before {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

fn app(db: Shared) -> Router {
    Router::new()
        .route("/tasks", get(list).post(create))
        .route("/tasks/:id", delete(remove))
        .route("/tasks/:id/done", post(complete))
        .with_state(db)
}

Milestone 4 — Start the Server

main.rs
#[tokio::main]
async fn main() {
    let db: Shared = Arc::default();
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .expect("could not bind port 3000");

    println!("listening on http://127.0.0.1:3000");
    axum::serve(listener, app(db)).await.unwrap();
}

Try It

Start the server with cargo run, then in another terminal:

terminal
# create
curl -X POST http://127.0.0.1:3000/tasks -H "content-type: application/json" -d '{"title":"Learn Rust"}'

# list
curl http://127.0.0.1:3000/tasks

# complete task 1
curl -X POST http://127.0.0.1:3000/tasks/1/done

# delete task 1  (-i shows the 204 status)
curl -i -X DELETE http://127.0.0.1:3000/tasks/1
Windows PowerShell

curl is an alias for Invoke-WebRequest in Windows PowerShell. Use curl.exe instead, and wrap the JSON in single quotes with doubled inner quotes, or use Invoke-RestMethod.

Milestone 5 — Test the Router Without a Network

Because app() returns a plain Router, we can call it directly as a tower::Service. No ports, no flakiness.

main.rs
#[cfg(test)]
mod tests {
    use super::*;
    use axum::{body::Body, http::Request};
    use http_body_util::BodyExt;
    use tower::ServiceExt; // for .oneshot()

    #[tokio::test]
    async fn create_then_list() {
        let db: Shared = Arc::default();

        let create = Request::post("/tasks")
            .header("content-type", "application/json")
            .body(Body::from(r#"{"title":"write tests"}"#))
            .unwrap();
        let res = app(db.clone()).oneshot(create).await.unwrap();
        assert_eq!(res.status(), StatusCode::CREATED);

        let list = Request::get("/tasks").body(Body::empty()).unwrap();
        let res = app(db).oneshot(list).await.unwrap();
        assert_eq!(res.status(), StatusCode::OK);

        let bytes = res.into_body().collect().await.unwrap().to_bytes();
        let body = String::from_utf8(bytes.to_vec()).unwrap();
        assert!(body.contains("write tests"));
    }

    #[tokio::test]
    async fn blank_title_is_rejected() {
        let db: Shared = Arc::default();
        let req = Request::post("/tasks")
            .header("content-type", "application/json")
            .body(Body::from(r#"{"title":"   "}"#))
            .unwrap();
        let res = app(db).oneshot(req).await.unwrap();
        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
    }
}
terminal
cargo test
⚠️ What this version intentionally leaves out
  • Persistence: tasks live in memory and vanish on restart.
  • Authentication: anyone who can reach the port can use it.
  • Graceful shutdown and structured logging.
  • Using unwrap() on the mutex lock. A panic while the lock is held would poison it. That is acceptable here because no handler panics while locked.

Challenge Version

Turn the prototype into something deployable. Attempt these without step-by-step guidance:

  1. Persist tasks in SQLite using the sqlx crate and replace the Mutex<Vec> with a connection pool in State.
  2. Replace StatusCode errors with your own AppError enum that implements IntoResponse and returns a JSON {"error": "..."} body.
  3. Add tower_http::trace::TraceLayer for request logging and tracing_subscriber to display it.
  4. Add a PATCH /tasks/:id route that updates the title, and write tests for it.
  5. Add graceful shutdown with with_graceful_shutdown listening for Ctrl-C.
  6. Containerise it with a multi-stage Dockerfile producing a small release image.
🏅 You made it

You have gone from println! to a tested async web service, using ownership, borrowing, traits, error handling, concurrency and async along the way. Publish the finished project on GitHub with a README and add it to your portfolio.

Topic 22 - Real-World Practical Projects

Project 1: Command-Line Pattern Search Utility (Grep Clone)

Hands-on Project Rust CLI & Safety
🎯 Project Goal

Build a memory-safe, blazing-fast CLI tool in Rust that searches files for matching text patterns, inspired by grep.

Project Overview

Master Rust ownership, borrowing, error handling (Result), command-line argument parsing, and file reading.

Full Code Implementation

minigrep.rs
use std::env;
use std::fs;
use std::process;

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result {
        if args.len() < 3 {
            return Err("Usage: minigrep  ");
        }
        Ok(Config { query: args[1].clone(), file_path: args[2].clone() })
    }
}

fn main() {
    let args: Vec = env::args().collect();
    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    let contents = fs::read_to_string(config.file_path).expect("Should have been able to read file");
    for line in contents.lines().filter(|l| l.contains(&config.query)) {
        println!("{line}");
    }
}
Practice Challenge

Add an environment variable check (CASE_INSENSITIVE=1) to perform case-insensitive pattern matching!