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.
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.ioautomatically. - 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.
fn main() { println!("Hello, Rust Mastery!"); println!("Welcome to safe systems programming!"); }
Welcome to safe systems programming!
Breaking Down the Code
fnkeyword: Used to declare a function. In Rust, functions are the primary way to group logic.println!: Notice the!. This tells Rust thatprintln!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.
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.
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:
xis immutable and cannot be changedyis mutable because it uses themutkeyword
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
mutfor 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
Data Types & Functions
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, isizeand unsignedu8, u16, u32, u64, u128, usize. The default isi32.usize/isizematch the pointer size of the machine and are used for indexing. - Floating point —
f32andf64. The default isf64. - Boolean —
bool, eithertrueorfalse. - Character —
char, a single Unicode scalar value written with single quotes, e.g.'z'or'😀'. It is 4 bytes wide.
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:
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 }
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.
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.
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).
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 ->.
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.
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.
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 }
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.
- 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 achar. - Mixing numeric types:
let x: i32 = 5; let y: i64 = x;does not compile. Convert explicitly withx as i64ori64::from(x). - Forgetting that arrays have a fixed length. Use a
Vecwhen the size changes.
- Prefer
i32andf64unless you have a reason to pick another size; they are fast and the defaults. - Use
i64::from(x)for lossless conversions andtry_fromfor conversions that might fail. Reserveasfor cases where truncation is acceptable. - Run
cargo clippyregularly. It flags many of the mistakes above with clear explanations.
Write a small program that:
- Defines
fn check_pass(grade: f64) -> boolthat returnstruewhen the grade is50.0or higher. - Defines
fn average(scores: [f64; 3]) -> f64that returns the mean of three scores. - Defines
fn min_max(values: [i32; 5]) -> (i32, i32)that returns the smallest and largest value as a tuple. - 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?
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; }
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?
Square brackets create an array. Parentheses create a tuple, which is a different compound type.
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:
ifexpressionselsestatements- Loops
matchexpressions
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
conditionis true,numberbecomes 5 - Otherwise,
numberbecomes 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
ifexpressions - 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
matchto 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
ifexpressions - How loops operate in Rust
- How to use
whileandforloops - How powerful
matchexpressions are
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.
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
// 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.
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.
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.
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.
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 aVec).
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
forloops overwhilewhen possible — they are more idiomatic and prevent infinite loops caused by forgetting to increment a counter. - When in doubt, use
iter(). Only useinto_iter()if you specifically need to destroy the original collection to move its data. - Remember that
mapandfilterare lazy. If you don't callcollect()or a loop, the code inside them will never even run!
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
mutfor 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.
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
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.
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
Stringand&str. - Mutating immutable vectors.
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.
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
Resultfor 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
Optionexample returningSomeorNone
Modules & Crates
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 filesrc/main.rs) or a library crate (root filesrc/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.
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.
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 mod bank; // loads src/bank.rs use bank::Account; fn main() { let acc = Account::new("Ada"); println!("{}", acc.balance()); }
// 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.
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
[package] name = "my_app" version = "0.1.0" edition = "2021" [dependencies] rand = "0.8" serde = { version = "1", features = ["derive"] }
use rand::Rng; fn main() { let roll = rand::thread_rng().gen_range(1..=6); println!("You rolled a {roll}"); }
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.
- Forgetting
pubon a function or struct field, then getting “is private” errors from another module. - Writing
mod bank;but not creatingsrc/bank.rs(orsrc/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
usewithout listing it inCargo.toml.
- 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 treeto see your dependency graph, andcargo audit(from thecargo-audittool) to check for known vulnerabilities.
- Create a new project with
cargo new inventory. - Add a module
iteminsrc/item.rswith apub struct Itemthat has a publicnameand a privatequantity. - Provide
pub fn new,pub fn add_stock(&mut self, n: u32)andpub fn quantity(&self) -> u32. - In
main.rs, create an item, add stock, and print it. Confirm that writing toquantitydirectly does not compile. - Add the
randcrate and give each new item a random starting quantity between 1 and 10.
What is the default visibility of an item in a module?
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?
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?
super goes up one level, crate starts at the root, and self means the current module.
Traits & Generics
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.
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:
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.
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:
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.
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 }
Generics / impl Trait | dyn Trait | |
|---|---|---|
| Resolved | Compile time | Run time (vtable) |
| Speed | Fastest, can inline | Small indirection cost |
Mixed types in one Vec | No | Yes |
| Binary size | Larger (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(...)].
#[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()); }
- Calling a method on a generic
Twithout a bound: “no method named … found for type parameterT”. Add the trait to the bound. - Trying to put different types in a
VecwithoutBox<dyn Trait>. - Implementing a foreign trait for a foreign type (e.g.
DisplayforVec<i32>). This is forbidden by the orphan rule; wrap the type in your own struct instead. - Using
dyn Traitwith a trait that has generic methods or returnsSelf(not object-safe).
- Start with generics and
impl Trait. Reach fordyn Traitonly when you truly need runtime polymorphism. - Prefer
whereclauses once a signature has more than one or two bounds. - Implement
std::fmt::Displayfor your types so they print nicely, andFromto make conversions ergonomic.
- Define a trait
Describewith a methodfn describe(&self) -> String. - Implement it for two structs of your choice (for example
DogandCar). - Write a generic function
print_all<T: Describe>(items: &[T]). - Build a
Vec<Box<dyn Describe>>holding both a dog and a car, and print every description. - Derive
DebugandCloneon both structs.
What does fn show<T: Display>(x: T) mean?
T: Display is a trait bound restricting T to types that implement Display.
When do you need Box<dyn Trait> rather than generics?
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?
derive asks the compiler to generate the standard implementation when all fields are themselves Clone.
Capstone Project: Secure CLI
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.
- Parsing arguments with
clap - Reading files in chunks with
std::io::Read - Hashing with the
sha2crate - A custom error enum with
DisplayandFrom - Proper exit codes so the tool works in scripts
- A unit test with a known-answer vector
Acceptance Criteria
hashsum file.txtprints<64 hex chars> file.txtand exits with code 0.hashsum file.txt --verify <hash>printsOK file.txtand exits 0 when the hash matches (case-insensitive), otherwise prints a clear mismatch message to stderr and exits 1.- A missing or unreadable file produces a readable error (no panic, no stack trace) and exit code 1.
- Files larger than memory work: the tool reads in fixed-size chunks.
cargo testpasses, including a test that hashes"abc".
Milestone 1 — Project Setup
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.
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.
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.
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.
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
cargo test cargo run -- Cargo.toml cargo run -- Cargo.toml --verify 0000 # prints a mismatch and exits with code 1 echo $? # (PowerShell: $LASTEXITCODE)
- 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.)
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:
- Add
--algo sha256|sha512using aclap::ValueEnum. - Accept several files and print one line per file.
- Read a
SHA256SUMSfile (format:hash filename) and verify every entry, likesha256sum -c. - Hash
stdinwhen the file argument is-. - Add an integration test in
tests/cli.rsthat runs the compiled binary (hint: theassert_cmdcrate).
Lifetimes
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.
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:
// 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:
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).
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:
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:
- Each reference parameter gets its own lifetime.
- If there is exactly one input lifetime, it is assigned to all output references.
- If there is a
&selfor&mut selfparameter, 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:
let s: &'static str = "I live for the whole program";
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
// 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.
- Returning a reference to a local variable:
fn make() -> &String { let s = String::new(); &s }. The value is dropped when the function returns. Return theStringitself. - Annotating every reference with the same
'aout of habit. Over-constraining lifetimes makes valid code fail to compile. - Thinking
'aextends 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.
- 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.
- Write
fn first_longer<'a>(a: &'a str, b: &'a str) -> &'a strthat returnsaunlessbis strictly longer. - Create a struct
Highlight<'a>holding a&'a strand a methodlen(&self) -> usize. - 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?
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?
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?
Literals are baked into the executable, so they are valid for the entire run of the program.
Smart Pointers (Box, Rc, RefCell)
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.
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…
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.
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 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.
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] }
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:
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 } }
| Type | Owners | Mutability checks | Thread-safe |
|---|---|---|---|
Box<T> | One | Compile time | If T is |
Rc<T> | Many | Compile time (read-only) | No |
RefCell<T> | One | Runtime | No |
Arc<Mutex<T>> | Many | Runtime (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.
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()); }
- Using
Rcwhen plain ownership or a borrow would do. EachRcadds a counter and hides who really owns the data. - Holding a
borrow_mut()guard across anotherborrow()of the sameRefCell, causing a runtime panic. Keep guards short-lived. - Creating
Rccycles, which leak memory. UseWeakfor back-references. - Using
Rc<RefCell<T>>across threads. UseArc<Mutex<T>>instead.
- Prefer
Rc::clone(&a)overa.clone(). It makes it obvious you are copying a pointer, not the data. - If you can restructure to avoid interior mutability, do.
RefCelltrades compile-time guarantees for runtime panics. - Use
try_borrow_mut()when you cannot be sure the cell is free; it returns aResultinstead of panicking.
- Define a binary tree enum
Tree { Leaf, Node(Box<Tree>, i32, Box<Tree>) }and writefn sum(t: &Tree) -> i32. - Create an
Rc<RefCell<Vec<String>>>log shared between two structs, each pushing messages. Print the final log andRc::strong_count. - Trigger a
RefCelldouble-borrow panic on purpose, then fix it by limiting the scope of the first borrow.
Why does a recursive enum need Box?
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?
When the strong count reaches zero the value is dropped and its memory released.
When does RefCell check borrowing rules?
RefCell enforces the same rules as the borrow checker but dynamically, panicking on violation.
Concurrency (Threads & Channels)
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.
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.
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.
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.
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 }
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 (&TisSend).Rc<T>is neither, which is why using it across threads is a compile error.Arc<T>is both (whenTis).
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.
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 }
- Forgetting
drop(tx): the receivingforloop never ends because one sender is still alive. - Holding a
MutexGuardlonger 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()onlock()without understanding poisoning: if a thread panics while holding the lock, laterlock()calls returnErr.
- Prefer channels when work flows one way (pipelines, workers). Prefer
Arc<Mutex>for genuinely shared state. - For CPU-bound data parallelism, use the
rayoncrate:vec.par_iter().map(...)parallelises with one line. - Keep critical sections tiny: lock, copy out what you need, unlock.
- 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 inmain. - Re-implement the same task with
thread::scopeand compare how much simpler it is. - Write a deliberately racy-looking program using
Rcacross threads. Read the compiler error and explain which trait is missing.
Why is move needed in thread::spawn(move || ...)?
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?
Arc provides thread-safe shared ownership and Mutex provides exclusive, synchronised access.
When does a for msg in rx loop stop?
The receiver iterator ends once the channel is closed, which happens when every Sender is dropped.
Closures & Advanced Iterators
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.
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:
| Trait | Can be called | Captures |
|---|---|---|
Fn | Many times | Only reads captured values |
FnMut | Many times | Mutates captured values |
FnOnce | Once | Moves captured values out |
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
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.
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
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 }
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.
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.
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] }
- 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) andinto_iter()(consumes, yieldsT). - Collecting into a
Vecin 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.
- Annotate the target type of
collect()(Vec<_>,HashMap<_, _>,String…) or use the turbofish:collect::<Vec<_>>(). - Use
iter().copied()or.cloned()to turn&Titems intoTwhenT: Copy/Clone. - Prefer
sum(),max(),min_by_key()over manual loops: they are clearer and equally fast.
- Given
let text = "the quick brown fox jumps over the lazy dog";, use one iterator chain to build aVec<String>of the upper-cased words longer than 3 letters. - Count how many times each word appears using
foldor aforloop over aHashMap. - Write
fn compose(f: impl Fn(i32) -> i32, g: impl Fn(i32) -> i32) -> impl Fn(i32) -> i32that returnsg(f(x)). - Implement an iterator
Fibonacciand print the first 10 numbers with.take(10).
Which closure trait lets you call the closure only once?
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?
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?
fold repeatedly applies a closure to an accumulator and each item, producing one result.
Async Rust (Tokio)
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.
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
cargo new async_demo cd async_demo cargo add tokio --features full
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.
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.
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
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!
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"), } }
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.
let result = tokio::task::spawn_blocking(|| expensive_cpu_work()).await.unwrap();
Async vs. Threads
| Threads | Async tasks | |
|---|---|---|
| Best for | CPU-bound work | Many concurrent I/O waits |
| Cost per unit | ~MBs of stack, OS scheduling | ~hundreds of bytes |
| Blocking calls | Fine | Harmful |
| Complexity | Lower | Higher (Send, pinning, colouring) |
- Forgetting
.await: the future is created but never runs. The compiler warns “unused implementer ofFuturethat must be used”. - Holding a
std::sync::MutexGuardacross an.await. Usetokio::sync::Mutexor 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_allor spawn.
- 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
tracingfor structured logs that follow a request across tasks.
- Write an
async fn slow_square(n: u64) -> u64that sleeps 200 ms and returnsn * n. - Time how long it takes to compute the squares of 1..=10 sequentially, then concurrently with
tokio::spawn. Explain the difference. - Wrap the whole concurrent run in
tokio::time::timeoutof 1 second and handle theErrcase.
What does calling an async fn without .await do?
Futures are lazy. Without .await or a spawn, the body never executes.
Why is std::thread::sleep bad inside an async task?
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?
join! waits for all futures. select! returns as soon as the first one finishes.
Final Project — Rust Web Service
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
| Method | Path | Purpose | Success status |
|---|---|---|---|
GET | /tasks | List all tasks | 200 |
POST | /tasks | Create a task from {"title": "..."} | 201 |
POST | /tasks/:id/done | Mark a task complete | 200 (404 if missing) |
DELETE | /tasks/:id | Delete a task | 204 (404 if missing) |
Acceptance Criteria
- All four routes behave as in the table, returning JSON bodies where applicable.
- Unknown IDs return 404, not a panic or a 500.
- Empty or whitespace-only titles are rejected with 400.
- State is shared safely between requests with no data races (the compiler will insist).
- A handler test using
tower::ServiceExt::oneshotprovesPOST /tasksfollowed byGET /tasksreturns the new task.
Milestone 1 — Project Setup
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.
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.
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
#[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:
# 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
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.
#[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); } }
cargo test
- 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:
- Persist tasks in SQLite using the
sqlxcrate and replace theMutex<Vec>with a connection pool inState. - Replace
StatusCodeerrors with your ownAppErrorenum that implementsIntoResponseand returns a JSON{"error": "..."}body. - Add
tower_http::trace::TraceLayerfor request logging andtracing_subscriberto display it. - Add a
PATCH /tasks/:idroute that updates the title, and write tests for it. - Add graceful shutdown with
with_graceful_shutdownlistening for Ctrl-C. - Containerise it with a multi-stage
Dockerfileproducing a small release image.
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.
Project 1: Command-Line Pattern Search Utility (Grep Clone)
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
Full Code Implementation
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}");
}
}
Add an environment variable check (CASE_INSENSITIVE=1) to perform case-insensitive pattern matching!