Course overview
This course is targeted at developers experienced in other procedural or object-oriented programming languages.
- Day 1: Rust foundations and the concept of ownership
- Day 2: Type system and error handling
- Day 3: Building programs & advanced topics
- Day 4: ESP32-C3 embedded systems — hands-on hardware programming
Each day is a mix of theory and exercises. Days 1 to 3 feature exercises in a std environment (building CLI applications on desktop). Day 4 applies the theory from Day 3 to real hardware: building an embedded temperature monitoring system on an ESP32-C3 microcontroller using no_std Rust.
This repository
Contains the course slides/script as an mdbook and solutions to the exercises in the solutions directory. Will be updated before and during the course.
Installation Instructions Days 1 to 3
Please ensure the following software is installed on the device you bring to the course.
If there are any questions or difficulties during the installation please don’t hesitate to contact the instructor (rolandbrand11@gmail.com).
Rust
Install Rust using rustup (Rust’s official installer)
- Visit rust-lang.org and follow the installation instructions for your operating system.
- Verify installation with:
rustc --versionandcargo --version
Recommended: Install rust-analyzer for a better development experience:
- rust-analyzer: A language server that provides IDE features like autocompletion, go-to-definition, and inline errors. Install with:
rustup component add rust-analyzer
The following tools are included by default with the stable toolchain:
- clippy: Run with
cargo clippyto catch common mistakes and improve your code. - rustfmt: Run with
cargo fmtto automatically format your code.
Git
Git for version control: git-scm.com
- Make sure you can access it through the command line:
git --version
Zed Editor
Download from zed.dev
During the course the trainer will use Zed - participants are recommended to use the same editor, but are free to choose any other editor or IDE. The trainer will not be able to provide setup or configuration support for other editors or IDEs during the course.
Create a Test Project
Create a new Rust project and build it:
cargo new hello-rust
cd hello-rust
cargo build
Run the Project
Execute the project to verify your Rust installation:
cargo run
You should see “Hello, world!” printed to your terminal.
Troubleshooting
If you encounter any issues:
Rust Installation Notes
- On Linux, you will need a C linker (and optionally a C compiler for crates with native code). Install build essentials if not already present:
sudo apt install build-essential(Ubuntu/Debian) or equivalent for your distribution. - On Windows, the Visual Studio C++ Build Tools are required by default, as Rust’s default toolchain uses the MSVC linker. The rustup installer will guide you through this.
Cargo Issues
- Try clearing the cargo cache:
cargo clean - Update rust:
rustup update
Cleanup
To remove the test project:
cd
rm -rf hello-rust
If you can complete all these steps successfully, your environment is ready for the first two days of the Rust course!
Additional Setup for Day 4 — ESP32-C3 Embedded
Day 4 targets ESP32-C3 hardware. The standard Rust toolchain from Days 1-3 is required, plus:
# Add the RISC-V target for ESP32-C3
rustup target add riscv32imc-unknown-none-elf
# Install the flash/monitor tool
cargo install cargo-espflash
Hardware Requirements
- ESP32-C3 development board (e.g. ESP32-C3-DevKitM-1)
- USB-C cable for programming and power
No external sensors or components are needed — the exercises use the ESP32-C3’s built-in temperature sensor.
→ Regularly pull updates to the repo
Chapter 1: Course Introduction & Setup
Development Environment Setup
Let’s get your Rust development environment ready. Rust’s tooling is excellent - you’ll find it more unified than C++ and more performant than .NET.
Installing Rust
The recommended way to install Rust is through rustup, Rust’s official toolchain manager.
On Unix-like systems (Linux/macOS):
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
On Windows:
Download and run the installer from rustup.rs
Rust on Windows supports two ABIs: MSVC and GNU. The rustup installer defaults to MSVC (x86_64-pc-windows-msvc), which is the recommended choice for most purposes. It requires the Visual Studio C++ Build Tools — the installer will offer to set these up for you. When prompted, select the “Desktop development with C++” workload.
Do not switch to the GNU toolchain (
x86_64-pc-windows-gnu) unless you specifically need MinGW/MSYS2 interop. The GNU target requires a full MSYS2/MinGW installation on yourPATH; without it, builds will fail with errors such aserror calling dlltool 'dlltool.exe': program not found.
After installation, verify:
rustc --version
cargo --version
Understanding the Rust Toolchain
| Tool | Purpose | C++ Equivalent | .NET Equivalent |
|---|---|---|---|
rustc | Compiler | g++, clang++ | csc, dotnet build |
cargo | Build system & package manager | cmake + conan/vcpkg | dotnet CLI + NuGet |
rustup | Toolchain manager | - | .NET SDK manager |
clippy | Linter | clang-tidy | Code analyzers |
rustfmt | Formatter | clang-format | dotnet format |
Your First Rust Project
Let’s create a Hello World project to verify everything works:
cargo new hello_rust
cd hello_rust
This creates:
hello_rust/
├── Cargo.toml # Like CMakeLists.txt or .csproj
└── src/
└── main.rs # Entry point
Look at src/main.rs:
fn main() { println!("Hello, world!"); }
Run it:
cargo run
Understanding Cargo
Cargo is Rust’s build system and package manager. Coming from C++ or .NET, you’ll love its simplicity.
Key Cargo Commands
| Command | Purpose | Similar to |
|---|---|---|
cargo new | Create new project | dotnet new, cmake init |
cargo build | Compile project | make, dotnet build |
cargo run | Build & run | ./a.out, dotnet run |
cargo test | Run tests | ctest, dotnet test |
cargo doc | Generate documentation | doxygen |
cargo check | Fast syntax/type check | Incremental compilation |
Debug vs Release Builds
cargo build # Debug build (./target/debug/)
cargo build --release # Optimized build (./target/release/)
Performance difference is significant! Debug builds include:
- Overflow checks
- Debug symbols
- No optimizations
Project Structure Best Practices
A typical Rust project structure:
my_project/
├── Cargo.toml # Project manifest
├── Cargo.lock # Dependency lock file (like package-lock.json)
├── src/
│ ├── main.rs # Binary entry point
│ ├── lib.rs # Library entry point
│ └── module.rs # Additional modules
├── tests/ # Integration tests
│ └── integration_test.rs
├── benches/ # Benchmarks
│ └── benchmark.rs
├── examples/ # Example programs
│ └── example.rs
└── target/ # Build artifacts (gitignored)
Comparing with C++/.NET
C++ Developers
- No header files! Modules are automatically resolved
- No makefiles to write - Cargo handles everything
- Dependencies are downloaded automatically (like vcpkg/conan)
- No undefined behavior in safe Rust
.NET Developers
- Similar project structure to .NET Core
Cargo.tomlis like.csproj- crates.io is like NuGet
- No garbage collector - deterministic destruction
Quick Wins: Why You’ll Love Rust’s Tooling
- Unified tooling: Everything works together seamlessly
- Excellent error messages: The compiler teaches you Rust
- Fast incremental compilation: cargo check is lightning fast
- Built-in testing: No need for external test frameworks
- Documentation generation: Automatic API docs from comments
Setting Up for Success
Enable Useful Rustup Components
rustup component add clippy # Linter
rustup component add rustfmt # Formatter
rustup component add rust-src # Source code for std library
Create a Learning Workspace
Let’s set up a workspace for this course:
mkdir rust-course-workspace
cd rust-course-workspace
cargo new --bin day1_exercises
cargo new --lib day1_library
Common Setup Issues and Solutions
| Issue | Solution |
|---|---|
| “rustc not found” | Restart terminal after installation |
| Slow compilation | Enable sccache: cargo install sccache |
| Can’t debug | Zed has built-in debugging support |
| Windows linker errors | Ensure the MSVC toolchain is active (rustup default stable-x86_64-pc-windows-msvc) and Visual Studio C++ Build Tools are installed — see the Windows note above |
Exercises
Exercise 1.1: Toolchain Exploration
Create a new project and explore these cargo commands:
cargo tree- View dependency treecargo doc --open- Generate and view documentationcargo clippy- Run the linter
Exercise 1.2: Build Configurations
- Create a simple program that prints the numbers 1 to 1_000_000
- Time the difference between debug and release builds
- Compare binary sizes
Exercise 1.3: First Debugging Session
- Create a program with an intentional panic
- Set a breakpoint in Zed
- Step through the code with the debugger
Key Takeaways
✅ Rust’s tooling is unified and modern - no need for complex build systems
✅ Cargo handles dependencies, building, testing, and documentation
✅ Debug vs Release builds have significant performance differences
✅ The development experience is similar to modern .NET, better than typical C++
✅ Zed with built-in rust-analyzer provides excellent IDE support
Next up: Chapter 2: Rust Fundamentals - Let’s write some Rust!
Chapter 2: Rust Fundamentals
Type System, Variables, Functions, and Basic Collections
Learning Objectives
By the end of this chapter, you’ll be able to:
- Understand Rust’s type system and its relationship to C++/.NET
- Work with variables, mutability, and type inference
- Write and call functions with proper parameter passing
- Handle strings effectively (String vs &str)
- Use basic collections (Vec, HashMap, etc.)
- Apply pattern matching with match expressions
Rust’s Type System: Safety First
Rust’s type system is designed around two core principles:
- Memory Safety: Prevent segfaults, buffer overflows, and memory leaks
- Thread Safety: Eliminate data races at compile time
Comparison with Familiar Languages
| Concept | C++ | C#/.NET | Rust |
|---|---|---|---|
| Null checking | Runtime (segfaults) | Runtime (NullReferenceException) | Compile-time (Option |
| Memory management | Manual (new/delete) | GC | Compile-time (ownership) |
| Thread safety | Runtime (mutexes) | Runtime (locks) | Compile-time (Send/Sync) |
| Type inference | auto (C++11+) | var | Extensive |
Variables and Mutability
The Default: Immutable
In Rust, variables are immutable by default - a key philosophical difference:
#![allow(unused)] fn main() { // Immutable by default let x = 5; x = 6; // ❌ Compile error! // Must explicitly opt into mutability let mut y = 5; y = 6; // ✅ This works }
Why This Matters:
- Prevents accidental modifications
- Enables compiler optimizations
- Makes concurrent code safer
- Forces you to think about what should change
Comparison to C++/.NET
// C++: Mutable by default
int x = 5; // Mutable
const int y = 5; // Immutable
// C#: Mutable by default
int x = 5; // Mutable
readonly int y = 5; // Immutable (field-level)
#![allow(unused)] fn main() { // Rust: Immutable by default let x = 5; // Immutable let mut y = 5; // Mutable }
Type Annotations and Inference
Rust has excellent type inference, but you can be explicit when needed:
#![allow(unused)] fn main() { // Type inference (preferred when obvious) let x = 42; // inferred as i32 let name = "Alice"; // inferred as &str let numbers = vec![1, 2, 3]; // inferred as Vec<i32> // Explicit types (when needed for clarity or disambiguation) let x: i64 = 42; let pi: f64 = 3.14159; let is_ready: bool = true; }
Variable Shadowing
Rust allows “shadowing” - reusing variable names with different types:
#![allow(unused)] fn main() { let x = 5; // x is i32 let x = "hello"; // x is now &str (different variable!) let x = x.len(); // x is now usize }
This is different from mutation and is often used for transformations.
Basic Types
Integer Types
Rust is explicit about integer sizes to prevent overflow issues:
#![allow(unused)] fn main() { // Signed integers let a: i8 = -128; // 8-bit signed (-128 to 127) let b: i16 = 32_000; // 16-bit signed let c: i32 = 2_000_000_000; // 32-bit signed (default) let d: i64 = 9_223_372_036_854_775_807; // 64-bit signed let e: i128 = 1; // 128-bit signed // Unsigned integers let f: u8 = 255; // 8-bit unsigned (0 to 255) let g: u32 = 4_000_000_000; // 32-bit unsigned let h: u64 = 18_446_744_073_709_551_615; // 64-bit unsigned // Architecture-dependent let size: usize = 64; // Pointer-sized (32 or 64 bit) let diff: isize = -32; // Signed pointer-sized }
Note: Underscores in numbers are just for readability (like 1'000'000 in C++14+).
Floating Point Types
#![allow(unused)] fn main() { let pi: f32 = 3.14159; // Single precision let e: f64 = 2.718281828; // Double precision (default) }
Boolean and Character Types
#![allow(unused)] fn main() { let is_rust_awesome: bool = true; let emoji: char = '🦀'; // 4-byte Unicode scalar value // Note: char is different from u8! let byte_value: u8 = b'A'; // ASCII byte let unicode_char: char = 'A'; // Unicode character }
Tuples: Fixed-Size Heterogeneous Collections
Tuples group values of different types into a compound type. They have a fixed size once declared:
#![allow(unused)] fn main() { // Creating tuples let tup: (i32, f64, u8) = (500, 6.4, 1); let tup = (500, 6.4, 1); // Type inference works too // Destructuring let (x, y, z) = tup; println!("The value of y is: {}", y); // Direct access using dot notation let five_hundred = tup.0; let six_point_four = tup.1; let one = tup.2; // Empty tuple (unit type) let unit = (); // Type () - represents no meaningful value // Common use: returning multiple values from functions fn get_coordinates() -> (f64, f64) { (37.7749, -122.4194) // San Francisco coordinates } let (lat, lon) = get_coordinates(); }
Comparison with C++/C#:
- C++:
std::tuple<int, double, char>orstd::pair<T1, T2> - C#:
(int, double, byte)value tuples orTuple<int, double, byte> - Rust:
(i32, f64, u8)- simpler syntax, built into the language
Arrays: Fixed-Size Homogeneous Collections
Arrays in Rust have a fixed size known at compile time and store elements of the same type:
#![allow(unused)] fn main() { // Creating arrays let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; let a: [i32; 5] = [1, 2, 3, 4, 5]; // Type annotation: [type; length] let a = [1, 2, 3, 4, 5]; // Type inference // Initialize with same value let zeros = [0; 100]; // Creates array with 100 zeros // Accessing elements let first = months[0]; // "January" let second = months[1]; // "February" // Array slicing let slice = &months[0..3]; // ["January", "February", "March"] // Iterating over arrays for month in &months { println!("{}", month); } // Arrays vs Vectors comparison let arr = [1, 2, 3]; // Stack-allocated, fixed size let vec = vec![1, 2, 3]; // Heap-allocated, growable }
Key Differences from Vectors:
| Feature | Array [T; N] | Vector Vec<T> |
| —–– | ––––––– | ————— |
| Size | Fixed at compile time | Growable at runtime |
| Memory | Stack-allocated | Heap-allocated |
| Performance | Faster for small, fixed data | Better for dynamic data |
| Use case | Known size, performance critical | Unknown or changing size |
Comparison with C++/C#:
- C++:
int arr[5]orstd::array<int, 5> - C#:
int[] arr = new int[5](heap) orSpan<int>(stack) - Rust:
let arr: [i32; 5]- size is part of the type
Functions: The Building Blocks
Function Syntax
#![allow(unused)] fn main() { // Basic function fn greet() { println!("Hello, world!"); } // Function with parameters fn add(x: i32, y: i32) -> i32 { x + y // No semicolon = return value } // Alternative explicit return fn subtract(x: i32, y: i32) -> i32 { return x - y; // Explicit return with semicolon } }
Key Differences from C++/.NET
| Aspect | C++ | C#/.NET | Rust |
|---|---|---|---|
| Return syntax | return x; | return x; | x (no semicolon) |
| Parameter types | int x | int x | x: i32 |
| Return type | int func() | int Func() | fn func() -> i32 |
Parameters: By Value vs By Reference
// By value (default) - ownership transferred fn take_ownership(s: String) { println!("{}", s); // s is dropped here } // By immutable reference - borrowing fn borrow_immutable(s: &String) { println!("{}", s); // s reference is dropped, original still valid } // By mutable reference - mutable borrowing fn borrow_mutable(s: &mut String) { s.push_str(" world"); } // Example usage fn main() { let mut message = String::from("Hello"); borrow_immutable(&message); // ✅ Can borrow immutably borrow_mutable(&mut message); // ✅ Can borrow mutably take_ownership(message); // ✅ Transfers ownership // println!("{}", message); // ❌ Error: value moved }
Control Flow: Making Decisions and Repeating
Rust provides familiar control flow constructs with some unique features that enhance safety and expressiveness.
if Expressions
In Rust, if is an expression, not just a statement - it returns a value:
#![allow(unused)] fn main() { // Basic if/else let number = 7; if number < 5 { println!("Less than 5"); } else if number == 5 { println!("Equal to 5"); } else { println!("Greater than 5"); } // if as an expression returning values let condition = true; let number = if condition { 5 } else { 10 }; // number = 5 // Must have same type in both branches // let value = if condition { 5 } else { "ten" }; // ❌ Type mismatch! }
Loops: Three Flavors
Rust offers three loop constructs, each with specific use cases:
loop - Infinite Loop with Break
#![allow(unused)] fn main() { // Infinite loop - must break explicitly let mut counter = 0; let result = loop { counter += 1; if counter == 10 { break counter * 2; // loop can return a value! } }; println!("Result: {}", result); // Prints: Result: 20 // Loop labels for nested loops 'outer: loop { println!("Entered outer loop"); 'inner: loop { println!("Entered inner loop"); break 'outer; // Break the outer loop } println!("This won't execute"); } }
while - Conditional Loop
#![allow(unused)] fn main() { // Standard while loop let mut number = 3; while number != 0 { println!("{}!", number); number -= 1; } println!("LIFTOFF!!!"); // Common pattern: checking conditions let mut stack = vec![1, 2, 3]; while !stack.is_empty() { let value = stack.pop(); println!("Popped: {:?}", value); } }
for - Iterator Loop
The for loop is the most idiomatic way to iterate in Rust:
#![allow(unused)] fn main() { // Iterate over a collection let numbers = vec![1, 2, 3, 4, 5]; for num in &numbers { println!("{}", num); } // Range syntax (exclusive end) for i in 0..5 { println!("{}", i); // Prints 0, 1, 2, 3, 4 } // Inclusive range for i in 1..=5 { println!("{}", i); // Prints 1, 2, 3, 4, 5 } // Enumerate for index and value let items = vec!["a", "b", "c"]; for (index, value) in items.iter().enumerate() { println!("{}: {}", index, value); } // Reverse iteration for i in (1..=3).rev() { println!("{}", i); // Prints 3, 2, 1 } }
Comparison with C++/.NET
| Feature | C++ | C#/.NET | Rust |
|---|---|---|---|
| for-each | for (auto& x : vec) | foreach (var x in list) | for x in &vec |
| Index loop | for (int i = 0; i < n; i++) | for (int i = 0; i < n; i++) | for i in 0..n |
| Infinite | while (true) | while (true) | loop |
| Break with value | Not supported | Not supported | break value |
Control Flow Best Practices
#![allow(unused)] fn main() { // Prefer iterators over index loops // ❌ Not idiomatic let vec = vec![1, 2, 3]; let mut i = 0; while i < vec.len() { println!("{}", vec[i]); i += 1; } // ✅ Idiomatic for item in &vec { println!("{}", item); } // Use if-let for simple pattern matching let optional = Some(5); // Verbose match match optional { Some(value) => println!("Got: {}", value), None => {}, } // Cleaner if-let if let Some(value) = optional { println!("Got: {}", value); } // while-let for repeated pattern matching let mut stack = vec![1, 2, 3]; while let Some(top) = stack.pop() { println!("Popped: {}", top); } }
Strings: The Complex Topic
Strings in Rust are more complex than C++/.NET due to UTF-8 handling and ownership.
String vs &str: The Key Distinction
#![allow(unused)] fn main() { // String: Owned, growable, heap-allocated let mut owned_string = String::from("Hello"); owned_string.push_str(" world"); // &str: String slice, borrowed, usually stack-allocated let string_slice: &str = "Hello world"; let slice_of_string: &str = &owned_string; }
Comparison Table
| Type | C++ Equivalent | C#/.NET Equivalent | Rust |
|---|---|---|---|
| Owned | std::string | string | String |
| View/Slice | std::string_view | ReadOnlySpan<char> | &str |
Common String Operations
#![allow(unused)] fn main() { // Creation let s1 = String::from("Hello"); let s2 = "World".to_string(); let s3 = String::new(); // Concatenation let combined = format!("{} {}", s1, s2); // Like printf/String.Format let mut s4 = String::from("Hello"); s4.push_str(" world"); // Append string s4.push('!'); // Append character // Length and iteration println!("Length: {}", s4.len()); // Byte length! println!("Chars: {}", s4.chars().count()); // Character count // Iterating over characters (proper Unicode handling) for c in s4.chars() { println!("{}", c); } // Iterating over bytes for byte in s4.bytes() { println!("{}", byte); } }
String Slicing
#![allow(unused)] fn main() { let s = String::from("hello world"); let hello = &s[0..5]; // "hello" - byte indices! let world = &s[6..11]; // "world" let full = &s[..]; // Entire string // ⚠️ Warning: Slicing can panic with Unicode! let unicode = "🦀🔥"; // let bad = &unicode[0..1]; // ❌ Panics! Cuts through emoji let good = &unicode[0..4]; // ✅ One emoji (4 bytes) }
Collections: Vectors and Hash Maps
Vec: The Workhorse Collection
Vectors are Rust’s equivalent to std::vector or List<T>:
#![allow(unused)] fn main() { // Creation let mut numbers = Vec::new(); // Empty vector let mut numbers: Vec<i32> = Vec::new(); // With type annotation let numbers = vec![1, 2, 3, 4, 5]; // vec! macro // Adding elements let mut v = Vec::new(); v.push(1); v.push(2); v.push(3); // Accessing elements let first = &v[0]; // Panics if out of bounds let first_safe = v.get(0); // Returns Option<&T> match v.get(0) { Some(value) => println!("First: {}", value), None => println!("Vector is empty"), } // Iteration for item in &v { // Borrow each element println!("{}", item); } for item in &mut v { // Mutable borrow *item *= 2; } for item in v { // Take ownership (consumes v) println!("{}", item); } }
HashMap<K, V>: Key-Value Storage
#![allow(unused)] fn main() { use std::collections::HashMap; // Creation let mut scores = HashMap::new(); scores.insert("Alice".to_string(), 100); scores.insert("Bob".to_string(), 85); // Or with collect let teams = vec!["Blue", "Yellow"]; let initial_scores = vec![10, 50]; let scores: HashMap<_, _> = teams .iter() .zip(initial_scores.iter()) .collect(); // Accessing values let alice_score = scores.get("Alice"); match alice_score { Some(score) => println!("Alice: {}", score), None => println!("Alice not found"), } // Iteration for (key, value) in &scores { println!("{}: {}", key, value); } // Entry API for complex operations scores.entry("Charlie".to_string()).or_insert(0); *scores.entry("Alice".to_string()).or_insert(0) += 10; }
Pattern Matching with match
The match expression is Rust’s powerful control flow construct:
Basic Matching
#![allow(unused)] fn main() { let number = 7; match number { 1 => println!("One"), 2 | 3 => println!("Two or three"), 4..=6 => println!("Four to six"), _ => println!("Something else"), // Default case } }
Matching with Option
#![allow(unused)] fn main() { let maybe_number: Option<i32> = Some(5); match maybe_number { Some(value) => println!("Got: {}", value), None => println!("Nothing here"), } // Or use if let for simple cases if let Some(value) = maybe_number { println!("Got: {}", value); } }
Destructuring
#![allow(unused)] fn main() { let point = (3, 4); match point { (0, 0) => println!("Origin"), (x, 0) => println!("On x-axis at {}", x), (0, y) => println!("On y-axis at {}", y), (x, y) => println!("Point at ({}, {})", x, y), } }
Common Pitfalls and Solutions
Pitfall 1: String vs &str Confusion
#![allow(unused)] fn main() { // ❌ Common mistake fn greet(name: String) { // Takes ownership println!("Hello, {}", name); } let name = String::from("Alice"); greet(name); // greet(name); // ❌ Error: value moved // ✅ Better approach fn greet(name: &str) { // Borrows println!("Hello, {}", name); } let name = String::from("Alice"); greet(&name); greet(&name); // ✅ Still works }
Pitfall 2: Integer Overflow in Debug Mode
#![allow(unused)] fn main() { let mut x: u8 = 255; x += 1; // Panics in debug mode, wraps in release mode // Use checked arithmetic for explicit handling match x.checked_add(1) { Some(result) => x = result, None => println!("Overflow detected!"), } }
Pitfall 3: Vec Index Out of Bounds
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; // let x = v[10]; // ❌ Panics! // ✅ Safe alternatives let x = v.get(10); // Returns Option<&T> let x = v.get(0).unwrap(); // Explicit panic with better message }
Key Takeaways
- Immutability by default encourages safer, more predictable code
- Type inference is powerful but explicit types help with clarity
- String handling is more complex but prevents many Unicode bugs
- Collections are memory-safe with compile-time bounds checking
- Pattern matching is exhaustive and catches errors at compile time
Memory Insight: Unlike C++ or .NET, Rust tracks ownership at compile time, preventing entire classes of bugs without runtime overhead.
Exercises
Exercise 1: Basic Types and Functions
Create a program that:
- Defines a function
calculate_bmi(height: f64, weight: f64) -> f64 - Uses the function to calculate BMI for several people
- Returns a string description (“Underweight”, “Normal”, “Overweight”, “Obese”)
// Starter code fn calculate_bmi(height: f64, weight: f64) -> f64 { // Your implementation here } fn bmi_category(bmi: f64) -> &'static str { // Your implementation here } fn main() { let height = 1.75; // meters let weight = 70.0; // kg let bmi = calculate_bmi(height, weight); let category = bmi_category(bmi); println!("BMI: {:.1}, Category: {}", bmi, category); }
Exercise 2: String Manipulation
Write a function that:
- Takes a sentence as input
- Returns the longest word in the sentence
- Handle the case where multiple words have the same length
#![allow(unused)] fn main() { fn find_longest_word(sentence: &str) -> Option<&str> { // Your implementation here // Hint: Use split_whitespace() and max_by_key() } #[cfg(test)] mod tests { use super::*; #[test] fn test_longest_word() { assert_eq!(find_longest_word("Hello rust"), Some("Hello")); assert_eq!(find_longest_word(""), None); assert_eq!(find_longest_word("a bb ccc"), Some("ccc")); } } }
Additional Resources
Next Up: In Chapter 3, we’ll explore structs and enums - Rust’s powerful data modeling tools that go far beyond what you might expect from C++/.NET experience.
Chapter 3: Structs and Enums
Data Modeling and Methods in Rust
Learning Objectives
By the end of this chapter, you’ll be able to:
- Define and use structs effectively for data modeling
- Understand when and how to implement methods and associated functions
- Master enums for type-safe state representation
- Apply pattern matching with complex data structures
- Choose between structs and enums for different scenarios
- Implement common patterns from OOP languages in Rust
Structs: Structured Data
Structs in Rust are similar to structs in C++ or classes in C#, but with some key differences around memory layout and method definition.
Basic Struct Definition
#![allow(unused)] fn main() { // Similar to C++ struct or C# class struct Person { name: String, age: u32, email: String, } // Creating instances let person = Person { name: String::from("Alice"), age: 30, email: String::from("alice@example.com"), }; // Accessing fields println!("Name: {}", person.name); println!("Age: {}", person.age); }
Comparison with C++/.NET
| Feature | C++ | C#/.NET | Rust |
|---|---|---|---|
| Definition | struct Person { std::string name; }; | class Person { public string Name; } | struct Person { name: String } |
| Instantiation | Person p{"Alice"}; | var p = new Person { Name = "Alice" }; | Person { name: "Alice".to_string() } |
| Field Access | p.name | p.Name | p.name |
| Methods | Inside struct | Inside class | Separate impl block |
Struct Update Syntax
#![allow(unused)] fn main() { let person1 = Person { name: String::from("Alice"), age: 30, email: String::from("alice@example.com"), }; // Create a new instance based on existing one let person2 = Person { name: String::from("Bob"), ..person1 // Use remaining fields from person1 }; // Note: person1 is no longer usable if any non-Copy fields were moved! }
Tuple Structs
When you don’t need named fields:
#![allow(unused)] fn main() { // Tuple struct - like std::pair in C++ or Tuple in C# struct Point(f64, f64); struct Color(u8, u8, u8); let origin = Point(0.0, 0.0); let red = Color(255, 0, 0); // Access by index println!("X: {}, Y: {}", origin.0, origin.1); }
Unit Structs
Structs with no data - useful for type safety:
#![allow(unused)] fn main() { // Unit struct - zero size struct Marker; // Useful for phantom types and markers let marker = Marker; }
Methods and Associated Functions
In Rust, methods are defined separately from the struct definition in impl blocks.
Instance Methods
#![allow(unused)] fn main() { struct Rectangle { width: f64, height: f64, } impl Rectangle { // Method that takes &self (immutable borrow) fn area(&self) -> f64 { self.width * self.height } // Method that takes &mut self (mutable borrow) fn scale(&mut self, factor: f64) { self.width *= factor; self.height *= factor; } // Method that takes self (takes ownership) fn into_square(self) -> Rectangle { let size = (self.width + self.height) / 2.0; Rectangle { width: size, height: size, } } } // Usage let mut rect = Rectangle { width: 10.0, height: 5.0 }; println!("Area: {}", rect.area()); // Borrows immutably rect.scale(2.0); // Borrows mutably let square = rect.into_square(); // Takes ownership // rect is no longer usable here! }
Associated Functions (Static Methods)
#![allow(unused)] fn main() { impl Rectangle { // Associated function (like static method in C#) fn new(width: f64, height: f64) -> Rectangle { Rectangle { width, height } } // Constructor-like function fn square(size: f64) -> Rectangle { Rectangle { width: size, height: size, } } } // Usage - called on the type, not an instance let rect = Rectangle::new(10.0, 5.0); let square = Rectangle::square(7.0); }
Multiple impl Blocks
You can have multiple impl blocks for organization:
#![allow(unused)] fn main() { impl Rectangle { // Construction methods fn new(width: f64, height: f64) -> Self { Self { width, height } } } impl Rectangle { // Calculation methods fn area(&self) -> f64 { self.width * self.height } fn perimeter(&self) -> f64 { 2.0 * (self.width + self.height) } } }
Enums: More Powerful Than You Think
Rust enums are much more powerful than C++ enums or C# enums. They’re similar to discriminated unions or algebraic data types.
Basic Enums
#![allow(unused)] fn main() { // Simple enum - like C++ enum class #[derive(Debug)] // Allows printing with {:?} enum Direction { North, South, East, West, } let dir = Direction::North; println!("{:?}", dir); // Prints: North }
Enums with Data
This is where Rust enums shine - each variant can hold different types of data:
#![allow(unused)] fn main() { enum IpAddr { V4(u8, u8, u8, u8), // IPv4 with 4 bytes V6(String), // IPv6 as string } let home = IpAddr::V4(127, 0, 0, 1); let loopback = IpAddr::V6(String::from("::1")); // More complex example enum Message { Quit, // No data Move { x: i32, y: i32 }, // Anonymous struct Write(String), // Single value ChangeColor(i32, i32, i32), // Tuple } }
Pattern Matching with Enums
#![allow(unused)] fn main() { fn process_message(msg: Message) { match msg { Message::Quit => { println!("Quit received"); } Message::Move { x, y } => { println!("Move to ({}, {})", x, y); } Message::Write(text) => { println!("Write: {}", text); } Message::ChangeColor(r, g, b) => { println!("Change color to RGB({}, {}, {})", r, g, b); } } } }
Methods on Enums
Enums can have methods too:
#![allow(unused)] fn main() { impl Message { fn is_quit(&self) -> bool { matches!(self, Message::Quit) } fn process(&self) { match self { Message::Quit => std::process::exit(0), Message::Write(text) => println!("{}", text), _ => println!("Processing other message"), } } } }
Option: Null Safety
The most important enum in Rust is Option<T> - Rust’s way of handling nullable values:
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
Comparison with Null Handling
| Language | Null Representation | Safety |
|---|---|---|
| C++ | nullptr, raw pointers | Runtime crashes |
| C#/.NET | null, Nullable<T> | Runtime exceptions |
| Rust | Option<T> | Compile-time safety |
Working with Option
#![allow(unused)] fn main() { fn find_user(id: u32) -> Option<String> { if id == 1 { Some(String::from("Alice")) } else { None } } // Pattern matching match find_user(1) { Some(name) => println!("Found user: {}", name), None => println!("User not found"), } // Using if let for simple cases if let Some(name) = find_user(1) { println!("Hello, {}", name); } // Chaining operations let user_name_length = find_user(1) .map(|name| name.len()) // Transform if Some .unwrap_or(0); // Default value if None }
Common Option Methods
#![allow(unused)] fn main() { let maybe_number: Option<i32> = Some(5); // Unwrapping (use carefully!) let number = maybe_number.unwrap(); // Panics if None let number = maybe_number.unwrap_or(0); // Default value let number = maybe_number.unwrap_or_else(|| compute_default()); // Safe checking if maybe_number.is_some() { println!("Has value: {}", maybe_number.unwrap()); } // Transformation let doubled = maybe_number.map(|x| x * 2); // Some(10) or None let as_string = maybe_number.map(|x| x.to_string()); // Filtering let even = maybe_number.filter(|&x| x % 2 == 0); }
Result<T, E>: Error Handling
Another crucial enum is Result<T, E> for error handling:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
Basic Usage
#![allow(unused)] fn main() { use std::fs::File; use std::io::ErrorKind; fn open_file(filename: &str) -> Result<File, std::io::Error> { File::open(filename) } // Pattern matching match open_file("config.txt") { Ok(file) => println!("File opened successfully"), Err(error) => match error.kind() { ErrorKind::NotFound => println!("File not found"), ErrorKind::PermissionDenied => println!("Permission denied"), other_error => println!("Other error: {:?}", other_error), }, } }
When to Use Structs vs Enums
Use Structs When:
- You need to group related data together
- All fields are always present and meaningful
- You’re modeling “entities” or “things”
#![allow(unused)] fn main() { // Good use of struct - user profile struct UserProfile { username: String, email: String, created_at: std::time::SystemTime, is_active: bool, } }
Use Enums When:
- You have mutually exclusive states or variants
- You need type-safe state machines
- You’re modeling “choices” or “alternatives”
#![allow(unused)] fn main() { // Good use of enum - connection state enum ConnectionState { Disconnected, Connecting { attempt: u32 }, Connected { since: std::time::SystemTime }, Error { message: String, retry_count: u32 }, } }
Combining Structs and Enums
#![allow(unused)] fn main() { struct GamePlayer { name: String, health: u32, state: PlayerState, } enum PlayerState { Idle, Moving { destination: Point }, Fighting { target: String }, Dead { respawn_time: u64 }, } struct Point { x: f64, y: f64, } }
Advanced Patterns
Generic Structs
#![allow(unused)] fn main() { struct Pair<T> { first: T, second: T, } impl<T> Pair<T> { fn new(first: T, second: T) -> Self { Pair { first, second } } fn get_first(&self) -> &T { &self.first } } // Usage let int_pair = Pair::new(1, 2); let string_pair = Pair::new("hello".to_string(), "world".to_string()); }
Deriving Common Traits
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq)] // Auto-implement common traits struct Point { x: f64, y: f64, } let p1 = Point { x: 1.0, y: 2.0 }; let p2 = p1.clone(); // Clone trait println!("{:?}", p1); // Debug trait println!("Equal: {}", p1 == p2); // PartialEq trait }
Common Pitfalls and Solutions
Pitfall 1: Forgetting to Handle All Enum Variants
#![allow(unused)] fn main() { enum Status { Active, Inactive, Pending, } fn handle_status(status: Status) { match status { Status::Active => println!("Active"), Status::Inactive => println!("Inactive"), // ❌ Missing Status::Pending - won't compile! } } // ✅ Solution: Handle all variants or use default fn handle_status_fixed(status: Status) { match status { Status::Active => println!("Active"), Status::Inactive => println!("Inactive"), Status::Pending => println!("Pending"), // Handle all variants } } }
Pitfall 2: Moving Out of Borrowed Content
#![allow(unused)] fn main() { struct Container { value: String, } fn bad_example(container: &Container) -> String { container.value // ❌ Cannot move out of borrowed content } // ✅ Solutions: fn return_reference(container: &Container) -> &str { &container.value // Return a reference } fn return_clone(container: &Container) -> String { container.value.clone() // Clone the value } }
Pitfall 3: Unwrapping Options/Results in Production
#![allow(unused)] fn main() { // ❌ Dangerous in production code fn bad_parse(input: &str) -> i32 { input.parse::<i32>().unwrap() // Can panic! } // ✅ Better approaches fn safe_parse(input: &str) -> Option<i32> { input.parse().ok() } fn parse_with_default(input: &str, default: i32) -> i32 { input.parse().unwrap_or(default) } }
Key Takeaways
- Structs group related data - similar to classes but with explicit memory layout
- Methods are separate from data definition in
implblocks - Enums are powerful - they can hold data and represent complex state
- Pattern matching is exhaustive - compiler ensures all cases are handled
- Option and Result eliminate null pointer exceptions and improve error handling
- Choose the right tool: structs for entities, enums for choices
Exercises
Exercise 1: Building a Library System
Create a library management system using structs and enums:
// Define the data structures struct Book { title: String, author: String, isbn: String, status: BookStatus, } enum BookStatus { Available, CheckedOut { borrower: String, due_date: String }, Reserved { reserver: String }, } impl Book { fn new(title: String, author: String, isbn: String) -> Self { // Your implementation } fn checkout(&mut self, borrower: String, due_date: String) -> Result<(), String> { // Your implementation - return error if not available } fn return_book(&mut self) -> Result<(), String> { // Your implementation } fn is_available(&self) -> bool { // Your implementation } } fn main() { let mut book = Book::new( "The Rust Programming Language".to_string(), "Steve Klabnik".to_string(), "978-1718500440".to_string(), ); // Test the implementation println!("Available: {}", book.is_available()); match book.checkout("Alice".to_string(), "2023-12-01".to_string()) { Ok(()) => println!("Book checked out successfully"), Err(e) => println!("Checkout failed: {}", e), } }
Exercise 2: Calculator with Different Number Types
Build a calculator that can handle different number types:
#[derive(Debug, Clone)] enum Number { Integer(i64), Float(f64), Fraction { numerator: i64, denominator: i64 }, } impl Number { fn add(self, other: Number) -> Number { // Your implementation // Convert everything to float for simplicity, or implement proper fraction math } fn to_float(&self) -> f64 { // Your implementation } fn display(&self) -> String { // Your implementation } } fn main() { let a = Number::Integer(5); let b = Number::Float(3.14); let c = Number::Fraction { numerator: 1, denominator: 2 }; let result = a.add(b); println!("5 + 3.14 = {}", result.display()); }
Exercise 3: State Machine for a Traffic Light
Implement a traffic light state machine:
struct TrafficLight { current_state: LightState, timer: u32, } enum LightState { Red { duration: u32 }, Yellow { duration: u32 }, Green { duration: u32 }, } impl TrafficLight { fn new() -> Self { // Start with Red for 30 seconds } fn tick(&mut self) { // Decrease timer and change state when timer reaches 0 // Red(30) -> Green(25) -> Yellow(5) -> Red(30) -> ... } fn current_color(&self) -> &str { // Return the current color as a string } fn time_remaining(&self) -> u32 { // Return remaining time in current state } } fn main() { let mut light = TrafficLight::new(); for _ in 0..100 { println!("Light: {}, Time remaining: {}", light.current_color(), light.time_remaining()); light.tick(); // Simulate 1 second delay std::thread::sleep(std::time::Duration::from_millis(100)); } }
Next Up: In Chapter 4, we’ll dive deep into ownership - Rust’s unique approach to memory management that eliminates entire classes of bugs without garbage collection.
Chapter 4: Ownership - THE MOST IMPORTANT CONCEPT
Understanding Rust’s Unique Memory Management
Learning Objectives
By the end of this chapter, you’ll be able to:
- Understand ownership rules and how they differ from C++/.NET memory management
- Work confidently with borrowing and references
- Navigate lifetime annotations and understand when they’re needed
- Transfer ownership safely between functions and data structures
- Debug common ownership errors with confidence
- Apply ownership principles to write memory-safe, performant code
Why Ownership Matters: The Problem It Solves
Memory Management Comparison
| Language | Memory Management | Common Issues | Performance | Safety |
|---|---|---|---|---|
| C++ | Manual (new/delete, RAII) | Memory leaks, double-free, dangling pointers | High | Runtime crashes |
| C#/.NET | Garbage Collector | GC pauses, memory pressure | Medium | Runtime exceptions |
| Rust | Compile-time ownership | Compiler errors (not runtime!) | High | Compile-time safety |
The Core Problem
// C++ - Dangerous code that compiles
std::string* dangerous() {
std::string local = "Hello";
return &local; // ❌ Returning reference to local variable!
}
// This compiles but crashes at runtime
// C# - Memory managed but can still have issues
class Manager {
private List<string> items;
public IEnumerable<string> GetItems() {
items = null; // Oops!
return items; // ❌ NullReferenceException at runtime
}
}
#![allow(unused)] fn main() { // Rust - Won't compile, saving you from runtime crashes fn safe_rust() -> &str { let local = String::from("Hello"); &local // ❌ Compile error: `local` does not live long enough } // Error caught at compile time! }
The Three Rules of Ownership
Rule 1: Each Value Has a Single Owner
#![allow(unused)] fn main() { let s1 = String::from("Hello"); // s1 owns the string let s2 = s1; // Ownership moves to s2 // println!("{}", s1); // ❌ Error: value borrowed after move // Compare to C++: // std::string s1 = "Hello"; // s1 owns the string // std::string s2 = s1; // s2 gets a COPY (expensive!) // std::cout << s1; // ✅ Still works, s1 unchanged }
Rule 2: There Can Only Be One Owner at a Time
fn take_ownership(s: String) { // s comes into scope println!("{}", s); } // s goes out of scope and `drop` is called, memory freed fn main() { let s = String::from("Hello"); take_ownership(s); // s's value moves into function // println!("{}", s); // ❌ Error: value borrowed after move }
Rule 3: When the Owner Goes Out of Scope, the Value is Dropped
#![allow(unused)] fn main() { { let s = String::from("Hello"); // s comes into scope // do stuff with s } // s goes out of scope, memory freed automatically }
Move Semantics: Ownership Transfer
Understanding Moves
#![allow(unused)] fn main() { // Primitive types implement Copy trait let x = 5; let y = x; // x is copied, both x and y are valid println!("x: {}, y: {}", x, y); // ✅ Works fine // Complex types move by default let s1 = String::from("Hello"); let s2 = s1; // s1 is moved to s2 // println!("{}", s1); // ❌ Error: value borrowed after move println!("{}", s2); // ✅ Only s2 is valid // Clone when you need a copy let s3 = String::from("World"); let s4 = s3.clone(); // Explicit copy println!("s3: {}, s4: {}", s3, s4); // ✅ Both valid }
Copy vs Move Types
#![allow(unused)] fn main() { // Types that implement Copy (stored on stack) let a = 5; // i32 let b = true; // bool let c = 'a'; // char let d = (1, 2); // Tuple of Copy types // Types that don't implement Copy (may use heap) let e = String::from("Hello"); // String let f = vec![1, 2, 3]; // Vec<i32> let g = Box::new(42); // Box<i32> // Copy types can be used after assignment let x = a; // a is copied println!("a: {}, x: {}", a, x); // ✅ Both work // Move types transfer ownership let y = e; // e is moved // println!("{}", e); // ❌ Error: moved }
References and Borrowing
Immutable References (Shared Borrowing)
fn calculate_length(s: &String) -> usize { // s is a reference s.len() } // s goes out of scope, but doesn't own data, so nothing happens fn main() { let s1 = String::from("Hello"); let len = calculate_length(&s1); // Pass reference println!("Length of '{}' is {}.", s1, len); // ✅ s1 still usable }
Mutable References (Exclusive Borrowing)
fn change(s: &mut String) { s.push_str(", world"); } fn main() { let mut s = String::from("Hello"); change(&mut s); // Pass mutable reference println!("{}", s); // Prints: Hello, world }
The Borrowing Rules
Rule 1: Either one mutable reference OR any number of immutable references
#![allow(unused)] fn main() { let mut s = String::from("Hello"); // ✅ Multiple immutable references let r1 = &s; let r2 = &s; println!("{} and {}", r1, r2); // OK // ❌ Cannot have mutable reference with immutable ones let r3 = &s; let r4 = &mut s; // Error: cannot borrow as mutable }
Rule 2: References must always be valid (no dangling references)
#![allow(unused)] fn main() { fn dangle() -> &String { // Returns reference to String let s = String::from("hello"); &s // ❌ Error: `s` does not live long enough } // s is dropped, reference would be invalid // ✅ Solution: Return owned value fn no_dangle() -> String { let s = String::from("hello"); s // Move s out, no reference needed } }
Reference Patterns in Practice
// Good: Take references when you don't need ownership fn print_length(s: &str) { // &str works with String and &str println!("Length: {}", s.len()); } // Good: Take mutable reference when you need to modify fn append_exclamation(s: &mut String) { s.push('!'); } // Sometimes you need ownership fn take_and_process(s: String) -> String { // Do expensive processing that consumes s format!("Processed: {}", s.to_uppercase()) } fn main() { let mut text = String::from("Hello"); print_length(&text); // Borrow immutably append_exclamation(&mut text); // Borrow mutably let result = take_and_process(text); // Transfer ownership // text is no longer valid here println!("{}", result); }
Lifetimes: Ensuring Reference Validity
Why Lifetimes Exist
#![allow(unused)] fn main() { // The compiler needs to ensure this is safe: fn longest(x: &str, y: &str) -> &str { if x.len() > y.len() { x } else { y } } // Question: How long should the returned reference live? }
Lifetime Annotation Syntax
#![allow(unused)] fn main() { // Explicit lifetime annotations fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } // The lifetime 'a means: // - x and y must both live at least as long as 'a // - The returned reference will live as long as 'a // - 'a is the shorter of the two input lifetimes }
Lifetime Elision Rules (When You Don’t Need Annotations)
Rule 1: Each reference parameter gets its own lifetime
#![allow(unused)] fn main() { // This: fn first_word(s: &str) -> &str { /* ... */ } // Is actually this: fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ } }
Rule 2: If there’s exactly one input lifetime, it’s assigned to all outputs
#![allow(unused)] fn main() { // These are equivalent: fn get_first(list: &Vec<String>) -> &String { &list[0] } fn get_first<'a>(list: &'a Vec<String>) -> &'a String { &list[0] } }
Rule 3: Methods with &self give output the same lifetime as self
#![allow(unused)] fn main() { impl<'a> Person<'a> { fn get_name(&self) -> &str { // Implicitly &'a str self.name } } }
Complex Lifetime Examples
#![allow(unused)] fn main() { // Multiple lifetimes fn compare_and_return<'a, 'b>( x: &'a str, y: &'b str, return_first: bool ) -> &'a str { // Always returns something with lifetime 'a if return_first { x } else { y } // ❌ Error: y has wrong lifetime } // Fixed version - both inputs must have same lifetime fn compare_and_return<'a>( x: &'a str, y: &'a str, return_first: bool ) -> &'a str { if return_first { x } else { y } // ✅ OK } }
Structs with Lifetimes
// Struct holding references needs lifetime annotation struct ImportantExcerpt<'a> { part: &'a str, // This reference must live at least as long as the struct } impl<'a> ImportantExcerpt<'a> { fn level(&self) -> i32 { 3 } fn announce_and_return_part(&self, announcement: &str) -> &str { println!("Attention please: {}", announcement); self.part // Returns reference with same lifetime as &self } } fn main() { let novel = String::from("Call me Ishmael. Some years ago..."); let first_sentence = novel.split('.').next().expect("Could not find a '.'"); let i = ImportantExcerpt { part: first_sentence, }; // i is valid as long as novel is valid }
Static Lifetime
#![allow(unused)] fn main() { // 'static means the reference lives for the entire program duration let s: &'static str = "I have a static lifetime."; // String literals // Static variables static GLOBAL_COUNT: i32 = 0; let count_ref: &'static i32 = &GLOBAL_COUNT; // Sometimes you need to store static references struct Config { name: &'static str, // Must be a string literal or static } }
Advanced Ownership Patterns
Returning References from Functions
#![allow(unused)] fn main() { // ❌ Cannot return reference to local variable fn create_and_return() -> &str { let s = String::from("hello"); &s // Error: does not live long enough } // ✅ Return owned value instead fn create_and_return_owned() -> String { String::from("hello") } // ✅ Return reference to input (with lifetime) fn get_first_word(text: &str) -> &str { text.split_whitespace().next().unwrap_or("") } }
Ownership with Collections
fn main() { let mut vec = Vec::new(); // Adding owned values vec.push(String::from("hello")); vec.push(String::from("world")); // ❌ Cannot move out of vector by index // let first = vec[0]; // Error: cannot move // ✅ Borrowing is fine let first_ref = &vec[0]; println!("First: {}", first_ref); // ✅ Clone if you need ownership let first_owned = vec[0].clone(); // ✅ Or use into_iter() to transfer ownership for item in vec { // vec is moved here println!("Owned item: {}", item); } // vec is no longer usable }
Splitting Borrows
#![allow(unused)] fn main() { // Sometimes you need to borrow different parts of a struct struct Point { x: f64, y: f64, } impl Point { // ❌ This won't work - can't return multiple mutable references // fn get_coords_mut(&mut self) -> (&mut f64, &mut f64) { // (&mut self.x, &mut self.y) // } // ✅ This works - different fields can be borrowed separately fn update_coords(&mut self, new_x: f64, new_y: f64) { self.x = new_x; // Borrow x mutably self.y = new_y; // Borrow y mutably (different field) } } }
Common Ownership Patterns and Solutions
Pattern 1: Function Parameters
#![allow(unused)] fn main() { // ❌ Don't take ownership unless you need it fn process_text(text: String) -> usize { text.len() // We don't need to own text for this } // ✅ Better: take a reference fn process_text(text: &str) -> usize { text.len() } // ✅ When you do need ownership: fn store_text(text: String) -> Box<String> { Box::new(text) // We're storing it, so ownership makes sense } }
Pattern 2: Return Values
#![allow(unused)] fn main() { // ✅ Return owned values when creating new data fn create_greeting(name: &str) -> String { format!("Hello, {}!", name) } // ✅ Return references when extracting from input fn get_file_extension(filename: &str) -> Option<&str> { filename.split('.').last() } }
Pattern 3: Structs Holding Data
// ✅ Own data when struct should control lifetime #[derive(Debug)] struct User { name: String, // Owned email: String, // Owned } // ✅ Borrow when data lives elsewhere #[derive(Debug)] struct UserRef<'a> { name: &'a str, // Borrowed email: &'a str, // Borrowed } // Usage fn main() { // Owned version - can outlive source data let user = User { name: String::from("Alice"), email: String::from("alice@example.com"), }; // Borrowed version - tied to source data lifetime let name = "Bob"; let email = "bob@example.com"; let user_ref = UserRef { name, email }; }
Debugging Ownership Errors
Common Error Messages and Solutions
1. “Value borrowed after move”
#![allow(unused)] fn main() { // ❌ Problem let s = String::from("hello"); let s2 = s; // s moved here println!("{}", s); // Error: value borrowed after move // ✅ Solutions // Option 1: Use references let s = String::from("hello"); let s2 = &s; // Borrow instead println!("{} {}", s, s2); // Option 2: Clone when you need copies let s = String::from("hello"); let s2 = s.clone(); // Explicit copy println!("{} {}", s, s2); }
2. “Cannot borrow as mutable”
#![allow(unused)] fn main() { // ❌ Problem let s = String::from("hello"); // Immutable s.push_str(" world"); // Error: cannot borrow as mutable // ✅ Solution: Make it mutable let mut s = String::from("hello"); s.push_str(" world"); }
3. “Borrowed value does not live long enough”
#![allow(unused)] fn main() { // ❌ Problem fn get_string() -> &str { let s = String::from("hello"); &s // Error: does not live long enough } // ✅ Solutions // Option 1: Return owned value fn get_string() -> String { String::from("hello") } // Option 2: Use string literal (static lifetime) fn get_string() -> &'static str { "hello" } }
Tools for Understanding Ownership
#![allow(unused)] fn main() { fn debug_ownership() { let s1 = String::from("hello"); println!("s1 created"); let s2 = s1; // Move occurs here println!("s1 moved to s2"); // println!("{}", s1); // This would error let s3 = &s2; // Borrow s2 println!("s2 borrowed as s3: {}", s3); drop(s2); // Explicit drop println!("s2 dropped"); // println!("{}", s3); // This would error - s2 was dropped } }
Performance Implications
Zero-Cost Abstractions
#![allow(unused)] fn main() { // All of these have the same runtime performance: // Direct access let vec = vec![1, 2, 3, 4, 5]; let sum1 = vec[0] + vec[1] + vec[2] + vec[3] + vec[4]; // Iterator (zero-cost abstraction) let sum2: i32 = vec.iter().sum(); // Reference passing (no copying) fn sum_vec(v: &Vec<i32>) -> i32 { v.iter().sum() } let sum3 = sum_vec(&vec); // All compile to similar assembly code! }
Memory Layout Guarantees
#![allow(unused)] fn main() { // Rust guarantees memory layout #[repr(C)] // Compatible with C struct layout struct Point { x: f64, // Guaranteed to be first y: f64, // Guaranteed to be second } // No hidden vtables, no GC headers // What you see is what you get in memory }
Key Takeaways
- Ownership prevents entire classes of bugs at compile time
- Move semantics are default - be explicit when you want copies
- Borrowing allows safe sharing without ownership transfer
- Lifetimes ensure references are always valid but often inferred
- The compiler is your friend - ownership errors are caught early
- Zero runtime cost - all ownership checks happen at compile time
Mental Model Summary
#![allow(unused)] fn main() { // Think of ownership like keys to a house: let house_keys = String::from("keys"); // You own the keys let friend = house_keys; // You give keys to friend // house_keys is no longer valid // You no longer have keys let borrowed_keys = &friend; // Friend lets you borrow keys // friend still owns keys // Friend still owns them drop(friend); // Friend moves away // borrowed_keys no longer valid // Your borrowed keys invalid }
Exercises
Exercise 1: Ownership Transfer Chain
Create a program that demonstrates ownership transfer through a chain of functions:
// Implement these functions following ownership rules fn create_message() -> String { // Create and return a String } fn add_greeting(message: String) -> String { // Take ownership, add "Hello, " prefix, return new String } fn add_punctuation(message: String) -> String { // Take ownership, add "!" suffix, return new String } fn print_and_consume(message: String) { // Take ownership, print message, let it be dropped } fn main() { // Chain the functions together // create -> add_greeting -> add_punctuation -> print_and_consume // Try to use the message after each step - what happens? }
Exercise 2: Reference vs Ownership
Fix the ownership issues in this code:
fn analyze_text(text: String) -> (usize, String) { let word_count = text.split_whitespace().count(); let uppercase = text.to_uppercase(); (word_count, uppercase) } fn main() { let article = String::from("Rust is a systems programming language"); let (count, upper) = analyze_text(article); println!("Original: {}", article); // ❌ This should work but doesn't println!("Word count: {}", count); println!("Uppercase: {}", upper); // Also make this work: let count2 = analyze_text(article).0; // ❌ This should also work }
Exercise 3: Lifetime Annotations
Implement a function that finds the longest common prefix of two strings:
// Fix the lifetime annotations fn longest_common_prefix(s1: &str, s2: &str) -> &str { let mut i = 0; let s1_chars: Vec<char> = s1.chars().collect(); let s2_chars: Vec<char> = s2.chars().collect(); while i < s1_chars.len() && i < s2_chars.len() && s1_chars[i] == s2_chars[i] { i += 1; } &s1[..i] // Return slice of first string } #[cfg(test)] mod tests { use super::*; #[test] fn test_common_prefix() { assert_eq!(longest_common_prefix("hello", "help"), "hel"); assert_eq!(longest_common_prefix("rust", "ruby"), "ru"); assert_eq!(longest_common_prefix("abc", "xyz"), ""); } } fn main() { let word1 = String::from("programming"); let word2 = "program"; let prefix = longest_common_prefix(&word1, word2); println!("Common prefix: '{}'", prefix); // Both word1 and word2 should still be usable here println!("Word1: {}, Word2: {}", word1, word2); }
Next Up: In Chapter 5, we’ll explore smart pointers - Rust’s tools for more complex memory management scenarios when simple ownership isn’t enough.
Chapter 5: Smart Pointers
Advanced Memory Management Beyond Basic Ownership
Learning Objectives
By the end of this chapter, you’ll be able to:
- Use Box
for heap allocation and recursive data structures - Share ownership safely with Rc
and Arc - Implement interior mutability with RefCell
and Mutex - Prevent memory leaks with Weak
references - Choose the right smart pointer for different scenarios
- Understand the performance implications of each smart pointer type
What Are Smart Pointers?
Smart pointers are data structures that act like pointers but have additional metadata and capabilities. Unlike regular references, smart pointers own the data they point to.
Smart Pointers vs Regular References
| Feature | Regular Reference | Smart Pointer |
|---|---|---|
| Ownership | Borrows data | Owns data |
| Memory location | Stack or heap | Usually heap |
| Deallocation | Automatic (owner drops) | Automatic (smart pointer drops) |
| Runtime overhead | None | Some (depends on type) |
Comparison with C++/.NET
| Rust | C++ Equivalent | C#/.NET Equivalent |
|---|---|---|
Box<T> | std::unique_ptr<T> | No direct equivalent |
Rc<T> | std::shared_ptr<T> | Reference counting GC |
Arc<T> | std::shared_ptr<T> (thread-safe) | Thread-safe references |
RefCell<T> | No equivalent | Lock-free interior mutability |
Weak<T> | std::weak_ptr<T> | WeakReference<T> |
Box: Single Ownership on the Heap
Box<T> is the simplest smart pointer - it provides heap allocation with single ownership.
When to Use Box
- Large data: Move large structs to heap to avoid stack overflow
- Recursive types: Enable recursive data structures
- Trait objects: Store different types behind a common trait
- Unsized types: Store dynamically sized types
Basic Usage
fn main() { // Heap allocation let b = Box::new(5); println!("b = {}", b); // Box implements Deref, so this works // Large struct - better on heap struct LargeStruct { data: [u8; 1024 * 1024], // 1MB } let large = Box::new(LargeStruct { data: [0; 1024 * 1024] }); // Only pointer stored on stack, data on heap }
Recursive Data Structures
// ❌ This won't compile - infinite size // enum List { // Cons(i32, List), // Nil, // } // ✅ This works - Box has known size #[derive(Debug)] enum List { Cons(i32, Box<List>), Nil, } impl List { fn new() -> List { List::Nil } fn prepend(self, elem: i32) -> List { List::Cons(elem, Box::new(self)) } fn len(&self) -> usize { match self { List::Cons(_, tail) => 1 + tail.len(), List::Nil => 0, } } } fn main() { let list = List::new() .prepend(1) .prepend(2) .prepend(3); println!("List: {:?}", list); println!("Length: {}", list.len()); }
Box with Trait Objects
trait Draw { fn draw(&self); } struct Circle { radius: f64, } struct Rectangle { width: f64, height: f64, } impl Draw for Circle { fn draw(&self) { println!("Drawing circle with radius {}", self.radius); } } impl Draw for Rectangle { fn draw(&self) { println!("Drawing rectangle {}x{}", self.width, self.height); } } fn main() { let shapes: Vec<Box<dyn Draw>> = vec![ Box::new(Circle { radius: 5.0 }), Box::new(Rectangle { width: 10.0, height: 5.0 }), ]; for shape in shapes { shape.draw(); } }
Rc: Reference Counted Single-Threaded Sharing
Rc<T> (Reference Counted) enables multiple ownership of the same data in single-threaded scenarios.
When to Use Rc
- Multiple owners need to read the same data
- Data lifetime is determined by multiple owners
- Single-threaded environment only
- Shared immutable data structures (graphs, trees)
Basic Usage
use std::rc::Rc; fn main() { let a = Rc::new(5); println!("Reference count: {}", Rc::strong_count(&a)); // 1 let b = Rc::clone(&a); // Shallow clone, increases ref count println!("Reference count: {}", Rc::strong_count(&a)); // 2 { let c = Rc::clone(&a); println!("Reference count: {}", Rc::strong_count(&a)); // 3 } // c dropped here println!("Reference count: {}", Rc::strong_count(&a)); // 2 } // a and b dropped here, memory freed when count reaches 0
Sharing Lists
use std::rc::Rc; #[derive(Debug)] enum List { Cons(i32, Rc<List>), Nil, } fn main() { let a = Rc::new(List::Cons(5, Rc::new(List::Cons(10, Rc::new(List::Nil))))); let b = List::Cons(3, Rc::clone(&a)); let c = List::Cons(4, Rc::clone(&a)); println!("List a: {:?}", a); println!("List b: {:?}", b); println!("List c: {:?}", c); println!("Reference count for a: {}", Rc::strong_count(&a)); // 3 }
Tree with Shared Subtrees
use std::rc::Rc; #[derive(Debug)] struct TreeNode { value: i32, left: Option<Rc<TreeNode>>, right: Option<Rc<TreeNode>>, } impl TreeNode { fn new(value: i32) -> Rc<Self> { Rc::new(TreeNode { value, left: None, right: None, }) } fn with_children(value: i32, left: Option<Rc<TreeNode>>, right: Option<Rc<TreeNode>>) -> Rc<Self> { Rc::new(TreeNode { value, left, right }) } } fn main() { // Shared subtree let shared_subtree = TreeNode::with_children( 10, Some(TreeNode::new(5)), Some(TreeNode::new(15)), ); // Two different trees sharing the same subtree let tree1 = TreeNode::with_children(1, Some(Rc::clone(&shared_subtree)), None); let tree2 = TreeNode::with_children(2, Some(Rc::clone(&shared_subtree)), None); println!("Tree 1: {:?}", tree1); println!("Tree 2: {:?}", tree2); println!("Shared subtree references: {}", Rc::strong_count(&shared_subtree)); // 3 }
RefCell: Interior Mutability
RefCell<T> provides “interior mutability” - the ability to mutate data even when there are immutable references to it. The borrowing rules are enforced at runtime instead of compile time.
When to Use RefCell
- You need to mutate data behind shared references
- You’re certain the borrowing rules are followed, but the compiler can’t verify it
- Implementing patterns that require mutation through shared references
- Building mock objects for testing
Basic Usage
use std::cell::RefCell; fn main() { let data = RefCell::new(5); // Borrow immutably { let r1 = data.borrow(); let r2 = data.borrow(); println!("r1: {}, r2: {}", r1, r2); // Multiple immutable borrows OK } // Borrows dropped here // Borrow mutably { let mut r3 = data.borrow_mut(); *r3 = 10; } // Mutable borrow dropped here println!("Final value: {}", data.borrow()); }
Runtime Borrow Checking
use std::cell::RefCell; fn main() { let data = RefCell::new(5); let r1 = data.borrow(); // let r2 = data.borrow_mut(); // ❌ Panic! Already borrowed immutably drop(r1); // Drop immutable borrow let r2 = data.borrow_mut(); // ✅ OK now println!("Mutably borrowed: {}", r2); }
Combining Rc and RefCell
This is a common pattern for shared mutable data:
use std::rc::Rc; use std::cell::RefCell; #[derive(Debug)] struct Node { value: i32, children: Vec<Rc<RefCell<Node>>>, } impl Node { fn new(value: i32) -> Rc<RefCell<Self>> { Rc::new(RefCell::new(Node { value, children: Vec::new(), })) } fn add_child(parent: &Rc<RefCell<Node>>, child: Rc<RefCell<Node>>) { parent.borrow_mut().children.push(child); } } fn main() { let root = Node::new(1); let child1 = Node::new(2); let child2 = Node::new(3); Node::add_child(&root, child1); Node::add_child(&root, child2); println!("Root: {:?}", root); // Modify child through shared reference root.borrow().children[0].borrow_mut().value = 20; println!("Modified root: {:?}", root); }
Arc: Atomic Reference Counting for Concurrency
Arc<T> (Atomically Reference Counted) is the thread-safe version of Rc<T>.
When to Use Arc
- Multiple threads need to share ownership of data
- Thread-safe reference counting is needed
- Sharing immutable data across thread boundaries
Basic Usage
use std::sync::Arc; use std::thread; fn main() { let data = Arc::new(vec![1, 2, 3, 4, 5]); let mut handles = vec![]; for i in 0..3 { let data_clone = Arc::clone(&data); let handle = thread::spawn(move || { println!("Thread {}: {:?}", i, data_clone); }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("Reference count: {}", Arc::strong_count(&data)); // Back to 1 }
Arc<Mutex>: Shared Mutable State
For mutable shared data across threads, combine Arc<T> with Mutex<T>:
use std::sync::{Arc, Mutex}; use std::thread; fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let counter_clone = Arc::clone(&counter); let handle = thread::spawn(move || { let mut num = counter_clone.lock().unwrap(); *num += 1; }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("Final count: {}", *counter.lock().unwrap()); // Should be 10 }
Weak: Breaking Reference Cycles
Weak<T> provides a non-owning reference that doesn’t affect reference counting. It’s used to break reference cycles that would cause memory leaks.
The Reference Cycle Problem
use std::rc::{Rc, Weak}; use std::cell::RefCell; #[derive(Debug)] struct Node { value: i32, parent: RefCell<Weak<Node>>, // Weak reference to parent children: RefCell<Vec<Rc<Node>>>, // Strong references to children } impl Node { fn new(value: i32) -> Rc<Self> { Rc::new(Node { value, parent: RefCell::new(Weak::new()), children: RefCell::new(Vec::new()), }) } fn add_child(parent: &Rc<Node>, child: Rc<Node>) { // Set parent weak reference *child.parent.borrow_mut() = Rc::downgrade(parent); // Add child strong reference parent.children.borrow_mut().push(child); } } fn main() { let parent = Node::new(1); let child = Node::new(2); Node::add_child(&parent, child); // Access parent from child let parent_from_child = parent.children.borrow()[0] .parent .borrow() .upgrade(); // Convert weak to strong reference if let Some(parent_ref) = parent_from_child { println!("Child's parent value: {}", parent_ref.value); } println!("Parent strong count: {}", Rc::strong_count(&parent)); // 1 println!("Parent weak count: {}", Rc::weak_count(&parent)); // 1 }
Observer Pattern with Weak References
use std::rc::{Rc, Weak}; use std::cell::RefCell; trait Observer { fn notify(&self, message: &str); } struct Subject { observers: RefCell<Vec<Weak<dyn Observer>>>, } impl Subject { fn new() -> Self { Subject { observers: RefCell::new(Vec::new()), } } fn subscribe(&self, observer: Weak<dyn Observer>) { self.observers.borrow_mut().push(observer); } fn notify_all(&self, message: &str) { let mut observers = self.observers.borrow_mut(); observers.retain(|weak_observer| { if let Some(observer) = weak_observer.upgrade() { observer.notify(message); true // Keep this observer } else { false // Remove dead observer } }); } } struct ConcreteObserver { id: String, } impl Observer for ConcreteObserver { fn notify(&self, message: &str) { println!("Observer {} received: {}", self.id, message); } } fn main() { let subject = Subject::new(); { let observer1 = Rc::new(ConcreteObserver { id: "1".to_string() }); let observer2 = Rc::new(ConcreteObserver { id: "2".to_string() }); subject.subscribe(Rc::downgrade(&observer1)); subject.subscribe(Rc::downgrade(&observer2)); subject.notify_all("Hello observers!"); } // Observers dropped here subject.notify_all("Anyone still listening?"); // Dead observers cleaned up }
Choosing the Right Smart Pointer
Decision Tree
Do you need shared ownership?
├─ No → Use Box<T>
└─ Yes
├─ Single threaded?
│ ├─ Yes
│ │ ├─ Need interior mutability? → Rc<RefCell<T>>
│ │ └─ Just sharing? → Rc<T>
│ └─ No (multi-threaded)
│ ├─ Need interior mutability? → Arc<Mutex<T>>
│ └─ Just sharing? → Arc<T>
└─ Breaking cycles? → Use Weak<T> in combination
Performance Characteristics
| Smart Pointer | Allocation | Reference Counting | Thread Safety | Interior Mutability |
|---|---|---|---|---|
Box<T> | Heap | No | No | No |
Rc<T> | Heap | Yes (non-atomic) | No | No |
Arc<T> | Heap | Yes (atomic) | Yes | No |
RefCell<T> | Stack/Heap | No | No | Yes (runtime) |
Weak<T> | No allocation | Weak counting | Depends on target | No |
Common Patterns
#![allow(unused)] fn main() { use std::rc::{Rc, Weak}; use std::cell::RefCell; use std::sync::{Arc, Mutex}; // Pattern 1: Immutable shared data (single-threaded) fn pattern1() { let shared_data = Rc::new(vec![1, 2, 3, 4, 5]); let clone1 = Rc::clone(&shared_data); let clone2 = Rc::clone(&shared_data); // Multiple readers, no writers } // Pattern 2: Mutable shared data (single-threaded) fn pattern2() { let shared_data = Rc::new(RefCell::new(vec![1, 2, 3])); shared_data.borrow_mut().push(4); let len = shared_data.borrow().len(); } // Pattern 3: Immutable shared data (multi-threaded) fn pattern3() { let shared_data = Arc::new(vec![1, 2, 3, 4, 5]); let clone = Arc::clone(&shared_data); std::thread::spawn(move || { println!("{:?}", clone); }); } // Pattern 4: Mutable shared data (multi-threaded) fn pattern4() { let shared_data = Arc::new(Mutex::new(vec![1, 2, 3])); let clone = Arc::clone(&shared_data); std::thread::spawn(move || { clone.lock().unwrap().push(4); }); } }
Common Pitfalls and Solutions
Pitfall 1: Reference Cycles with Rc
#![allow(unused)] fn main() { use std::rc::Rc; use std::cell::RefCell; // ❌ This creates a reference cycle and memory leak #[derive(Debug)] struct BadNode { children: RefCell<Vec<Rc<BadNode>>>, parent: RefCell<Option<Rc<BadNode>>>, // Strong reference = cycle! } // ✅ Use Weak for parent references #[derive(Debug)] struct GoodNode { children: RefCell<Vec<Rc<GoodNode>>>, parent: RefCell<Option<std::rc::Weak<GoodNode>>>, // Weak reference } }
Pitfall 2: RefCell Runtime Panics
#![allow(unused)] fn main() { use std::cell::RefCell; fn dangerous_refcell() { let data = RefCell::new(5); let _r1 = data.borrow(); let _r2 = data.borrow_mut(); // ❌ Panics at runtime! } // ✅ Safe RefCell usage fn safe_refcell() { let data = RefCell::new(5); { let r1 = data.borrow(); println!("Value: {}", r1); } // r1 dropped { let mut r2 = data.borrow_mut(); *r2 = 10; } // r2 dropped } }
Pitfall 3: Unnecessary Arc for Single-Threaded Code
#![allow(unused)] fn main() { // ❌ Unnecessary atomic operations use std::sync::Arc; fn single_threaded_sharing() { let data = Arc::new(vec![1, 2, 3]); // Atomic ref counting overhead // ... single-threaded code only } // ✅ Use Rc for single-threaded sharing use std::rc::Rc; fn single_threaded_sharing_optimized() { let data = Rc::new(vec![1, 2, 3]); // Faster non-atomic ref counting // ... single-threaded code only } }
Key Takeaways
- Box
for single ownership heap allocation and recursive types - Rc
for shared ownership in single-threaded contexts - RefCell
for interior mutability with runtime borrow checking - Arc
for shared ownership across threads - Weak
to break reference cycles and avoid memory leaks - Combine smart pointers for complex sharing patterns (e.g.,
Rc<RefCell<T>>) - Choose based on threading and mutability needs
Exercises
Exercise 1: Binary Tree with Parent References
Implement a binary tree where nodes can access both children and parents without creating reference cycles:
use std::rc::{Rc, Weak}; use std::cell::RefCell; #[derive(Debug)] struct TreeNode { value: i32, left: Option<Rc<RefCell<TreeNode>>>, right: Option<Rc<RefCell<TreeNode>>>, parent: RefCell<Weak<RefCell<TreeNode>>>, } impl TreeNode { fn new(value: i32) -> Rc<RefCell<Self>> { // Implement } fn add_left_child(node: &Rc<RefCell<TreeNode>>, value: i32) { // Implement: Add left child and set its parent reference } fn add_right_child(node: &Rc<RefCell<TreeNode>>, value: i32) { // Implement: Add right child and set its parent reference } fn get_parent_value(&self) -> Option<i32> { // Implement: Get parent's value if it exists } fn find_root(&self) -> Option<Rc<RefCell<TreeNode>>> { // Implement: Traverse up to find root node } } fn main() { let root = TreeNode::new(1); TreeNode::add_left_child(&root, 2); TreeNode::add_right_child(&root, 3); let left_child = root.borrow().left.as_ref().unwrap().clone(); TreeNode::add_left_child(&left_child, 4); // Test parent access let grandchild = left_child.borrow().left.as_ref().unwrap().clone(); println!("Grandchild's parent: {:?}", grandchild.borrow().get_parent_value()); // Test root finding if let Some(found_root) = grandchild.borrow().find_root() { println!("Root value: {}", found_root.borrow().value); } }
Exercise 2: Thread-Safe Cache
Implement a thread-safe cache using Arc and Mutex:
use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::thread; struct Cache<K, V> { data: Arc<Mutex<HashMap<K, V>>>, } impl<K, V> Cache<K, V> where K: Clone + Eq + std::hash::Hash + Send + 'static, V: Clone + Send + 'static, { fn new() -> Self { // Implement } fn get(&self, key: &K) -> Option<V> { // Implement: Get value from cache } fn set(&self, key: K, value: V) { // Implement: Set value in cache } fn size(&self) -> usize { // Implement: Get cache size } } impl<K, V> Clone for Cache<K, V> { fn clone(&self) -> Self { // Implement: Clone should share the same underlying data Cache { data: Arc::clone(&self.data), } } } fn main() { let cache = Cache::new(); let mut handles = vec![]; // Spawn multiple threads that use the cache for i in 0..5 { let cache_clone = cache.clone(); let handle = thread::spawn(move || { // Set some values cache_clone.set(format!("key{}", i), i * 10); // Get some values if let Some(value) = cache_clone.get(&format!("key{}", i)) { println!("Thread {}: got value {}", i, value); } }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("Final cache size: {}", cache.size()); }
Exercise 3: Observer Pattern with Automatic Cleanup
Extend the observer pattern to automatically clean up observers and provide subscription management:
use std::rc::{Rc, Weak}; use std::cell::RefCell; trait Observer { fn update(&self, data: &str); fn id(&self) -> &str; } struct Subject { observers: RefCell<Vec<Weak<dyn Observer>>>, } impl Subject { fn new() -> Self { // Implement } fn subscribe(&self, observer: Weak<dyn Observer>) { // Implement: Add observer } fn unsubscribe(&self, observer_id: &str) { // Implement: Remove observer by ID } fn notify(&self, data: &str) { // Implement: Notify all observers, cleaning up dead ones } fn observer_count(&self) -> usize { // Implement: Count living observers } } struct ConcreteObserver { id: String, } impl ConcreteObserver { fn new(id: String) -> Rc<Self> { Rc::new(ConcreteObserver { id }) } } impl Observer for ConcreteObserver { fn update(&self, data: &str) { println!("Observer {} received: {}", self.id, data); } fn id(&self) -> &str { &self.id } } fn main() { let subject = Subject::new(); let observer1 = ConcreteObserver::new("obs1".to_string()); let observer2 = ConcreteObserver::new("obs2".to_string()); subject.subscribe(Rc::downgrade(&observer1)); subject.subscribe(Rc::downgrade(&observer2)); subject.notify("First message"); println!("Observer count: {}", subject.observer_count()); // Drop one observer drop(observer1); subject.notify("Second message"); println!("Observer count after cleanup: {}", subject.observer_count()); subject.unsubscribe("obs2"); subject.notify("Third message"); println!("Final observer count: {}", subject.observer_count()); }
Additional Resources
- Rust Container Cheat Sheet by Ralph Levien - An excellent visual reference for Rust containers and smart pointers, including Vec, String, Box, Rc, Arc, RefCell, and more. Perfect for quick lookups and comparisons.
- The Rust Book - Smart Pointers
- Rust by Example - Smart Pointers
- RefCell and Interior Mutability
Next Up: In Day 2, we’ll explore collections, traits, and generics - the tools that make Rust code both safe and expressive.
Chapter 6: Collections Beyond Vec
HashMap and HashSet for Real-World Applications
Learning Objectives
By the end of this chapter, you’ll be able to:
- Use HashMap<K, V> efficiently for key-value storage
- Apply HashSet
for unique value collections - Master the Entry API for efficient map operations
- Choose between HashMap, BTreeMap, and other collections
- Work with custom types as keys
Quick Collection Reference
| Collection | Use When You Need | Performance |
|---|---|---|
Vec<T> | Ordered sequence, index access | O(1) index, O(n) search |
HashMap<K,V> | Fast key-value lookups | O(1) average all operations |
HashSet<T> | Unique values, fast membership test | O(1) average all operations |
BTreeMap<K,V> | Sorted keys, range queries | O(log n) all operations |
HashMap<K, V>: The Swiss Army Knife
Basic Operations
#![allow(unused)] fn main() { use std::collections::HashMap; fn hashmap_basics() { // Creation let mut scores = HashMap::new(); scores.insert("Alice", 100); scores.insert("Bob", 85); // From iterator let teams = vec!["Blue", "Red"]; let points = vec![10, 50]; let team_scores: HashMap<_, _> = teams.into_iter() .zip(points.into_iter()) .collect(); // Accessing values if let Some(score) = scores.get("Alice") { println!("Alice's score: {}", score); } // Check existence if scores.contains_key("Alice") { println!("Alice is in the map"); } } }
The Entry API: Powerful and Efficient
#![allow(unused)] fn main() { use std::collections::HashMap; fn entry_api_examples() { let mut word_count = HashMap::new(); let text = "the quick brown fox jumps over the lazy dog the"; // Count words efficiently for word in text.split_whitespace() { *word_count.entry(word).or_insert(0) += 1; } // Insert if absent let mut cache = HashMap::new(); cache.entry("key").or_insert_with(|| { // Expensive computation only runs if key doesn't exist expensive_calculation() }); // Modify or insert let mut scores = HashMap::new(); scores.entry("Alice") .and_modify(|score| *score += 10) .or_insert(100); } fn expensive_calculation() -> String { "computed_value".to_string() } }
HashMap with Custom Keys
#![allow(unused)] fn main() { use std::collections::HashMap; #[derive(Debug, Eq, PartialEq, Hash)] struct UserId(u64); #[derive(Debug, Eq, PartialEq, Hash)] struct CompositeKey { category: String, id: u32, } fn custom_keys() { let mut user_data = HashMap::new(); user_data.insert(UserId(1001), "Alice"); user_data.insert(UserId(1002), "Bob"); let mut composite_map = HashMap::new(); composite_map.insert( CompositeKey { category: "user".to_string(), id: 1 }, "User One" ); // Access with custom key if let Some(name) = user_data.get(&UserId(1001)) { println!("Found user: {}", name); } } }
HashSet: Unique Value Collections
Basic Operations and Set Theory
#![allow(unused)] fn main() { use std::collections::HashSet; fn hashset_operations() { // Create and populate let mut set1: HashSet<i32> = vec![1, 2, 3, 2, 4].into_iter().collect(); let set2: HashSet<i32> = vec![3, 4, 5, 6].into_iter().collect(); // Set operations let union: HashSet<_> = set1.union(&set2).cloned().collect(); let intersection: HashSet<_> = set1.intersection(&set2).cloned().collect(); let difference: HashSet<_> = set1.difference(&set2).cloned().collect(); println!("Union: {:?}", union); // {1, 2, 3, 4, 5, 6} println!("Intersection: {:?}", intersection); // {3, 4} println!("Difference: {:?}", difference); // {1, 2} // Check membership if set1.contains(&3) { println!("Set contains 3"); } // Insert returns bool indicating if value was new if set1.insert(10) { println!("10 was added (wasn't present before)"); } } fn practical_hashset_use() { // Track visited items let mut visited = HashSet::new(); let items = vec!["home", "about", "home", "contact", "about"]; for item in items { if visited.insert(item) { println!("First visit to: {}", item); } else { println!("Already visited: {}", item); } } } }
When to Use BTreeMap/BTreeSet
Use BTreeMap/BTreeSet when you need:
- Keys/values in sorted order
- Range queries (
map.range("a".."c")) - Consistent iteration order
- No hash function available for keys
#![allow(unused)] fn main() { use std::collections::BTreeMap; // Example: Leaderboard that needs sorted scores let mut leaderboard = BTreeMap::new(); leaderboard.insert(95, "Alice"); leaderboard.insert(87, "Bob"); leaderboard.insert(92, "Charlie"); // Iterate in score order (ascending) for (score, name) in &leaderboard { println!("{}: {}", name, score); } // Get top 3 scores let top_scores: Vec<_> = leaderboard .iter() .rev() // Reverse for descending order .take(3) .collect(); }
Common Pitfalls
HashMap Key Requirements
#![allow(unused)] fn main() { use std::collections::HashMap; // ❌ f64 doesn't implement Eq (NaN issues) // let mut map: HashMap<f64, String> = HashMap::new(); // ✅ Use ordered wrapper or integer representation #[derive(Debug, PartialEq, Eq, Hash)] struct OrderedFloat(i64); // Store as integer representation impl From<f64> for OrderedFloat { fn from(f: f64) -> Self { OrderedFloat(f.to_bits() as i64) } } }
Borrowing During Iteration
#![allow(unused)] fn main() { // ❌ Can't modify while iterating // for (key, value) in &map { // map.insert(new_key, new_value); // Error! // } // ✅ Collect changes first, apply after let changes: Vec<_> = map.iter() .filter(|(_, &v)| v > threshold) .map(|(k, v)| (format!("new_{}", k), v * 2)) .collect(); for (key, value) in changes { map.insert(key, value); } }
Exercise: Student Grade Management System
Create a system that manages student grades using HashMap and HashSet to practice collections operations and the Entry API:
use std::collections::{HashMap, HashSet}; #[derive(Debug)] struct GradeBook { // Student name -> HashMap of (subject -> grade) grades: HashMap<String, HashMap<String, f64>>, // Set of all subjects offered subjects: HashSet<String>, } impl GradeBook { fn new() -> Self { GradeBook { grades: HashMap::new(), subjects: HashSet::new(), } } fn add_subject(&mut self, subject: String) { // TODO: Add subject to the subjects set todo!() } fn add_grade(&mut self, student: String, subject: String, grade: f64) { // TODO: Add a grade for a student in a subject // Hints: // 1. Add subject to subjects set // 2. Use entry() API to get or create the student's grade map // 3. Insert the grade for the subject todo!() } fn get_student_average(&self, student: &str) -> Option<f64> { // TODO: Calculate average grade for a student across all their subjects // Return None if student doesn't exist // Hint: Use .values() and iterator methods todo!() } fn get_subject_average(&self, subject: &str) -> Option<f64> { // TODO: Calculate average grade for a subject across all students // Return None if no students have grades in this subject todo!() } fn get_students_in_subject(&self, subject: &str) -> Vec<&String> { // TODO: Return list of students who have a grade in the given subject // Hint: Filter students who have this subject in their grade map todo!() } fn get_top_students(&self, n: usize) -> Vec<(String, f64)> { // TODO: Return top N students by average grade // Format: Vec<(student_name, average_grade)> // Hint: Calculate averages, collect into Vec, sort, and take top N todo!() } fn remove_student(&mut self, student: &str) -> bool { // TODO: Remove a student and all their grades // Return true if student existed, false otherwise todo!() } fn list_subjects(&self) -> Vec<&String> { // TODO: Return all subjects as a sorted vector todo!() } } fn main() { let mut gradebook = GradeBook::new(); // Add subjects gradebook.add_subject("Math".to_string()); gradebook.add_subject("English".to_string()); gradebook.add_subject("Science".to_string()); // Add grades for students gradebook.add_grade("Alice".to_string(), "Math".to_string(), 95.0); gradebook.add_grade("Alice".to_string(), "English".to_string(), 87.0); gradebook.add_grade("Bob".to_string(), "Math".to_string(), 82.0); gradebook.add_grade("Bob".to_string(), "Science".to_string(), 91.0); gradebook.add_grade("Charlie".to_string(), "English".to_string(), 78.0); gradebook.add_grade("Charlie".to_string(), "Science".to_string(), 85.0); // Test the methods if let Some(avg) = gradebook.get_student_average("Alice") { println!("Alice's average: {:.2}", avg); } if let Some(avg) = gradebook.get_subject_average("Math") { println!("Math class average: {:.2}", avg); } let math_students = gradebook.get_students_in_subject("Math"); println!("Students in Math: {:?}", math_students); let top_students = gradebook.get_top_students(2); println!("Top 2 students: {:?}", top_students); println!("All subjects: {:?}", gradebook.list_subjects()); }
Implementation Hints:
-
add_grade() method:
- Use
self.grades.entry(student).or_insert_with(HashMap::new) - Then insert the grade:
.insert(subject, grade)
- Use
-
get_student_average():
- Use
self.grades.get(student)?to get the student’s grades - Use
.values().sum::<f64>() / values.len() as f64
- Use
-
get_subject_average():
- Iterate through all students:
self.grades.iter() - Filter students who have this subject:
filter_map(|(_, grades)| grades.get(subject)) - Calculate average from the filtered grades
- Iterate through all students:
-
get_top_students():
- Use
map()to convert students to (name, average) pairs - Use
collect::<Vec<_>>()andsort_by()with float comparison - Use
take(n)to get top N
- Use
What you’ll learn:
- HashMap’s Entry API for efficient insertions
- HashSet for tracking unique values
- Nested HashMap structures
- Iterator methods for data processing
- Working with Option types from HashMap lookups
Key Takeaways
- HashMap<K,V> for fast key-value lookups with the Entry API for efficiency
- HashSet
for unique values and set operations - BTreeMap/BTreeSet when you need sorted data or range queries
- Custom keys must implement Hash + Eq (or Ord for BTree*)
- Can’t modify while iterating - collect changes first
- Entry API prevents redundant lookups and improves performance
Next Up: In Chapter 7, we’ll explore traits - Rust’s powerful system for defining shared behavior and enabling polymorphism without inheritance.
Chapter 7: Traits - Shared Behavior and Polymorphism
Defining, Implementing, and Using Traits in Rust
Learning Objectives
By the end of this chapter, you’ll be able to:
- Define custom traits and implement them for various types
- Use trait bounds to constrain generic types
- Work with trait objects for dynamic dispatch
- Understand the difference between static and dynamic dispatch
- Apply common standard library traits effectively
- Use associated types and default implementations
- Handle trait coherence and orphan rules
What Are Traits?
Traits define shared behavior that types can implement. They’re similar to interfaces in C#/Java or concepts in C++20, but with some unique features.
Traits vs Other Languages
| Concept | C++ | C#/Java | Rust |
|---|---|---|---|
| Interface | Pure virtual class | Interface | Trait |
| Multiple inheritance | Yes (complex) | No (interfaces only) | Yes (traits) |
| Default implementations | No | Yes (C# 8+, Java 8+) | Yes |
| Associated types | No | No | Yes |
| Static dispatch | Templates | Generics | Generics |
| Dynamic dispatch | Virtual functions | Virtual methods | Trait objects |
Basic Trait Definition
#![allow(unused)] fn main() { // Define a trait trait Drawable { fn draw(&self); fn area(&self) -> f64; // Default implementation fn description(&self) -> String { format!("A drawable shape with area {}", self.area()) } } // Implement the trait for different types struct Circle { radius: f64, } struct Rectangle { width: f64, height: f64, } impl Drawable for Circle { fn draw(&self) { println!("Drawing a circle with radius {}", self.radius); } fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } } impl Drawable for Rectangle { fn draw(&self) { println!("Drawing a rectangle {}x{}", self.width, self.height); } fn area(&self) -> f64 { self.width * self.height } // Override default implementation fn description(&self) -> String { format!("A rectangle with dimensions {}x{}", self.width, self.height) } } }
Standard Library Traits You Need to Know
Debug and Display
use std::fmt; #[derive(Debug)] // Automatic Debug implementation struct Point { x: f64, y: f64, } // Manual Display implementation impl fmt::Display for Point { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "({}, {})", self.x, self.y) } } fn main() { let p = Point { x: 1.0, y: 2.0 }; println!("{:?}", p); // Debug: Point { x: 1.0, y: 2.0 } println!("{}", p); // Display: (1.0, 2.0) }
Clone and Copy
#[derive(Clone, Copy, Debug)] struct SmallData { value: i32, } #[derive(Clone, Debug)] struct LargeData { data: Vec<i32>, } fn main() { let small = SmallData { value: 42 }; let small_copy = small; // Copy happens automatically println!("{:?}", small); // Still usable after copy let large = LargeData { data: vec![1, 2, 3] }; let large_clone = large.clone(); // Explicit clone needed // large moved here, but we have large_clone }
Generic Functions with Trait Bounds
Note: This section previews generic syntax (
<T: Trait>). Chapter 8 covers generics in full — for now, focus on the trait side: what capabilities you can require.
Basic Trait Bounds
#![allow(unused)] fn main() { use std::fmt::Display; // Function that works with any type implementing Display fn print_info<T: Display>(item: T) { println!("Info: {}", item); } // Multiple trait bounds fn print_and_compare<T: Display + PartialEq>(item1: T, item2: T) { println!("Item 1: {}", item1); println!("Item 2: {}", item2); println!("Are equal: {}", item1 == item2); } // Where clause for complex bounds fn complex_function<T, U>(t: T, u: U) -> String where T: Display + Clone, U: std::fmt::Debug + Default, { format!("{} and {:?}", t, u) } }
Trait Objects and Dynamic Dispatch
Creating Trait Objects
trait Animal { fn make_sound(&self); fn name(&self) -> &str; } struct Dog { name: String } struct Cat { name: String } impl Animal for Dog { fn make_sound(&self) { println!("Woof!"); } fn name(&self) -> &str { &self.name } } impl Animal for Cat { fn make_sound(&self) { println!("Meow!"); } fn name(&self) -> &str { &self.name } } // Using trait objects fn main() { // Vec of trait objects let animals: Vec<Box<dyn Animal>> = vec![ Box::new(Dog { name: "Buddy".to_string() }), Box::new(Cat { name: "Whiskers".to_string() }), ]; for animal in &animals { println!("{} says:", animal.name()); animal.make_sound(); } // Function parameter as trait object pet_animal(&Dog { name: "Rex".to_string() }); } fn pet_animal(animal: &dyn Animal) { println!("Petting {}", animal.name()); animal.make_sound(); }
Associated Types
Basic Associated Types
#![allow(unused)] fn main() { trait Iterator { type Item; // Associated type fn next(&mut self) -> Option<Self::Item>; } struct Counter { current: u32, max: u32, } impl Counter { fn new(max: u32) -> Counter { Counter { current: 0, max } } } impl Iterator for Counter { type Item = u32; // Specify the associated type fn next(&mut self) -> Option<Self::Item> { if self.current < self.max { let current = self.current; self.current += 1; Some(current) } else { None } } } }
Associated Types vs Generic Parameters
Why does Iterator use type Item instead of trait Iterator<Item>? The key difference: an associated type allows exactly one implementation per type, while a generic parameter allows many.
// Associated type: Counter can only iterate over ONE type (u32)
impl Iterator for Counter {
type Item = u32;
// ...
}
// If Iterator were generic: Counter could iterate over u32 AND &str — confusing!
// impl Iterator<u32> for Counter { ... }
// impl Iterator<&str> for Counter { ... }
Rule of thumb: use an associated type when there’s a single natural choice for the type (e.g., what an iterator yields). Use a generic parameter when the same type should work with multiple choices (e.g., From<T> — a type can be created From<String> and From<&str>).
Operator Overloading with Traits
Implementing Standard Operators
use std::ops::{Add, Mul}; #[derive(Debug, Clone, Copy)] struct Point { x: f64, y: f64, } // Implement addition for Point impl Add for Point { type Output = Point; fn add(self, other: Point) -> Point { Point { x: self.x + other.x, y: self.y + other.y, } } } // Implement scalar multiplication impl Mul<f64> for Point { type Output = Point; fn mul(self, scalar: f64) -> Point { Point { x: self.x * scalar, y: self.y * scalar, } } } fn main() { let p1 = Point { x: 1.0, y: 2.0 }; let p2 = Point { x: 3.0, y: 4.0 }; let p3 = p1 + p2; // Uses Add trait let p4 = p1 * 2.5; // Uses Mul trait println!("p1 + p2 = {:?}", p3); println!("p1 * 2.5 = {:?}", p4); }
Supertraits and Trait Inheritance
#![allow(unused)] fn main() { use std::fmt::Debug; // Supertrait example trait Person { fn name(&self) -> &str; } // Student requires Person trait Student: Person { fn university(&self) -> &str; } // Must implement both traits #[derive(Debug)] struct GradStudent { name: String, uni: String, } impl Person for GradStudent { fn name(&self) -> &str { &self.name } } impl Student for GradStudent { fn university(&self) -> &str { &self.uni } } // Function requiring multiple traits fn print_student_info<T: Student + Debug>(student: &T) { println!("Name: {}", student.name()); println!("University: {}", student.university()); println!("Debug: {:?}", student); } }
Common Trait Patterns
The From and Into Traits
use std::convert::From; #[derive(Debug)] struct Millimeters(u32); #[derive(Debug)] struct Meters(f64); impl From<Meters> for Millimeters { fn from(m: Meters) -> Self { Millimeters((m.0 * 1000.0) as u32) } } // Into is automatically implemented! fn main() { let m = Meters(1.5); let mm: Millimeters = m.into(); // Uses Into (automatic from From) println!("{:?}", mm); // Millimeters(1500) let m2 = Meters(2.0); let mm2 = Millimeters::from(m2); // Direct From usage println!("{:?}", mm2); // Millimeters(2000) }
Exercise: Trait Objects with Multiple Behaviors
Build a plugin system using trait objects:
trait Plugin { fn name(&self) -> &str; fn execute(&self); } trait Configurable { fn configure(&mut self, config: &str); } // Create different plugin types struct LogPlugin { name: String, level: String, } struct MetricsPlugin { name: String, interval: u32, } // TODO: Implement Plugin and Configurable for both types struct PluginManager { plugins: Vec<Box<dyn Plugin>>, } impl PluginManager { fn new() -> Self { PluginManager { plugins: Vec::new() } } fn register(&mut self, plugin: Box<dyn Plugin>) { // TODO: Add plugin to the list } fn run_all(&self) { // TODO: Execute all plugins } } fn main() { let mut manager = PluginManager::new(); // TODO: Create and register plugins // manager.register(Box::new(...)); manager.run_all(); }
Key Takeaways
- Traits define shared behavior across different types
- Static dispatch (generics) is faster but increases code size
- Dynamic dispatch (trait objects) enables runtime polymorphism
- Associated types provide cleaner APIs than generic parameters
- Operator overloading is done through standard traits
- Supertraits allow building trait hierarchies
- From/Into traits enable type conversions
- Default implementations reduce boilerplate code
Next Up: In Chapter 8, we’ll explore generics - Rust’s powerful system for writing flexible, reusable code with type parameters.
Chapter 8: Generics & Type Safety
Learning Objectives
- Master generic functions, structs, and methods
- Understand trait bounds and where clauses
- Learn const generics for compile-time parameters
- Apply type-driven design patterns
- Compare with C++ templates and .NET generics
Introduction
Generics allow you to write flexible, reusable code that works with multiple types while maintaining type safety. Coming from C++ or .NET, you’ll find Rust’s generics familiar but more constrained—in a good way.
Generic Functions
Basic Generic Functions
// Generic function that works with any type T fn swap<T>(a: &mut T, b: &mut T) { std::mem::swap(a, b); } // Multiple generic parameters fn pair<T, U>(first: T, second: U) -> (T, U) { (first, second) } // Usage fn main() { let mut x = 5; let mut y = 10; swap(&mut x, &mut y); println!("x: {}, y: {}", x, y); // x: 10, y: 5 let p = pair("hello", 42); println!("{:?}", p); // ("hello", 42) }
Comparison with C++ and .NET
| Feature | Rust | C++ Templates | .NET Generics |
|---|---|---|---|
| Compilation | Monomorphization | Template instantiation | Runtime generics |
| Type checking | At definition | At instantiation | At definition |
| Constraints | Trait bounds | Concepts (C++20) | Where clauses |
| Code bloat | Yes (like C++) | Yes | No |
| Performance | Zero-cost | Zero-cost | Small overhead |
Generic Structs
// Generic struct struct Point<T> { x: T, y: T, } // Different types for each field struct Pair<T, U> { first: T, second: U, } // Implementation for generic struct impl<T> Point<T> { fn new(x: T, y: T) -> Self { Point { x, y } } } // Implementation for specific type impl Point<f64> { fn distance_from_origin(&self) -> f64 { (self.x.powi(2) + self.y.powi(2)).sqrt() } } fn main() { let integer_point = Point::new(5, 10); let float_point = Point::new(1.0, 4.0); // Only available for Point<f64> println!("Distance: {}", float_point.distance_from_origin()); }
Trait Bounds
Trait bounds specify what functionality a generic type must have.
#![allow(unused)] fn main() { use std::fmt::Display; // T must implement Display fn print_it<T: Display>(value: T) { println!("{}", value); } // Multiple bounds with + fn print_and_clone<T: Display + Clone>(value: T) -> T { println!("{}", value); value.clone() } // Trait bounds on structs struct Wrapper<T: Display> { value: T, } // Complex bounds fn complex_function<T, U>(t: T, u: U) -> String where T: Display + Clone, U: Display + Debug, { format!("{} and {:?}", t.clone(), u) } }
impl Trait Shorthand
Rust provides a shorthand for trait bounds in both argument and return position:
use std::fmt::Display; // Instead of: fn print_it<T: Display>(value: T) fn print_it(value: impl Display) { println!("{}", value); } // Return position: caller doesn't know the concrete type fn make_greeting(name: &str) -> impl Display { format!("Hello, {}!", name) } fn main() { print_it(42); print_it("hello"); println!("{}", make_greeting("world")); }
Argument position (value: impl Display) is syntactic sugar for <T: Display>. Return position (-> impl Display) means “I return some type that implements Display, but I’m not telling you which one.” This is commonly used to return closures or complex iterator chains without spelling out the full type.
Where Clauses
Where clauses make complex bounds more readable:
#![allow(unused)] fn main() { use std::fmt::Debug; // Instead of this... fn ugly<T: Display + Clone, U: Debug + Display>(t: T, u: U) { // ... } // Write this... fn pretty<T, U>(t: T, u: U) where T: Display + Clone, U: Debug + Display, { // Much cleaner! } // Particularly useful with associated types fn process<I>(iter: I) where I: Iterator, I::Item: Display, { for item in iter { println!("{}", item); } } }
Generic Enums
The most common generic enums you’ll use:
#![allow(unused)] fn main() { // Option<T> - Rust's null replacement enum Option<T> { Some(T), None, } // Result<T, E> - For error handling enum Result<T, E> { Ok(T), Err(E), } // Custom generic enum enum BinaryTree<T> { Empty, Node { value: T, left: Box<BinaryTree<T>>, right: Box<BinaryTree<T>>, }, } impl<T> BinaryTree<T> { fn new() -> Self { BinaryTree::Empty } fn insert(&mut self, value: T) where T: Ord, { // Implementation here } } }
Const Generics
Const generics allow you to parameterize types with constant values:
// Array wrapper with compile-time size struct ArrayWrapper<T, const N: usize> { data: [T; N], } impl<T, const N: usize> ArrayWrapper<T, N> { fn new(value: T) -> Self where T: Copy, { ArrayWrapper { data: [value; N], } } } // Matrix type with compile-time dimensions struct Matrix<T, const ROWS: usize, const COLS: usize> { data: [[T; COLS]; ROWS], } fn main() { let arr: ArrayWrapper<i32, 5> = ArrayWrapper::new(0); let matrix: Matrix<f64, 3, 4> = Matrix { data: [[0.0; 4]; 3], }; }
Type Aliases and Newtype Pattern
// Type alias - just a synonym type Kilometers = i32; type Result<T> = std::result::Result<T, std::io::Error>; // Newtype pattern - creates a distinct type struct Meters(f64); struct Seconds(f64); impl Meters { fn to_feet(&self) -> f64 { self.0 * 3.28084 } } // Prevents mixing units fn calculate_speed(distance: Meters, time: Seconds) -> f64 { distance.0 / time.0 } fn main() { let distance = Meters(100.0); let time = Seconds(9.58); // Type safety prevents this: // let wrong = calculate_speed(time, distance); // Error! let speed = calculate_speed(distance, time); println!("Speed: {} m/s", speed); }
Phantom Types
Phantom types provide compile-time guarantees without runtime cost:
use std::marker::PhantomData; // States for a type-safe builder struct Locked; struct Unlocked; struct Door<State> { name: String, _state: PhantomData<State>, } impl Door<Locked> { fn new(name: String) -> Self { Door { name, _state: PhantomData, } } fn unlock(self) -> Door<Unlocked> { Door { name: self.name, _state: PhantomData, } } } impl Door<Unlocked> { fn open(&self) { println!("Opening door: {}", self.name); } fn lock(self) -> Door<Locked> { Door { name: self.name, _state: PhantomData, } } } fn main() { let door = Door::<Locked>::new("Front".to_string()); // door.open(); // Error: method not found let door = door.unlock(); door.open(); // OK }
Advanced Pattern: Type-Driven Design
#![allow(unused)] fn main() { // Email validation at compile time struct Unvalidated; struct Validated; struct Email<State = Unvalidated> { value: String, _state: PhantomData<State>, } impl Email<Unvalidated> { fn new(value: String) -> Self { Email { value, _state: PhantomData, } } fn validate(self) -> Result<Email<Validated>, String> { if self.value.contains('@') { Ok(Email { value: self.value, _state: PhantomData, }) } else { Err("Invalid email".to_string()) } } } impl Email<Validated> { fn send(&self) { println!("Sending email to: {}", self.value); } } // Function that only accepts validated emails fn send_newsletter(email: &Email<Validated>) { email.send(); } }
Common Pitfalls
1. Over-constraining Generics
#![allow(unused)] fn main() { // Bad: unnecessary Clone bound fn bad<T: Clone + Display>(value: &T) { println!("{}", value); // Clone not needed! } // Good: only required bounds fn good<T: Display>(value: &T) { println!("{}", value); } }
2. Missing Lifetime Parameters
#![allow(unused)] fn main() { // Won't compile // struct RefHolder<T> { // value: &T, // } // Correct struct RefHolder<'a, T> { value: &'a T, } }
3. Monomorphization Bloat
#![allow(unused)] fn main() { // Each T creates a new function copy fn generic<T>(value: T) -> T { value } // Consider using trait objects for large functions fn with_trait_object(value: &dyn Display) { println!("{}", value); } }
Exercise: Generic Priority Queue with Constraints
Create a priority queue system that demonstrates multiple generic programming concepts:
use std::fmt::{Debug, Display}; use std::cmp::Ord; use std::marker::PhantomData; // Part 1: Basic generic queue with trait bounds #[derive(Debug)] struct PriorityQueue<T> where T: Ord + Debug, { items: Vec<T>, } impl<T> PriorityQueue<T> where T: Ord + Debug, { fn new() -> Self { // TODO: Create a new empty priority queue todo!() } fn enqueue(&mut self, item: T) { // TODO: Add item and maintain sorted order (highest priority first) // Hint: Use Vec::push() then Vec::sort() todo!() } fn dequeue(&mut self) -> Option<T> { // TODO: Remove and return the highest priority item // Hint: Use Vec::pop() since we keep items sorted todo!() } fn peek(&self) -> Option<&T> { // TODO: Return reference to highest priority item without removing it todo!() } fn len(&self) -> usize { self.items.len() } fn is_empty(&self) -> bool { self.items.is_empty() } } // Part 2: Generic trait for items that can be prioritized trait Prioritized { type Priority: Ord; fn priority(&self) -> Self::Priority; } // Part 3: Advanced queue that works with any Prioritized type struct AdvancedQueue<T> where T: Prioritized + Debug, { items: Vec<T>, } impl<T> AdvancedQueue<T> where T: Prioritized + Debug, { fn new() -> Self { AdvancedQueue { items: Vec::new() } } fn enqueue(&mut self, item: T) { // TODO: Insert item in correct position based on priority // Use binary search for efficient insertion todo!() } fn dequeue(&mut self) -> Option<T> { // TODO: Remove highest priority item todo!() } } // Part 4: Example types implementing Prioritized #[derive(Debug, Eq, PartialEq)] struct Task { name: String, urgency: u32, } impl Prioritized for Task { type Priority = u32; fn priority(&self) -> Self::Priority { // TODO: Return the urgency level todo!() } } impl Ord for Task { fn cmp(&self, other: &Self) -> std::cmp::Ordering { // TODO: Compare based on urgency (higher urgency = higher priority) todo!() } } impl PartialOrd for Task { fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { Some(self.cmp(other)) } } // Part 5: Generic function with multiple trait bounds fn process_queue<T, Q>(queue: &mut Q, max_items: usize) -> Vec<T> where T: Debug + Clone, Q: QueueOperations<T>, { // TODO: Process up to max_items from the queue // Return a vector of processed items todo!() } // Part 6: Trait for queue operations (demonstrates trait design) trait QueueOperations<T> { fn enqueue(&mut self, item: T); fn dequeue(&mut self) -> Option<T>; fn len(&self) -> usize; } // TODO: Implement QueueOperations for PriorityQueue<T> fn main() { // Test basic priority queue with numbers let mut num_queue = PriorityQueue::new(); num_queue.enqueue(5); num_queue.enqueue(1); num_queue.enqueue(10); num_queue.enqueue(3); println!("Number queue:"); while let Some(num) = num_queue.dequeue() { println!("Processing: {}", num); } // Test with custom Task type let mut task_queue = PriorityQueue::new(); task_queue.enqueue(Task { name: "Low".to_string(), urgency: 1 }); task_queue.enqueue(Task { name: "High".to_string(), urgency: 5 }); task_queue.enqueue(Task { name: "Medium".to_string(), urgency: 3 }); println!("\nTask queue:"); while let Some(task) = task_queue.dequeue() { println!("Processing: {:?}", task); } // Test advanced queue with Prioritized trait let mut advanced_queue = AdvancedQueue::new(); advanced_queue.enqueue(Task { name: "First".to_string(), urgency: 2 }); advanced_queue.enqueue(Task { name: "Second".to_string(), urgency: 4 }); println!("\nAdvanced queue:"); while let Some(task) = advanced_queue.dequeue() { println!("Processing: {:?}", task); } }
Implementation Guidelines:
-
PriorityQueue methods:
new(): ReturnPriorityQueue { items: Vec::new() }enqueue(): Push item then sort withself.items.sort()dequeue(): Useself.items.pop()(gets highest after sorting)peek(): Useself.items.last()
-
Task::priority():
- Return
self.urgency
- Return
-
Task::cmp():
- Use
self.urgency.cmp(&other.urgency)
- Use
-
AdvancedQueue::enqueue():
- Use
binary_search_by_key()to find insertion point - Use
insert()to maintain sorted order
- Use
-
QueueOperations trait implementation:
- Implement for
PriorityQueue<T>by delegating to existing methods
- Implement for
What this exercise teaches:
- Trait bounds (
Ord + Debug) restrict generic types - Associated types in traits (
Priority) - Complex where clauses for readable constraints
- Generic trait implementation with multiple bounds
- Real-world generic patterns beyond simple containers
- Trait design for abstraction over different implementations
Key Takeaways
✅ Generics provide type safety without code duplication - Write once, use with many types
✅ Trait bounds specify required functionality - More explicit than C++ templates
✅ Monomorphization means zero runtime cost - Like C++ templates, unlike .NET generics
✅ Const generics enable compile-time computations - Arrays and matrices with known sizes
✅ Phantom types provide compile-time guarantees - State machines in the type system
✅ Type-driven design prevents bugs at compile time - Invalid states are unrepresentable
Next: Chapter 9: Enums & Pattern Matching
Chapter 9: Pattern Matching - Exhaustive Control Flow
Advanced Pattern Matching, Option/Result Handling, and Match Guards
Learning Objectives
By the end of this chapter, you’ll be able to:
- Use exhaustive pattern matching to handle all possible cases
- Apply advanced patterns with destructuring and guards
- Handle Option and Result types idiomatically
- Use if let, while let for conditional pattern matching
- Understand when to use match vs if let vs pattern matching in function parameters
- Write robust error handling with pattern matching
- Apply match guards for complex conditional logic
Pattern Matching vs Switch Statements
Comparison with Other Languages
| Feature | C/C++ switch | C# switch | Rust match |
|---|---|---|---|
| Exhaustiveness | No | Partial (warnings) | Yes (enforced) |
| Complex patterns | No | Limited | Full destructuring |
| Guards | No | Limited (when) | Yes |
| Return values | No | Expression (C# 8+) | Always expression |
| Fall-through | Default (dangerous) | No | Not possible |
Basic Match Expression
#![allow(unused)] fn main() { enum TrafficLight { Red, Yellow, Green, } enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(u8, u8, u8), } fn handle_traffic_light(light: TrafficLight) -> &'static str { match light { TrafficLight::Red => "Stop", TrafficLight::Yellow => "Prepare to stop", TrafficLight::Green => "Go", // Compiler ensures all variants are handled! } } fn handle_message(msg: Message) { match msg { Message::Quit => { println!("Quit message received"); std::process::exit(0); }, Message::Move { x, y } => { println!("Move to coordinates: ({}, {})", x, y); }, Message::Write(text) => { println!("Text message: {}", text); }, Message::ChangeColor(r, g, b) => { println!("Change color to RGB({}, {}, {})", r, g, b); }, } } }
Option and Result Pattern Matching
Handling Option
#![allow(unused)] fn main() { fn divide(x: f64, y: f64) -> Option<f64> { if y != 0.0 { Some(x / y) } else { None } } fn process_division(x: f64, y: f64) { match divide(x, y) { Some(result) => println!("Result: {}", result), None => println!("Cannot divide by zero"), } } // Nested Option handling fn parse_config(input: Option<&str>) -> Option<u32> { match input { Some(s) => match s.parse::<u32>() { Ok(num) => Some(num), Err(_) => None, }, None => None, } } // Better with combinators (covered later) fn parse_config_better(input: Option<&str>) -> Option<u32> { input?.parse().ok() } }
Handling Result<T, E>
#![allow(unused)] fn main() { use std::fs::File; use std::io::{self, Read}; fn read_file_contents(filename: &str) -> Result<String, io::Error> { let mut file = File::open(filename)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; Ok(contents) } fn process_file(filename: &str) { match read_file_contents(filename) { Ok(contents) => { println!("File contents ({} bytes):", contents.len()); println!("{}", contents); }, Err(error) => { match error.kind() { io::ErrorKind::NotFound => { println!("File '{}' not found", filename); }, io::ErrorKind::PermissionDenied => { println!("Permission denied for file '{}'", filename); }, _ => { println!("Error reading file '{}': {}", filename, error); }, } } } } // Custom error types #[derive(Debug)] enum ConfigError { MissingFile, ParseError(String), ValidationError(String), } fn load_config(path: &str) -> Result<Config, ConfigError> { let contents = std::fs::read_to_string(path) .map_err(|_| ConfigError::MissingFile)?; let config: Config = serde_json::from_str(&contents) .map_err(|e| ConfigError::ParseError(e.to_string()))?; validate_config(&config) .map_err(|msg| ConfigError::ValidationError(msg))?; Ok(config) } #[derive(Debug)] struct Config { port: u16, host: String, } fn validate_config(config: &Config) -> Result<(), String> { if config.port == 0 { return Err("Port cannot be zero".to_string()); } if config.host.is_empty() { return Err("Host cannot be empty".to_string()); } Ok(()) } }
Advanced Patterns
Destructuring and Nested Patterns
#![allow(unused)] fn main() { struct Point { x: i32, y: i32, } enum Shape { Circle { center: Point, radius: f64 }, Rectangle { top_left: Point, bottom_right: Point }, Triangle(Point, Point, Point), } fn analyze_shape(shape: &Shape) { match shape { // Destructure nested structures Shape::Circle { center: Point { x, y }, radius } => { println!("Circle at ({}, {}) with radius {}", x, y, radius); }, // Partial destructuring with .. Shape::Rectangle { top_left: Point { x: x1, y: y1 }, .. } => { println!("Rectangle starting at ({}, {})", x1, y1); }, // Destructure tuple variants Shape::Triangle(p1, p2, p3) => { println!("Triangle with vertices: ({}, {}), ({}, {}), ({}, {})", p1.x, p1.y, p2.x, p2.y, p3.x, p3.y); }, } } // Pattern matching with references and dereferencing fn process_optional_point(point: &Option<Point>) { match point { Some(Point { x, y }) => println!("Point at ({}, {})", x, y), None => println!("No point"), } } // Multiple patterns fn classify_number(n: i32) -> &'static str { match n { 1 | 2 | 3 => "small", 4..=10 => "medium", 11..=100 => "large", _ => "very large", } } // Binding values in patterns fn process_message_advanced(msg: Message) { match msg { Message::Move { x: 0, y } => { println!("Move vertically to y: {}", y); }, Message::Move { x, y: 0 } => { println!("Move horizontally to x: {}", x); }, Message::Move { x, y } if x == y => { println!("Move diagonally to ({}, {})", x, y); }, Message::Move { x, y } => { println!("Move to ({}, {})", x, y); }, msg @ Message::Write(_) => { println!("Received write message: {:?}", msg); }, _ => println!("Other message"), } } }
Match Guards
#![allow(unused)] fn main() { fn categorize_temperature(temp: f64, is_celsius: bool) -> &'static str { match temp { t if is_celsius && t < 0.0 => "freezing (Celsius)", t if is_celsius && t > 100.0 => "boiling (Celsius)", t if !is_celsius && t < 32.0 => "freezing (Fahrenheit)", t if !is_celsius && t > 212.0 => "boiling (Fahrenheit)", t if t > 0.0 => "positive temperature", 0.0 => "exactly zero", _ => "negative temperature", } } // Complex guards with destructuring #[derive(Debug)] enum Request { Get { path: String, authenticated: bool }, Post { path: String, data: Vec<u8> }, } fn handle_request(req: Request) -> &'static str { match req { Request::Get { path, authenticated: true } if path.starts_with("/admin") => { "Admin access granted" }, Request::Get { path, authenticated: false } if path.starts_with("/admin") => { "Admin access denied" }, Request::Get { .. } => "Regular GET request", Request::Post { data, .. } if data.len() > 1024 => { "Large POST request" }, Request::Post { .. } => "Regular POST request", } } }
if let and while let
if let for Simple Cases
#![allow(unused)] fn main() { // Instead of verbose match fn process_option_verbose(opt: Option<i32>) { match opt { Some(value) => println!("Got value: {}", value), None => {}, // Do nothing } } // Use if let for cleaner code fn process_option_clean(opt: Option<i32>) { if let Some(value) = opt { println!("Got value: {}", value); } } // if let with else fn process_result(result: Result<String, &str>) { if let Ok(value) = result { println!("Success: {}", value); } else { println!("Something went wrong"); } } // Chaining if let fn process_nested(opt: Option<Result<i32, &str>>) { if let Some(result) = opt { if let Ok(value) = result { println!("Got nested value: {}", value); } } } }
while let for Loops
#![allow(unused)] fn main() { fn process_iterator() { let mut stack = vec![1, 2, 3, 4, 5]; // Pop elements while they exist while let Some(value) = stack.pop() { println!("Processing: {}", value); } } fn process_lines() { use std::io::{self, BufRead}; let stdin = io::stdin(); let mut lines = stdin.lock().lines(); // Process lines until EOF or error while let Ok(line) = lines.next().unwrap_or(Err(io::Error::new( io::ErrorKind::UnexpectedEof, "EOF" ))) { if line.trim() == "quit" { break; } println!("You entered: {}", line); } } }
Pattern Matching in Function Parameters
Destructuring in Parameters
// Destructure tuples in parameters fn print_coordinates((x, y): (i32, i32)) { println!("Coordinates: ({}, {})", x, y); } // Destructure structs fn print_point(Point { x, y }: Point) { println!("Point: ({}, {})", x, y); } // Destructure with references fn analyze_point_ref(&Point { x, y }: &Point) { println!("Analyzing point at ({}, {})", x, y); } // Closure patterns fn main() { let points = vec![ Point { x: 1, y: 2 }, Point { x: 3, y: 4 }, Point { x: 5, y: 6 }, ]; // Destructure in closure parameters points.iter().for_each(|&Point { x, y }| { println!("Point: ({}, {})", x, y); }); // Filter with pattern matching let origin_points: Vec<_> = points .into_iter() .filter(|Point { x: 0, y: 0 }| true) // Only points at origin .collect(); }
Common Pitfalls and Best Practices
Pitfall 1: Incomplete Patterns
#![allow(unused)] fn main() { // BAD: This won't compile - missing Some case fn bad_option_handling(opt: Option<i32>) { match opt { None => println!("Nothing"), // Error: non-exhaustive patterns } } // GOOD: Handle all cases fn good_option_handling(opt: Option<i32>) { match opt { Some(val) => println!("Value: {}", val), None => println!("Nothing"), } } }
Pitfall 2: Unreachable Patterns
#![allow(unused)] fn main() { // BAD: Unreachable pattern fn bad_range_matching(n: i32) { match n { 1..=10 => println!("Small"), 5 => println!("Five"), // This is unreachable! _ => println!("Other"), } } // GOOD: More specific patterns first fn good_range_matching(n: i32) { match n { 5 => println!("Five"), 1..=10 => println!("Small (not five)"), _ => println!("Other"), } } }
Best Practices
#![allow(unused)] fn main() { // 1. Use @ binding to capture while pattern matching fn handle_special_ranges(value: i32) { match value { n @ 1..=5 => println!("Small number: {}", n), n @ 6..=10 => println!("Medium number: {}", n), n => println!("Large number: {}", n), } } // 2. Use .. to ignore fields you don't need struct LargeStruct { important: i32, flag: bool, data1: String, data2: String, data3: Vec<u8>, } fn process_large_struct(s: LargeStruct) { match s { LargeStruct { important, flag: true, .. } => { println!("Important value with flag: {}", important); }, LargeStruct { important, .. } => { println!("Important value without flag: {}", important); }, } } // 3. Prefer early returns with guards fn validate_user_input(input: &str) -> Result<i32, &'static str> { match input.parse::<i32>() { Ok(n) if n >= 0 => Ok(n), Ok(_) => Err("Number must be non-negative"), Err(_) => Err("Invalid number format"), } } }
Exercise: HTTP Status Handler
Create a function that handles different HTTP status codes using pattern matching:
#![allow(unused)] fn main() { #[derive(Debug)] enum HttpStatus { Ok, // 200 NotFound, // 404 ServerError, // 500 Custom(u16), // Any other code } #[derive(Debug)] struct HttpResponse { status: HttpStatus, body: Option<String>, headers: Vec<(String, String)>, } // TODO: Implement this function fn handle_response(response: HttpResponse) -> String { // Pattern match on the response to return appropriate messages: // - Ok with body: "Success: {body}" // - Ok without body: "Success: No content" // - NotFound: "Error: Resource not found" // - ServerError: "Error: Internal server error" // - Custom(code) where code < 400: "Info: Status {code}" // - Custom(code) where code >= 400: "Error: Status {code}" todo!() } }
Key Takeaways
- Exhaustiveness - Rust’s compiler ensures you handle all possible cases
- Pattern matching is an expression - Every match arm must return the same type
- Use if let for simple Option/Result handling instead of verbose match
- Match guards enable complex conditional logic within patterns
- Destructuring allows you to extract values from complex data structures
- Order matters - More specific patterns should come before general ones
- @ binding lets you capture values while pattern matching
- Early returns with guards can make code more readable
Next Up: In Chapter 10, we’ll explore error handling - Rust’s approach to robust error management with Result types and the ? operator.
Chapter 10: Error Handling - Result, ?, and Custom Errors
Robust Error Management in Rust
Learning Objectives
By the end of this chapter, you’ll be able to:
- Use Result<T, E> for recoverable error handling
- Master the ? operator for error propagation
- Create custom error types with proper error handling
- Understand when to use Result vs panic!
- Work with popular error handling crates (anyhow, thiserror)
- Implement error conversion and chaining
- Handle multiple error types gracefully
Rust’s Error Handling Philosophy
Error Categories
| Type | Examples | Rust Approach |
|---|---|---|
| Recoverable | File not found, network timeout | Result<T, E> |
| Unrecoverable | Array out of bounds, null pointer | panic! |
Comparison with Other Languages
| Language | Approach | Pros | Cons |
|---|---|---|---|
| C++ | Exceptions, error codes | Familiar | Runtime overhead, can be ignored |
| C#/.NET | Exceptions | Clean syntax | Performance cost, hidden control flow |
| Go | Explicit error returns | Explicit, fast | Verbose |
| Rust | Result<T, E> | Explicit, zero-cost | Must be handled |
Result<T, E>: The Foundation
Basic Result Usage
use std::fs::File; use std::io::ErrorKind; fn open_file(filename: &str) -> Result<File, std::io::Error> { File::open(filename) } fn main() { // Pattern matching match open_file("test.txt") { Ok(file) => println!("File opened successfully"), Err(error) => match error.kind() { ErrorKind::NotFound => println!("File not found"), ErrorKind::PermissionDenied => println!("Permission denied"), other_error => println!("Other error: {:?}", other_error), }, } // Using if let if let Ok(file) = open_file("test.txt") { println!("File opened with if let"); } // Unwrap variants (use carefully!) // let file1 = open_file("test.txt").unwrap(); // Panics on error // let file2 = open_file("test.txt").expect("Failed to open"); // Panics with message }
The ? Operator: Error Propagation Made Easy
Basic ? Usage
#![allow(unused)] fn main() { use std::fs::File; use std::io::{self, Read}; // Without ? operator (verbose) fn read_file_old_way(filename: &str) -> Result<String, io::Error> { let mut file = match File::open(filename) { Ok(file) => file, Err(e) => return Err(e), }; let mut contents = String::new(); match file.read_to_string(&mut contents) { Ok(_) => Ok(contents), Err(e) => Err(e), } } // With ? operator (concise) fn read_file_new_way(filename: &str) -> Result<String, io::Error> { let mut file = File::open(filename)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; Ok(contents) } // Even more concise fn read_file_shortest(filename: &str) -> Result<String, io::Error> { std::fs::read_to_string(filename) } }
Custom Error Types
Simple Custom Errors
#![allow(unused)] fn main() { use std::fmt; #[derive(Debug)] enum MathError { DivisionByZero, NegativeSquareRoot, } impl fmt::Display for MathError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { MathError::DivisionByZero => write!(f, "Cannot divide by zero"), MathError::NegativeSquareRoot => write!(f, "Cannot take square root of negative number"), } } } impl std::error::Error for MathError {} fn divide(a: f64, b: f64) -> Result<f64, MathError> { if b == 0.0 { Err(MathError::DivisionByZero) } else { Ok(a / b) } } fn square_root(x: f64) -> Result<f64, MathError> { if x < 0.0 { Err(MathError::NegativeSquareRoot) } else { Ok(x.sqrt()) } } }
Error Conversion and Chaining
The From Trait for Error Conversion
#![allow(unused)] fn main() { use std::fs::File; use std::io; use std::num::ParseIntError; #[derive(Debug)] enum AppError { Io(io::Error), Parse(ParseIntError), Custom(String), } // Automatic conversion from io::Error impl From<io::Error> for AppError { fn from(error: io::Error) -> Self { AppError::Io(error) } } // Automatic conversion from ParseIntError impl From<ParseIntError> for AppError { fn from(error: ParseIntError) -> Self { AppError::Parse(error) } } impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { AppError::Io(e) => write!(f, "IO error: {}", e), AppError::Parse(e) => write!(f, "Parse error: {}", e), AppError::Custom(msg) => write!(f, "Error: {}", msg), } } } impl std::error::Error for AppError {} // Now ? operator works seamlessly fn read_number_from_file(filename: &str) -> Result<i32, AppError> { let contents = std::fs::read_to_string(filename)?; // io::Error -> AppError let number = contents.trim().parse::<i32>()?; // ParseIntError -> AppError if number < 0 { return Err(AppError::Custom("Number must be positive".to_string())); } Ok(number) } }
Chaining Multiple Operations
#![allow(unused)] fn main() { use std::path::Path; fn process_config_file(path: &Path) -> Result<Config, AppError> { std::fs::read_to_string(path)? .lines() .map(|line| line.trim()) .filter(|line| !line.is_empty() && !line.starts_with('#')) .map(|line| parse_config_line(line)) .collect::<Result<Vec<_>, _>>()? .into_iter() .fold(Config::default(), |mut cfg, (key, value)| { cfg.set(&key, value); cfg }) .validate() .map_err(|e| AppError::Custom(e)) } struct Config { settings: HashMap<String, String>, } impl Config { fn default() -> Self { Config { settings: HashMap::new() } } fn set(&mut self, key: &str, value: String) { self.settings.insert(key.to_string(), value); } fn validate(self) -> Result<Config, String> { if self.settings.is_empty() { Err("Configuration is empty".to_string()) } else { Ok(self) } } } fn parse_config_line(line: &str) -> Result<(String, String), AppError> { let parts: Vec<&str> = line.splitn(2, '=').collect(); if parts.len() != 2 { return Err(AppError::Custom(format!("Invalid config line: {}", line))); } Ok((parts[0].to_string(), parts[1].to_string())) } }
Working with External Error Libraries
Using anyhow for Applications
use anyhow::{Context, Result, bail}; // anyhow::Result is Result<T, anyhow::Error> fn load_config(path: &str) -> Result<Config> { let contents = std::fs::read_to_string(path) .context("Failed to read config file")?; let config: Config = serde_json::from_str(&contents) .context("Failed to parse JSON config")?; if config.port == 0 { bail!("Invalid port: 0"); } Ok(config) } fn main() -> Result<()> { let config = load_config("app.json")?; // Chain multiple operations with context let server = create_server(&config) .context("Failed to create server")?; server.run() .context("Server failed during execution")?; Ok(()) }
Using thiserror for Libraries
#![allow(unused)] fn main() { use thiserror::Error; #[derive(Error, Debug)] enum DataStoreError { #[error("data not found")] NotFound, #[error("permission denied: {0}")] PermissionDenied(String), #[error("invalid input: {msg}")] InvalidInput { msg: String }, #[error("database error")] Database(#[from] sqlx::Error), #[error("serialization error")] Serialization(#[from] serde_json::Error), #[error(transparent)] Other(#[from] anyhow::Error), } // Use in library code fn get_user(id: u64) -> Result<User, DataStoreError> { if id == 0 { return Err(DataStoreError::InvalidInput { msg: "ID cannot be 0".to_string() }); } let user = db::query_user(id)?; // Automatic conversion from sqlx::Error Ok(user) } }
Error Handling Patterns
Early Returns with ?
#![allow(unused)] fn main() { fn process_data(input: &str) -> Result<String, Box<dyn std::error::Error>> { let parsed = parse_input(input)?; let validated = validate(parsed)?; let processed = transform(validated)?; Ok(format_output(processed)) } // Compare with nested match statements (avoid this!) fn process_data_verbose(input: &str) -> Result<String, Box<dyn std::error::Error>> { match parse_input(input) { Ok(parsed) => { match validate(parsed) { Ok(validated) => { match transform(validated) { Ok(processed) => Ok(format_output(processed)), Err(e) => Err(e.into()), } }, Err(e) => Err(e.into()), } }, Err(e) => Err(e.into()), } } }
Collecting Results
#![allow(unused)] fn main() { fn process_files(paths: &[&str]) -> Result<Vec<String>, io::Error> { paths.iter() .map(|path| std::fs::read_to_string(path)) .collect::<Result<Vec<_>, _>>() } // Handle partial success fn process_files_partial(paths: &[&str]) -> (Vec<String>, Vec<io::Error>) { let results: Vec<Result<String, io::Error>> = paths.iter() .map(|path| std::fs::read_to_string(path)) .collect(); let mut successes = Vec::new(); let mut failures = Vec::new(); for result in results { match result { Ok(content) => successes.push(content), Err(e) => failures.push(e), } } (successes, failures) } }
Testing Error Cases
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_division_by_zero() { let result = divide(10.0, 0.0); assert!(result.is_err()); // matches! is cleaner than match + panic! for variant checks assert!(matches!(result, Err(MathError::DivisionByZero))); } #[test] fn test_file_not_found() { let result = read_file_contents("nonexistent.txt"); assert!(result.is_err()); } #[test] #[should_panic(expected = "assertion failed")] fn test_panic_condition() { assert!(false, "assertion failed"); } } }
Exercise: Build a Configuration Parser
Create a robust configuration parser with proper error handling:
use std::collections::HashMap; use std::fs; use std::path::Path; #[derive(Debug)] enum ConfigError { IoError(std::io::Error), ParseError(String), ValidationError(String), } // TODO: Implement Display and Error traits for ConfigError // TODO: Implement From<std::io::Error> for automatic conversion struct Config { settings: HashMap<String, String>, } impl Config { fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> { // TODO: Read file, parse lines, handle comments (#) // TODO: Parse key=value pairs // TODO: Validate required keys exist todo!() } fn get(&self, key: &str) -> Option<&String> { self.settings.get(key) } fn get_required(&self, key: &str) -> Result<&String, ConfigError> { // TODO: Return error if key doesn't exist todo!() } fn get_int(&self, key: &str) -> Result<i32, ConfigError> { // TODO: Get value and parse as integer todo!() } } fn main() -> Result<(), ConfigError> { let config = Config::from_file("app.conf")?; let port = config.get_int("port")?; let host = config.get_required("host")?; println!("Starting server on {}:{}", host, port); Ok(()) }
Key Takeaways
- Use Result<T, E> for recoverable errors, panic! for unrecoverable ones
- The ? operator makes error propagation clean and efficient
- Custom error types should implement Display and Error traits
- Error conversion with From trait enables seamless ? usage
- anyhow is great for applications, thiserror for libraries
- Chain operations with Result for clean error handling
- Test error cases as thoroughly as success cases
- Collect multiple errors when appropriate instead of failing fast
Next Up: In Chapter 11, we’ll explore iterators and closures - Rust’s functional programming features that make data processing both efficient and expressive.
Chapter 11: Iterators and Functional Programming
Efficient Data Processing with Rust’s Iterator Pattern
Learning Objectives
By the end of this chapter, you’ll be able to:
- Use iterator adaptors like map, filter, fold effectively
- Understand lazy evaluation and its performance benefits
- Write closures with proper capture semantics
- Choose between loops and iterator chains
- Convert between collections using collect()
- Handle iterator errors gracefully
The Iterator Trait
#![allow(unused)] fn main() { trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; // 70+ provided methods like map, filter, fold, etc. } }
Key Concepts
- Lazy evaluation: Operations don’t execute until consumed
- Zero-cost abstraction: Compiles to same code as hand-written loops
- Composable: Chain multiple operations cleanly
Creating Iterators
#![allow(unused)] fn main() { fn iterator_sources() { // From collections let vec = vec![1, 2, 3]; vec.iter(); // &T - borrows vec.into_iter(); // T - takes ownership vec.iter_mut(); // &mut T - mutable borrow // From ranges (0..10) // 0 to 9 (0..=10) // 0 to 10 inclusive // Infinite iterators std::iter::repeat(5) // 5, 5, 5, ... (0..).step_by(2) // 0, 2, 4, 6, ... // From functions std::iter::from_fn(|| Some(rand::random::<u32>())) } }
Essential Iterator Adaptors
Transform: map, flat_map
#![allow(unused)] fn main() { fn transformations() { let numbers = vec![1, 2, 3, 4]; // Simple transformation let doubled: Vec<i32> = numbers.iter() .map(|x| x * 2) .collect(); // [2, 4, 6, 8] // Parse strings to numbers, handling errors let strings = vec!["1", "2", "3"]; let parsed: Result<Vec<i32>, _> = strings .iter() .map(|s| s.parse::<i32>()) .collect(); // Collects into Result<Vec<_>, _> // Flatten nested structures let nested = vec![vec![1, 2], vec![3, 4]]; let flat: Vec<i32> = nested .into_iter() .flat_map(|v| v.into_iter()) .collect(); // [1, 2, 3, 4] } }
Filter and Search
#![allow(unused)] fn main() { fn filtering() { let numbers = vec![1, 2, 3, 4, 5, 6]; // Keep only even numbers let evens: Vec<_> = numbers.iter() .filter(|&&x| x % 2 == 0) .cloned() .collect(); // [2, 4, 6] // Find first match let first_even = numbers.iter() .find(|&&x| x % 2 == 0); // Some(&2) // Check conditions let all_positive = numbers.iter().all(|&x| x > 0); // true let has_seven = numbers.iter().any(|&x| x == 7); // false // Position of element let pos = numbers.iter().position(|&x| x == 4); // Some(3) } }
Reduce: fold, reduce, sum
#![allow(unused)] fn main() { fn reductions() { let numbers = vec![1, 2, 3, 4, 5]; // Sum all elements let sum: i32 = numbers.iter().sum(); // 15 // Product of all elements let product: i32 = numbers.iter().product(); // 120 // Custom reduction with fold let result = numbers.iter() .fold(0, |acc, x| acc + x * x); // Sum of squares: 55 // Build a string let words = vec!["Hello", "World"]; let sentence = words.iter() .fold(String::new(), |mut acc, word| { if !acc.is_empty() { acc.push(' '); } acc.push_str(word); acc }); // "Hello World" } }
Take and Skip
#![allow(unused)] fn main() { fn slicing_iterators() { let numbers = 0..100; // Take first n elements let first_five: Vec<_> = numbers.clone() .take(5) .collect(); // [0, 1, 2, 3, 4] // Skip first n elements let after_ten: Vec<_> = numbers.clone() .skip(10) .take(5) .collect(); // [10, 11, 12, 13, 14] // Take while condition is true let until_ten: Vec<_> = numbers.clone() .take_while(|&x| x < 10) .collect(); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] } }
Closures: Anonymous Functions
Closure Syntax and Captures
#![allow(unused)] fn main() { fn closure_basics() { let x = 10; // Closure that borrows let add_x = |y| x + y; println!("{}", add_x(5)); // 15 // Closure that mutates let mut count = 0; let mut increment = || { count += 1; count }; println!("{}", increment()); // 1 println!("{}", increment()); // 2 // Move closure - takes ownership let message = String::from("Hello"); let print_message = move || println!("{}", message); print_message(); // message is no longer accessible here } }
Fn, FnMut, FnOnce Traits
#![allow(unused)] fn main() { // Fn: Can be called multiple times, borrows values fn apply_twice<F>(f: F) -> i32 where F: Fn(i32) -> i32 { f(f(5)) } // FnMut: Can be called multiple times, mutates values fn apply_mut<F>(mut f: F) where F: FnMut() { f(); f(); } // FnOnce: Can only be called once, consumes values fn apply_once<F>(f: F) where F: FnOnce() { f(); // f(); // Error: f was consumed } }
Common Patterns
Processing Collections
#![allow(unused)] fn main() { use std::collections::HashMap; fn collection_processing() { let text = "hello world hello rust"; // Word frequency counter let word_counts: HashMap<&str, usize> = text .split_whitespace() .fold(HashMap::new(), |mut map, word| { *map.entry(word).or_insert(0) += 1; map }); // Find most common word let most_common = word_counts .iter() .max_by_key(|(_, &count)| count) .map(|(&word, _)| word); println!("Most common: {:?}", most_common); // Some("hello") } }
Error Handling with Iterators
#![allow(unused)] fn main() { fn parse_numbers(input: &[&str]) -> Result<Vec<i32>, std::num::ParseIntError> { input.iter() .map(|s| s.parse::<i32>()) .collect() // Collects into Result<Vec<_>, _> } fn process_files(paths: &[&str]) -> Vec<Result<String, std::io::Error>> { paths.iter() .map(|path| std::fs::read_to_string(path)) .collect() // Collects all results, both Ok and Err } // Partition successes and failures fn partition_results<T, E>(results: Vec<Result<T, E>>) -> (Vec<T>, Vec<E>) { let (oks, errs): (Vec<_>, Vec<_>) = results .into_iter() .partition(|r| r.is_ok()); let values = oks.into_iter().map(|r| r.unwrap()).collect(); let errors = errs.into_iter().map(|r| r.unwrap_err()).collect(); (values, errors) } }
Infinite Iterators and Lazy Evaluation
#![allow(unused)] fn main() { fn lazy_evaluation() { // Generate Fibonacci numbers lazily let mut fib = (0u64, 1u64); let fibonacci = std::iter::from_fn(move || { let next = fib.0; fib = (fib.1, fib.0 + fib.1); Some(next) }); // Take only what we need let first_10: Vec<_> = fibonacci .take(10) .collect(); println!("First 10 Fibonacci: {:?}", first_10); // Find first Fibonacci > 1000 let mut fib2 = (0u64, 1u64); let first_large = std::iter::from_fn(move || { let next = fib2.0; fib2 = (fib2.1, fib2.0 + fib2.1); Some(next) }) .find(|&n| n > 1000); println!("First > 1000: {:?}", first_large); } }
Performance: Iterators vs Loops
#![allow(unused)] fn main() { // These compile to identical machine code! fn sum_squares_loop(nums: &[i32]) -> i32 { let mut sum = 0; for &n in nums { sum += n * n; } sum } fn sum_squares_iter(nums: &[i32]) -> i32 { nums.iter() .map(|&n| n * n) .sum() } // Iterator version is: // - More concise // - Harder to introduce bugs // - Easier to modify (add filter, take, etc.) // - Same performance! }
Exercise: Data Pipeline
Build a log analysis pipeline using iterators:
#[derive(Debug)] struct LogEntry { timestamp: u64, level: LogLevel, message: String, } #[derive(Debug, PartialEq)] enum LogLevel { Debug, Info, Warning, Error, } impl LogEntry { fn parse(line: &str) -> Option<LogEntry> { // Format: "timestamp|level|message" let parts: Vec<&str> = line.split('|').collect(); if parts.len() != 3 { return None; } let timestamp = parts[0].parse().ok()?; let level = match parts[1] { "DEBUG" => LogLevel::Debug, "INFO" => LogLevel::Info, "WARNING" => LogLevel::Warning, "ERROR" => LogLevel::Error, _ => return None, }; Some(LogEntry { timestamp, level, message: parts[2].to_string(), }) } } struct LogAnalyzer<'a> { lines: &'a [String], } impl<'a> LogAnalyzer<'a> { fn new(lines: &'a [String]) -> Self { LogAnalyzer { lines } } fn parse_entries(&self) -> impl Iterator<Item = LogEntry> + '_ { // TODO: Parse lines into LogEntry, skip invalid lines self.lines.iter() .filter_map(|line| LogEntry::parse(line)) } fn errors_only(&self) -> impl Iterator<Item = LogEntry> + '_ { // TODO: Return only ERROR level entries todo!() } fn in_time_range(&self, start: u64, end: u64) -> impl Iterator<Item = LogEntry> + '_ { // TODO: Return entries within time range todo!() } fn count_by_level(&self) -> HashMap<LogLevel, usize> { // TODO: Count entries by log level todo!() } fn most_recent(&self, n: usize) -> Vec<LogEntry> { // TODO: Return n most recent entries (highest timestamps) todo!() } } fn main() { let log_lines = vec![ "1000|INFO|Server started".to_string(), "1001|DEBUG|Connection received".to_string(), "1002|ERROR|Failed to connect to database".to_string(), "invalid line".to_string(), "1003|WARNING|High memory usage".to_string(), "1004|INFO|Request processed".to_string(), "1005|ERROR|Timeout error".to_string(), ]; let analyzer = LogAnalyzer::new(&log_lines); // Test the methods println!("Valid entries: {}", analyzer.parse_entries().count()); println!("Errors: {:?}", analyzer.errors_only().collect::<Vec<_>>()); println!("Count by level: {:?}", analyzer.count_by_level()); println!("Most recent 3: {:?}", analyzer.most_recent(3)); }
Key Takeaways
- Iterators are lazy - nothing happens until you consume them
- Zero-cost abstraction - same performance as hand-written loops
- Composable - chain operations for clean, readable code
- collect() is powerful - converts to any collection type
- Closures capture environment - be aware of borrowing vs moving
- Error handling - Result<Vec
, E> vs Vec<Result<T, E>> - Prefer iterators over manual loops for clarity and safety
Next Up: In Chapter 12, we’ll explore modules and visibility - essential for organizing larger Rust projects and creating clean APIs.
Chapter 12: Modules and Visibility
Organizing Rust Projects at Scale
Learning Objectives
By the end of this chapter, you’ll be able to:
- Structure Rust projects with modules and submodules
- Control visibility with
puband privacy rules - Use the
usekeyword effectively for imports - Organize code across multiple files
- Design clean module APIs with proper encapsulation
- Apply the module system to build maintainable projects
- Understand path resolution and the module tree
Module Basics
Defining Modules
// Modules can be defined inline mod network { pub fn connect() { println!("Connecting to network..."); } fn internal_function() { // Private by default - not accessible outside this module println!("Internal network operation"); } } mod database { pub struct Connection { // Fields are private by default host: String, port: u16, } impl Connection { // Public constructor pub fn new(host: String, port: u16) -> Self { Connection { host, port } } // Public method pub fn execute(&self, query: &str) { println!("Executing: {}", query); } // Private method fn validate_query(&self, query: &str) -> bool { !query.is_empty() } } } fn main() { network::connect(); // network::internal_function(); // Error: private function let conn = database::Connection::new("localhost".to_string(), 5432); conn.execute("SELECT * FROM users"); // println!("{}", conn.host); // Error: private field }
Module Hierarchy
#![allow(unused)] fn main() { mod front_of_house { pub mod hosting { pub fn add_to_waitlist() { println!("Added to waitlist"); } fn seat_at_table() { println!("Seated at table"); } } mod serving { fn take_order() {} fn serve_order() {} fn take_payment() {} } } // Using paths to access nested modules pub fn eat_at_restaurant() { // Absolute path crate::front_of_house::hosting::add_to_waitlist(); // Relative path front_of_house::hosting::add_to_waitlist(); } }
The use Keyword
Basic Imports
mod math { pub fn add(a: i32, b: i32) -> i32 { a + b } pub fn multiply(a: i32, b: i32) -> i32 { a * b } pub mod advanced { pub fn power(base: i32, exp: u32) -> i32 { base.pow(exp) } } } // Bring functions into scope use math::add; use math::multiply; use math::advanced::power; // Group imports use math::{add, multiply}; // Import everything from a module use math::advanced::*; fn main() { let sum = add(2, 3); // No need for math:: prefix let product = multiply(4, 5); let result = power(2, 10); }
Re-exporting with pub use
#![allow(unused)] fn main() { mod shapes { pub mod circle { pub struct Circle { pub radius: f64, } impl Circle { pub fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } } } pub mod rectangle { pub struct Rectangle { pub width: f64, pub height: f64, } impl Rectangle { pub fn area(&self) -> f64 { self.width * self.height } } } } // Re-export to flatten the hierarchy pub use shapes::circle::Circle; pub use shapes::rectangle::Rectangle; // Now users can do: // use your_crate::{Circle, Rectangle}; // Instead of: // use your_crate::shapes::circle::Circle; }
File-based Modules
Project Structure
src/
├── main.rs
├── lib.rs
├── network/
│ ├── mod.rs
│ ├── client.rs
│ └── server.rs
└── utils.rs
Main Module File (src/main.rs or src/lib.rs)
#![allow(unused)] fn main() { // src/lib.rs pub mod network; // Looks for network/mod.rs or network.rs pub mod utils; // Looks for utils.rs // Re-export commonly used items pub use network::client::Client; pub use network::server::Server; }
Module Directory (src/network/mod.rs)
#![allow(unused)] fn main() { // src/network/mod.rs pub mod client; pub mod server; // Common network functionality pub struct Config { pub timeout: u64, pub retry_count: u32, } impl Config { pub fn default() -> Self { Config { timeout: 30, retry_count: 3, } } } }
Submodule Files
#![allow(unused)] fn main() { // src/network/client.rs use super::Config; // Access parent module pub struct Client { config: Config, connected: bool, } impl Client { pub fn new(config: Config) -> Self { Client { config, connected: false, } } pub fn connect(&mut self) -> Result<(), String> { // Connection logic self.connected = true; Ok(()) } } }
#![allow(unused)] fn main() { // src/network/server.rs use super::Config; pub struct Server { config: Config, listening: bool, } impl Server { pub fn new(config: Config) -> Self { Server { config, listening: false, } } pub fn listen(&mut self, port: u16) -> Result<(), String> { println!("Listening on port {}", port); self.listening = true; Ok(()) } } }
Visibility Rules
Privacy Boundaries
mod outer { pub fn public_function() { println!("Public function"); } fn private_function() { println!("Private function"); } pub mod inner { pub fn inner_public() { // Can access parent's private items super::private_function(); } pub(super) fn visible_to_parent() { println!("Only visible to parent module"); } pub(crate) fn visible_in_crate() { println!("Visible throughout the crate"); } } } fn main() { outer::public_function(); outer::inner::inner_public(); // outer::inner::visible_to_parent(); // Error: not visible here outer::inner::visible_in_crate(); // OK: we're in the same crate }
Struct Field Visibility
mod back_of_house { pub struct Breakfast { pub toast: String, // Public field seasonal_fruit: String, // Private field } impl Breakfast { pub fn summer(toast: &str) -> Breakfast { Breakfast { toast: String::from(toast), seasonal_fruit: String::from("peaches"), } } } // All fields must be public for tuple struct to be constructable pub struct Color(pub u8, pub u8, pub u8); } fn main() { let mut meal = back_of_house::Breakfast::summer("Rye"); meal.toast = String::from("Wheat"); // OK: public field // meal.seasonal_fruit = String::from("strawberries"); // Error: private let color = back_of_house::Color(255, 0, 0); // OK: all fields public }
Module Design Patterns
API Design with Modules
// A well-designed module API pub mod database { // Re-export the main types users need pub use self::connection::Connection; pub use self::error::{Error, Result}; mod connection { use super::error::Result; pub struct Connection { // Implementation details hidden url: String, } impl Connection { pub fn open(url: &str) -> Result<Self> { Ok(Connection { url: url.to_string(), }) } pub fn execute(&self, query: &str) -> Result<()> { // Implementation Ok(()) } } } mod error { use std::fmt; #[derive(Debug)] pub struct Error { message: String, } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "Database error: {}", self.message) } } impl std::error::Error for Error {} pub type Result<T> = std::result::Result<T, Error>; } } // Clean usage use database::{Connection, Result}; fn main() -> Result<()> { let conn = Connection::open("postgres://localhost/mydb")?; conn.execute("SELECT * FROM users")?; Ok(()) }
Builder Pattern with Modules
pub mod request { pub struct Request { url: String, method: Method, headers: Vec<(String, String)>, } #[derive(Clone)] pub enum Method { GET, POST, PUT, DELETE, } pub struct RequestBuilder { url: Option<String>, method: Method, headers: Vec<(String, String)>, } impl RequestBuilder { pub fn new() -> Self { RequestBuilder { url: None, method: Method::GET, headers: Vec::new(), } } pub fn url(mut self, url: &str) -> Self { self.url = Some(url.to_string()); self } pub fn method(mut self, method: Method) -> Self { self.method = method; self } pub fn header(mut self, key: &str, value: &str) -> Self { self.headers.push((key.to_string(), value.to_string())); self } pub fn build(self) -> Result<Request, &'static str> { let url = self.url.ok_or("URL is required")?; Ok(Request { url, method: self.method, headers: self.headers, }) } } impl Request { pub fn builder() -> RequestBuilder { RequestBuilder::new() } pub fn send(&self) -> Result<Response, &'static str> { // Send request logic Ok(Response { status: 200 }) } } pub struct Response { pub status: u16, } } use request::{Request, Method}; fn main() { let response = Request::builder() .url("https://api.example.com/data") .method(Method::POST) .header("Content-Type", "application/json") .build() .unwrap() .send() .unwrap(); println!("Response status: {}", response.status); }
Common Patterns and Best Practices
Prelude Pattern
#![allow(unused)] fn main() { // Create a prelude module for commonly used items pub mod prelude { pub use crate::error::{Error, Result}; pub use crate::config::Config; pub use crate::client::Client; pub use crate::server::Server; } // Users can import everything they need with one line: // use your_crate::prelude::*; }
Internal Module Pattern
#![allow(unused)] fn main() { pub mod parser { // Public API pub fn parse(input: &str) -> Result<Expression, Error> { let tokens = internal::tokenize(input)?; internal::build_ast(tokens) } pub struct Expression { // ... } pub struct Error { // ... } // Implementation details in internal module mod internal { use super::*; pub(super) fn tokenize(input: &str) -> Result<Vec<Token>, Error> { // ... } pub(super) fn build_ast(tokens: Vec<Token>) -> Result<Expression, Error> { // ... } struct Token { // Private implementation detail } } } }
Exercise: Create a Library Management System
Design a module structure for a library system:
// TODO: Create the following module structure: // - books module with Book struct and methods // - members module with Member struct // - loans module for managing book loans // - Use proper visibility modifiers mod books { pub struct Book { // TODO: Add fields (some public, some private) } impl Book { // TODO: Add constructor and methods } } mod members { pub struct Member { // TODO: Add fields } impl Member { // TODO: Add methods } } mod loans { use super::books::Book; use super::members::Member; pub struct Loan { // TODO: Reference a Book and Member } impl Loan { // TODO: Implement loan management } } pub mod library { // TODO: Create a public API that uses the above modules // Re-export necessary types } fn main() { // TODO: Use the library module to: // 1. Create some books // 2. Register members // 3. Create loans // 4. Return books }
Key Takeaways
- Modules organize code into logical units with clear boundaries
- Privacy by default - items are private unless marked
pub - The
usekeyword brings items into scope for convenience - File structure mirrors module structure for large projects
pub usefor re-exports creates clean public APIs- Visibility modifiers (
pub(crate),pub(super)) provide fine-grained control - Module design should hide implementation details and expose minimal APIs
- Prelude pattern simplifies imports for users of your crate
Congratulations! You’ve completed Day 2 of the Rust course. You now have a solid understanding of Rust’s advanced features including traits, generics, error handling, iterators, and module organization. These concepts form the foundation for building robust, maintainable Rust applications.
Chapter 13: Cargo & Dependency Management
Cargo is Rust’s build system and package manager. It handles dependencies, compilation, testing, and distribution. This chapter covers dependency management, from editions and toolchains to private registries and reproducible builds.
1. Rust Editions
Rust editions are opt-in milestones released every three years that allow the language to evolve while maintaining stability guarantees. All editions remain fully interoperable - crates using different editions work together seamlessly.
Available Editions
| Edition | Released | Default Resolver | Key Changes |
|---|---|---|---|
| 2015 | Rust 1.0 | v1 | Original edition, extern crate required |
| 2018 | Rust 1.31 | v1 | Module system improvements, async/await, NLL |
| 2021 | Rust 1.56 | v2 | Disjoint captures, into_iter() arrays, reserved identifiers |
| 2024 | Rust 1.85 | v3 | MSRV-aware resolver, gen keyword, unsafe env functions |
Key Edition Changes
Edition 2018:
- No more
extern cratedeclarations (except for macros) - Uniform path syntax in
usestatements async/awaitkeywords reserved- Non-lexical lifetimes (NLL)
- Module system simplification
Edition 2021:
- Disjoint captures in closures (only capture used fields)
array.into_iter()iterates by value- Reserved syntax prefixes (
ident"...",ident'...',ident#...) - Default to resolver v2 for Cargo
- Panic macros require format strings
Edition 2024:
- MSRV-aware dependency resolution (resolver v3)
genkeyword reserved (for future generator blocks; not yet stabilized)std::env::set_varandremove_varmarked unsafe- Tail expression temporary lifetime changes
externblocks must now be written asunsafe extern(items inside can be markedsafe)
Configuration and Migration
[package]
name = "my-project"
version = "0.1.0"
edition = "2021"
# Migrate code to next edition (modifies files)
cargo fix --edition
# Apply idiomatic style changes
cargo fix --edition --edition-idioms
# Then update Cargo.toml manually
Edition Selection Strategy
| Project Type | Recommended Edition | Rationale |
|---|---|---|
| New projects | Latest stable | Access to all improvements |
| Libraries | Conservative (2018/2021) | Wider compatibility |
| Applications | Latest stable | Modern features |
| Legacy code | Keep current | Migrate when beneficial |
2. Toolchain Channels
Rust uses a release train model with three channels:
Nightly (daily) → Beta (6 weeks) → Stable (6 weeks)
| Channel | Release Cycle | Stability | Use Case |
|---|---|---|---|
| Stable | 6 weeks | Guaranteed stable | Production |
| Beta | 6 weeks | Generally stable | Testing upcoming releases |
| Nightly | Daily | May break | Experimental features |
Stable Channel
# Install or switch to stable
rustup default stable
# Use specific stable version
rustup install 1.82.0
rustup default 1.82.0
Beta Channel
# Switch to beta
rustup default beta
# Test with beta in CI
rustup run beta cargo test
Nightly Channel
# Use nightly for specific project
rustup override set nightly
# Install specific nightly
rustup install nightly-2024-11-28
Enabling unstable features:
// Only works on nightly
#![feature(gen_blocks)]
#![feature(specialization)]
Project Toolchain Configuration
# rust-toolchain.toml
[toolchain]
channel = "1.82.0" # Or "stable", "beta", "nightly"
components = ["rustfmt", "clippy"]
targets = ["wasm32-unknown-unknown"]
Override commands:
# Set override for current directory
rustup override set nightly
# Run command with specific toolchain
cargo +nightly build
cargo +1.82.0 test
CI/CD Multi-Channel Testing
# GitHub Actions
strategy:
matrix:
rust: [stable, beta, nightly]
continue-on-error: ${{ matrix.rust == 'nightly' }}
steps:
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
3. Dependency Resolution
Version Requirements
Cargo uses semantic versioning (SemVer) with various requirement operators:
[dependencies]
# Caret (default) - compatible versions
serde = "1.0" # means ^1.0.0
# Exact version
exact = "=1.0.0"
# Range
range = ">=1.2, <1.5"
# Wildcard
wildcard = "1.0.*"
# Tilde - patch updates only
tilde = "~1.0.0"
Transitive Dependencies
Cargo builds a dependency graph and resolves versions using maximum version strategy:
Your Project
├── crate-a = "1.0"
│ └── shared = "2.1" # Transitive dependency
└── crate-b = "2.0"
└── shared = "2.3" # Same dependency, different version
Resolution: Cargo picks shared = "2.3" (highest compatible version).
Resolver Versions
| Resolver | Default For | Behavior |
|---|---|---|
| v1 | Edition 2015/2018 | Unifies features across all uses |
| v2 | Edition 2021 | Independent feature resolution per target |
| v3 | Edition 2024 (Rust 1.85+) | MSRV-aware dependency selection, default in 2024 |
# Explicit resolver configuration
[package]
edition = "2018"
resolver = "2" # Opt into v2 resolver
# For workspaces
[workspace]
members = ["crate-a", "crate-b"]
resolver = "2"
Key v2 differences:
- Platform-specific dependencies don’t affect other platforms
- Build dependencies don’t share features with normal dependencies
- Dev dependencies only activate features when building tests/examples
Key v3 additions (Edition 2024 default):
- MSRV-aware dependency resolution when
rust-versionis specified - Falls back to compatible versions when newer versions require higher MSRV
- Better support for workspaces with mixed Rust versions
4. Cargo.lock
The Cargo.lock file pins exact dependency versions for reproducible builds.
When to Commit
| Project Type | Commit? | Reason |
|---|---|---|
| Binary/Application | Yes | Reproducible builds |
| Library | No | Allow flexible version resolution |
| Workspace root | Yes | Consistent versions across workspace |
Lock File Usage
# Build with exact lock file versions
cargo build --locked
# Update all dependencies
cargo update
# Update specific dependency
cargo update -p serde
# Update to specific version
cargo update -p tokio --precise 1.21.0
5. Minimum Supported Rust Version (MSRV)
[package]
rust-version = "1.74" # Minimum Rust version
Finding and Testing MSRV
# Install cargo-msrv
cargo install cargo-msrv
# Find minimum version
cargo msrv find
# Verify declared MSRV
cargo msrv verify
CI Testing
# GitHub Actions
- name: Test MSRV
run: |
rustup install $(grep rust-version Cargo.toml | cut -d'"' -f2)
cargo test --locked
MSRV Policy Guidelines
| Project Type | Suggested MSRV | Rationale |
|---|---|---|
| Foundational libraries | 6-12 months old | Maximum compatibility |
| Application libraries | 3-6 months old | Balance features/compatibility |
| Applications | Current stable | Use latest features |
| Internal tools | Latest stable | No external users |
6. Workspace Management
Workspaces allow managing multiple related crates in a single repository:
# Root Cargo.toml
[workspace]
members = ["crate-a", "crate-b", "crate-c"]
resolver = "2"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = "1.47"
[workspace.package]
authors = ["Your Name"]
edition = "2021"
license = "MIT"
repository = "https://github.com/user/repo"
Member crates inherit workspace configuration:
# crate-a/Cargo.toml
[package]
name = "crate-a"
version = "0.1.0"
authors.workspace = true
edition.workspace = true
[dependencies]
serde.workspace = true
tokio.workspace = true
Workspace Commands
# Build all workspace members
cargo build --workspace
# Test specific member
cargo test -p crate-a
# Run example from workspace member
cargo run -p crate-b --example demo
7. Private Registries
For organizations that need to host internal crates, private registries such as Kellnr (self-hosted) and JFrog Artifactory (enterprise) are available. See the Cargo registries documentation for configuration details.
8. Build Configuration
Profiles
[profile.dev]
opt-level = 0
debug = true
overflow-checks = true
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
[profile.bench]
inherits = "release"
# Custom profile
[profile.production]
inherits = "release"
lto = "fat"
panic = "abort"
Build Scripts
// build.rs
fn main() {
// Link system libraries
println!("cargo:rustc-link-lib=ssl");
// Rerun if files change
println!("cargo:rerun-if-changed=src/native.c");
// Compile C code
cc::Build::new()
.file("src/native.c")
.compile("native");
// Set environment variables
println!("cargo:rustc-env=BUILD_TIME={}",
chrono::Utc::now().to_rfc3339());
}
Build Dependencies
[build-dependencies]
cc = "1.0"
chrono = "0.4"
9. Dependencies
Dependency Types
[dependencies]
normal = "1.0"
[dev-dependencies]
criterion = "0.5"
proptest = "1.0"
[build-dependencies]
cc = "1.0"
[target.'cfg(windows)'.dependencies]
winapi = "0.3"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
Features
[dependencies]
tokio = { version = "1.47", default-features = false, features = ["rt-multi-thread", "macros"] }
[features]
default = ["std"]
std = ["serde/std"]
alloc = ["serde/alloc"]
performance = ["lto", "parallel"]
The dep: Prefix for Optional Dependencies
[dependencies]
eframe = { version = "0.30", optional = true }
[features]
gui = ["dep:eframe"] # dep: prefix avoids creating an implicit "eframe" feature
The dep: prefix (stabilized in Rust 1.60) explicitly references an optional dependency without creating an implicit feature of the same name. This gives you full control over your public feature namespace and is used in Day 4 for feature-gated modules.
Git and Path Dependencies
[dependencies]
# Git repository
from-git = { git = "https://github.com/user/repo", branch = "main" }
# Specific commit
specific = { git = "https://github.com/user/repo", rev = "abc123" }
# Local path
local = { path = "../local-crate" }
# Published with override
override = { version = "1.0", path = "../override" }
10. Documentation
Writing Documentation
#![allow(unused)] fn main() { //! Module-level documentation //! //! This module provides utilities for working with strings. /// Calculate factorial of n /// /// # Examples /// /// ``` /// assert_eq!(factorial(5), 120); /// assert_eq!(factorial(0), 1); /// ``` /// /// # Panics /// /// Panics if the result would overflow. pub fn factorial(n: u32) -> u32 { match n { 0 => 1, _ => n * factorial(n - 1), } } }
Documentation Commands
# Generate and open docs
cargo doc --open
# Exclude dependency documentation (your crate only)
cargo doc --no-deps
# Document private items
cargo doc --document-private-items
# Test documentation examples
cargo test --doc
11. Examples Directory
Structure example code for users:
examples/
├── basic.rs # cargo run --example basic
├── advanced.rs # cargo run --example advanced
└── multi-file/ # Multi-file example
├── main.rs
└── helper.rs
# Cargo.toml
[[example]]
name = "multi-file"
path = "examples/multi-file/main.rs"
12. Benchmarking with Criterion
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "my_benchmark"
harness = false
// benches/my_benchmark.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
fn fibonacci(n: u64) -> u64 {
match n {
0 | 1 => 1,
n => fibonacci(n-1) + fibonacci(n-2),
}
}
fn bench_fibonacci(c: &mut Criterion) {
let mut group = c.benchmark_group("fibonacci");
for i in [20, 30, 35].iter() {
group.bench_with_input(BenchmarkId::from_parameter(i), i, |b, &i| {
b.iter(|| fibonacci(black_box(i)));
});
}
group.finish();
}
criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);
Run benchmarks:
cargo bench
# Save baseline
cargo bench -- --save-baseline main
# Compare with baseline
cargo bench -- --baseline main
13. Security
Dependency Auditing
# Install audit tools
cargo install cargo-audit
cargo install cargo-deny
# Check for vulnerabilities
cargo audit
# Audit with fix suggestions
cargo audit fix
Deny Configuration
# deny.toml
[bans]
multiple-versions = "warn"
wildcards = "deny"
skip = []
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause"]
deny = ["GPL-3.0"]
[sources]
unknown-registry = "deny"
unknown-git = "warn"
cargo deny check
14. Reproducible Builds
Ensure reproducibility with:
- Committed
Cargo.lockfor applications - Pinned toolchain via
rust-toolchain.toml --lockedflag in CI builds- Vendored dependencies for offline builds
Vendoring Dependencies
# Vendor all dependencies
cargo vendor
# Configure to use vendored dependencies
mkdir .cargo
cat > .cargo/config.toml << EOF
[source.crates-io]
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "vendor"
EOF
# Build offline
cargo build --offline
15. Useful Commands
# Dependency tree
cargo tree
cargo tree -d # Show duplicates
cargo tree -i serde # Inverse dependencies
cargo tree -e features # Show features
# Workspace commands
cargo build --workspace # Build all members
cargo test --workspace # Test all members
cargo publish --dry-run # Verify before publishing
# Check commands
cargo check # Fast compilation check
cargo clippy # Linting
cargo fmt # Format code
# Cache management
cargo clean # Remove target directory
cargo clean -p specific-crate # Clean specific crate
# Package management
cargo new myproject --lib # Create library
cargo init # Initialize in existing directory
cargo package # Create distributable package
cargo publish # Publish to crates.io
Additional Resources
Chapter 14: Testing in Rust
Rust has first-class testing support built directly into the language and toolchain. There’s no need for external test frameworks like xUnit, NUnit, or Google Test — cargo test works out of the box on every Rust project. This chapter covers how to write, organize, and run tests effectively.
1. Unit Tests — The Basics
The #[test] Attribute
Any function annotated with #[test] becomes a test case. A test passes if it runs without panicking.
#![allow(unused)] fn main() { #[test] fn it_works() { let result = 2 + 2; assert_eq!(result, 4); } #[test] fn greeting_contains_name() { let greeting = format!("Hello, {}!", "Alice"); assert!(greeting.contains("Alice")); } }
Assertion Macros
Rust provides three core assertion macros:
| Macro | Purpose | Panics when |
|---|---|---|
assert!(expr) | Boolean check | expr is false |
assert_eq!(left, right) | Equality check | left != right |
assert_ne!(left, right) | Inequality check | left == right |
All three accept an optional custom message as additional arguments:
#![allow(unused)] fn main() { #[test] fn test_with_messages() { let age = 17; assert!(age >= 18, "Expected adult, got age {}", age); let expected = 42; let actual = compute_answer(); assert_eq!(actual, expected, "compute_answer() returned wrong value"); } fn compute_answer() -> i32 { 42 } }
Note:
assert_eq!andassert_ne!require the compared types to implement bothPartialEqandDebug. Most standard types do; for your own types, add#[derive(Debug, PartialEq)].
Comparison with C#/.NET
| C# / .NET | Rust | Notes |
|---|---|---|
[TestClass] | #[cfg(test)] mod tests | Test module, compiled only during testing |
[TestMethod] | #[test] | Marks a test function |
Assert.AreEqual(a, b) | assert_eq!(a, b) | Prints both values on failure |
Assert.IsTrue(x) | assert!(x) | Boolean assertion |
Assert.ThrowsException<T> | #[should_panic] | Expects a panic |
[ExpectedException] | #[should_panic(expected = "msg")] | Checks panic message |
[Ignore] | #[ignore] | Skips test unless explicitly requested |
The #[cfg(test)] Module
By convention, unit tests live in a tests module at the bottom of the same file, gated by #[cfg(test)]:
#![allow(unused)] fn main() { pub fn add(a: i32, b: i32) -> i32 { a + b } pub fn divide(a: f64, b: f64) -> Result<f64, String> { if b == 0.0 { return Err("Division by zero".to_string()); } Ok(a / b) } #[cfg(test)] mod tests { use super::*; #[test] fn test_add() { assert_eq!(add(2, 3), 5); assert_eq!(add(-1, 1), 0); } #[test] fn test_divide() { assert_eq!(divide(10.0, 2.0), Ok(5.0)); } #[test] fn test_divide_by_zero() { assert!(divide(5.0, 0.0).is_err()); } } }
The #[cfg(test)] attribute means this module is only compiled when running cargo test — it won’t bloat your release binary. The use super::*; import brings the parent module’s items into scope.
Testing Result<T, E> Returns
Test functions can return Result<(), E>, which lets you use ? instead of unwrap():
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use std::num::ParseIntError; #[test] fn test_parsing() -> Result<(), ParseIntError> { let value: i32 = "42".parse()?; assert_eq!(value, 42); Ok(()) } } }
The test fails if the function returns Err. This is especially useful when your test involves multiple fallible operations.
#[should_panic]
Use #[should_panic] when you expect a function to panic. You can optionally check the panic message:
#![allow(unused)] fn main() { pub fn validate_age(age: i32) -> i32 { if age < 0 || age > 150 { panic!("Invalid age: {age}. Must be between 0 and 150."); } age } #[cfg(test)] mod tests { use super::*; #[test] #[should_panic] fn test_negative_age_panics() { validate_age(-1); } #[test] #[should_panic(expected = "Invalid age")] fn test_panic_message() { validate_age(200); } } }
2. Test Organization
Rust supports three kinds of tests, each with a different scope:
| Kind | Location | Compiles as | Tests… |
|---|---|---|---|
| Unit tests | src/*.rs inside #[cfg(test)] | Part of the crate | Private + public API |
| Integration tests | tests/*.rs directory | Separate crate | Public API only |
| Doc tests | /// comments in source | Separate compilation | Examples in documentation |
Unit Tests: Colocated with Code
Unit tests live inside the module they test. This is different from C# where test projects are always separate — in Rust, tests sit right next to the code they exercise:
#![allow(unused)] fn main() { // src/temperature.rs pub struct Celsius(pub f64); pub struct Fahrenheit(pub f64); impl Celsius { pub fn to_fahrenheit(&self) -> Fahrenheit { Fahrenheit(self.0 * 9.0 / 5.0 + 32.0) } fn is_valid(&self) -> bool { self.0 >= -273.15 } } #[cfg(test)] mod tests { use super::*; #[test] fn boiling_point() { let c = Celsius(100.0); let f = c.to_fahrenheit(); assert!((f.0 - 212.0).abs() < f64::EPSILON); } #[test] fn can_test_private_functions() { // Rust allows unit tests to access private items! assert!(Celsius(20.0).is_valid()); assert!(!Celsius(-300.0).is_valid()); } } }
Integration Tests: The tests/ Directory
Files in a top-level tests/ directory are compiled as separate crates. They can only access your crate’s public API:
my_crate/
├── src/
│ └── lib.rs
├── tests/
│ ├── basic_operations.rs
│ └── edge_cases.rs
└── Cargo.toml
// tests/basic_operations.rs
use my_crate::{add, divide};
#[test]
fn test_add_from_outside() {
assert_eq!(add(10, 20), 30);
}
#[test]
fn test_divide_from_outside() {
assert!(divide(1.0, 0.0).is_err());
}
Each file in tests/ is a separate test binary. To share helper code between integration tests, put it in tests/common/mod.rs (the mod.rs naming prevents Cargo from treating the helper file itself as a test suite).
Doc Tests: Executable Examples
Code blocks inside /// documentation comments are compiled and run as tests:
#![allow(unused)] fn main() { /// Adds two numbers. /// /// # Examples /// /// ``` /// use my_crate::add; /// assert_eq!(add(2, 3), 5); /// ``` pub fn add(a: i32, b: i32) -> i32 { a + b } }
Doc tests serve double duty: they verify your examples are correct and provide documentation for users. If the example compiles but shouldn’t be run, use no_run. If it shouldn’t even compile (to show error examples), use compile_fail:
#![allow(unused)] fn main() { /// ```no_run /// // This compiles but we don't want to run it in tests /// std::process::exit(0); /// ``` /// /// ```compile_fail /// // This demonstrates a compile error /// let x: i32 = "not a number"; /// ``` }
When to Use Which
| Question | Answer |
|---|---|
| Testing private helper functions? | Unit test — only they have access |
| Testing your public API as a consumer would? | Integration test |
| Ensuring documentation examples stay correct? | Doc test |
| Quick check of a single function? | Unit test |
| End-to-end workflow across modules? | Integration test |
3. Running Tests
Basic Commands
# Run all tests (unit, integration, doc)
cargo test
# Run only unit tests (lib)
cargo test --lib
# Run only unit tests (bin)
cargo test --bins
cargo test --bin hello
# Run only integration tests
cargo test --test basic_operations
cargo test --test '*'
# Run only doc tests
cargo test --doc
# Run tests in a specific package (workspace)
cargo test -p my_crate
Filtering by Name
# Run tests whose name contains "divide"
cargo test divide
# Run tests in a specific module
cargo test temperature::tests
# Run a single, exact test
cargo test -- --exact test_add
Useful Flags
# Show println! output (normally captured on success)
cargo test -- --nocapture
# Run tests sequentially (default is parallel)
cargo test -- --test-threads=1
# Run only ignored tests
cargo test -- --ignored
# Run all tests including ignored
cargo test -- --include-ignored
# Show which tests are running (without running them)
cargo test -- --list
#[ignore] for Slow Tests
Mark expensive tests with #[ignore] so they don’t slow down your normal test runs:
#![allow(unused)] fn main() { #[test] #[ignore = "requires network access"] fn test_api_integration() { // This test calls an external API and takes seconds // Only runs with: cargo test -- --ignored } }
Cargo Nextest — A Faster Test Runner
cargo-nextest is a drop-in replacement for cargo test with better performance and output:
# Install
cargo install cargo-nextest
# Run all tests (same as cargo test, but faster and prettier)
cargo nextest run
# Filter by name
cargo nextest run divide
# List tests
cargo nextest list
Nextest runs each test as a separate process, providing better isolation and parallel performance. It also gives clearer output on failures.
4. Testing Patterns
Testing Private Functions
Unlike C# where you’d need [InternalsVisibleTo] or reflection to test private methods, Rust unit tests can test private functions directly — because they’re inside the same module:
#![allow(unused)] fn main() { fn internal_hash(data: &[u8]) -> u64 { // private implementation detail data.iter().fold(0u64, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as u64)) } pub fn is_valid_hash(data: &[u8], expected: u64) -> bool { internal_hash(data) == expected } #[cfg(test)] mod tests { use super::*; #[test] fn test_internal_hash_directly() { let hash = internal_hash(b"hello"); assert_ne!(hash, 0); // Same input always produces same output assert_eq!(internal_hash(b"hello"), hash); } } }
Test Helpers and Setup
Rust doesn’t have [SetUp] / [TearDown] attributes. Instead, use regular helper functions or builder patterns:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; // Helper function — the Rust equivalent of [SetUp] fn setup_calculator() -> Calculator { Calculator::with_precision(2) } #[test] fn test_add() { let calc = setup_calculator(); assert_eq!(calc.calculate(Operation::Add, 1.0, 2.0), Ok(3.0)); } #[test] fn test_subtract() { let calc = setup_calculator(); assert_eq!(calc.calculate(Operation::Subtract, 5.0, 3.0), Ok(2.0)); } } }
Mocking with Traits
Rust doesn’t have a built-in mocking framework like Moq or Mockito. Instead, use trait-based dependency injection — define behavior behind a trait, then provide a mock implementation in tests:
#![allow(unused)] fn main() { // Define behavior as a trait trait WeatherService { fn get_temperature(&self, city: &str) -> Result<f64, String>; } // Production implementation struct RealWeatherService; impl WeatherService for RealWeatherService { fn get_temperature(&self, city: &str) -> Result<f64, String> { // HTTP call to weather API... Ok(20.0) } } // Code under test depends on the trait, not the implementation fn should_wear_jacket(service: &dyn WeatherService, city: &str) -> bool { match service.get_temperature(city) { Ok(temp) => temp < 15.0, Err(_) => true, // When in doubt, bring a jacket } } #[cfg(test)] mod tests { use super::*; // Test mock — no framework needed struct MockWeatherService { temperature: Result<f64, String>, } impl WeatherService for MockWeatherService { fn get_temperature(&self, _city: &str) -> Result<f64, String> { self.temperature.clone() } } #[test] fn cold_weather_needs_jacket() { let service = MockWeatherService { temperature: Ok(5.0) }; assert!(should_wear_jacket(&service, "Zurich")); } #[test] fn warm_weather_no_jacket() { let service = MockWeatherService { temperature: Ok(25.0) }; assert!(!should_wear_jacket(&service, "Barcelona")); } #[test] fn error_means_jacket() { let service = MockWeatherService { temperature: Err("API down".to_string()), }; assert!(should_wear_jacket(&service, "Unknown")); } } }
This pattern is idiomatic Rust and works without any external crate. In Day 4, the ESP32-C3 exercises use exactly this approach: a TemperatureSensorHal trait defines sensor behavior, with a real ESP32 implementation for hardware and a MockTemperatureSensor for desktop testing. For more complex mocking needs, crates like mockall can auto-generate mock implementations from traits.
Testing Async Code
If you use async Rust, the #[tokio::test] attribute creates a runtime for your test:
#[tokio::test]
async fn test_async_fetch() {
let result = fetch_data("https://example.com").await;
assert!(result.is_ok());
}
This requires tokio as a dev-dependency with the macros and rt features:
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
5. Code Coverage with cargo llvm-cov
Code coverage measures which lines, branches, and functions your tests actually execute. It’s a useful tool for finding untested code — but high coverage numbers don’t guarantee correctness. Focus on testing critical paths rather than chasing 100%.
Setup
# Install
cargo install cargo-llvm-cov
rustup component add llvm-tools-preview
Basic Usage
# Run tests and show coverage summary
cargo llvm-cov
# Generate HTML report and open in browser
cargo llvm-cov --open
# Coverage for the whole workspace
cargo llvm-cov --workspace
Example Output
Filename Regions Missed Cover Lines Missed Cover
---------------------------------------------------------------------------------------
src/calculator.rs 12 2 83.33% 45 3 93.33%
src/lib.rs 8 0 100.00% 30 0 100.00%
---------------------------------------------------------------------------------------
TOTAL 20 2 90.00% 75 3 96.00%
The HTML report highlights covered lines in green and uncovered lines in red — a quick way to spot gaps.
CI/CD Integration
Add coverage to your GitHub Actions pipeline:
name: Coverage
on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: taiki-e/install-action@cargo-llvm-cov
- run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- uses: codecov/codecov-action@v5
with:
files: lcov.info
Coverage Best Practices
- Coverage finds untested code, not bugs — a covered line can still contain a bug
- Focus on critical paths — business logic, error handling, edge cases
- Don’t chase 100% — some code (e.g.,
Displayimpls, CLI boilerplate) isn’t worth testing exhaustively - Watch coverage trends — a sudden drop often signals forgotten tests for new code
6. Property-Based Testing
Standard unit tests check specific examples: “does add(2, 3) return 5?” Property-based testing takes a different approach: “does add(a, b) always equal add(b, a) for all a and b?” The framework generates hundreds of random inputs and checks that your invariants hold.
Using proptest
[dev-dependencies]
proptest = "1"
#[cfg(test)]
mod tests {
use proptest::prelude::*;
fn reverse(s: &str) -> String {
s.chars().rev().collect()
}
proptest! {
#[test]
fn reversing_twice_gives_original(s in "\\PC*") {
// For any string, reversing twice should return the original
assert_eq!(reverse(&reverse(&s)), s);
}
#[test]
fn reverse_preserves_length(s in "\\PC*") {
// .len() returns byte length; reversing chars preserves total bytes
assert_eq!(reverse(&s).len(), s.len());
}
#[test]
fn addition_is_commutative(a in 0i64..1000, b in 0i64..1000) {
assert_eq!(a + b, b + a);
}
}
}
When proptest finds a failing input, it shrinks it to the smallest reproducible case — making debugging much easier than staring at a random 200-character string.
When to Use Property-Based Tests
| Situation | Approach |
|---|---|
| Known specific inputs and expected outputs | Standard #[test] |
| Mathematical invariants (commutativity, associativity) | proptest |
Parsers: parse(format(x)) == x | proptest |
| “This should never panic for any input” | proptest |
| Testing a function against a simpler reference implementation | proptest |
Property-based tests are particularly good at catching edge cases you’d never think to write manually — empty strings, integer overflow, Unicode boundary conditions, etc.
Exercise: Test a MarkdownProcessor
In this exercise you’ll practise the testing techniques covered above by implementing a small Markdown-to-text processor and its test suite. You get the type signatures and the tests — your job is to make every test pass.
Setup
# Copy the starter into your workspace
cp -r solutions/day3/14_testing/ mysolutions/day3/14_testing/
cd mysolutions/day3/14_testing
cargo test # all tests should fail initially
What you implement
pub struct MarkdownProcessor;
impl MarkdownProcessor {
pub fn new() -> Self;
/// Strip all markdown formatting, return plain text.
pub fn to_plain_text(&self, input: &str) -> String;
/// Extract all links as (text, url) pairs from `[text](url)`.
pub fn extract_links(&self, input: &str) -> Vec<(String, String)>;
/// Count headings by level (1–6).
pub fn count_headings(&self, input: &str) -> HashMap<u8, usize>;
/// **bold** → UPPERCASE, *italic* → lowercase.
/// Panics on unmatched `**` markers.
pub fn transform_emphasis(&self, input: &str) -> String;
}
What the tests cover
| # | Test | Technique |
|---|---|---|
| 1 | plain_text_strips_headings | assert_eq! |
| 2 | plain_text_strips_bold_and_italic | assert_eq! |
| 3 | plain_text_converts_links_to_text | assert_eq! |
| 4 | extract_links_finds_all_links | assert_eq! on Vec |
| 5 | extract_links_returns_empty_for_no_links | assert!(_.is_empty()) |
| 6 | count_headings_by_level | HashMap assertions |
| 7 | transform_emphasis_bold_to_uppercase | assert_eq! |
| 8 | transform_emphasis_italic_to_lowercase | assert_eq! |
| 9 | transform_emphasis_panics_on_unmatched_bold | #[should_panic(expected = "...")] |
| 10 | round_trip_plain_text_is_stable | Result<(), String> return |
| 11 | large_document_performance | #[ignore] — run with cargo test -- --ignored |
Tips
- Start with
to_plain_text— most other functions build on the same parsing logic. - Use
str::trim_start_matchesto strip leading#characters. - For
extract_links, search for[then](then)in sequence. - The
#[should_panic]test expects the exact substring"Unmatched bold markers". - Run
cargo test -- --nocaptureto seeprintln!output from your code while debugging.
Solution
The reference solution is in solutions/day3/14_testing/src/lib.rs.
Summary
| What | How |
|---|---|
| Write a test | #[test] fn name() { ... } |
| Check equality | assert_eq!(actual, expected) |
| Expect a panic | #[should_panic(expected = "msg")] |
| Return Result from test | fn test() -> Result<(), E> { ... } |
| Test private functions | Put tests in #[cfg(test)] mod tests inside the same file |
| Integration tests | tests/*.rs directory |
| Doc tests | Code blocks in /// comments |
| Run all tests | cargo test |
| Filter tests | cargo test name_filter |
| See output | cargo test -- --nocapture |
| Skip slow tests | #[ignore], run with cargo test -- --ignored |
| Code coverage | cargo llvm-cov --open |
| Property testing | proptest! macro with random input generators |
Chapter 15: Macros & Code Generation
Macros are Rust’s metaprogramming feature - code that writes other code. They run at compile time, generating Rust code that gets compiled with the rest of your program. This chapter covers declarative macros with macro_rules! and introduces procedural macros.
What are Macros?
Macros enable code generation at compile time, reducing boilerplate and enabling domain-specific languages (DSLs). Unlike functions, macros:
- Operate on syntax trees, not values
- Can take a variable number of arguments
- Generate code before type checking
- Can create new syntax patterns
// This macro call
println!("Hello, {}!", "world");
// Expands to something like this (simplified)
std::io::_print(format_args!("Hello, {}!\n", "world"));
Declarative Macros with macro_rules!
Basic Syntax
#![allow(unused)] fn main() { macro_rules! say_hello { () => { println!("Hello!"); }; } say_hello!(); // Prints: Hello! }
Fragment Specifiers
Macros use pattern matching with fragment specifiers that match different parts of Rust syntax:
| Specifier | Matches | Example |
|---|---|---|
$x:expr | Expressions | 5 + 3, foo(), if a { b } else { c } |
$x:ident | Identifiers | my_var, String, foo |
$x:ty | Types | i32, Vec<String>, &str |
$x:tt | Single token tree | Any token or ()/[]/{}-delimited group |
$x:pat | Patterns | Some(x), 0..=9, _ |
$x:block | Code blocks | { stmt; stmt; expr } |
$x:stmt | Statements | let x = 5, x.push(1) |
$x:item | Items | fn, struct, impl, mod definitions |
$x:path | Paths | std::vec::Vec, crate::module::Type |
$x:literal | Literals | 42, "hello", true |
$x:vis | Visibility | pub, pub(crate), (empty) |
$x:lifetime | Lifetimes | 'a, 'static |
$x:meta | Attributes | derive(Debug), cfg(test) |
Edition 2024 note: expr now also matches const and _ expressions. Use expr_2021 for the old behavior.
Here are the most commonly used specifiers in practice:
#![allow(unused)] fn main() { macro_rules! create_function { ($func_name:ident) => { fn $func_name() { println!("You called {}!", stringify!($func_name)); } }; } create_function!(foo); foo(); // Prints: You called foo! }
#![allow(unused)] fn main() { macro_rules! double { ($e:expr) => { $e * 2 }; } let result = double!(5 + 3); // 16 }
tt (token tree) is the most flexible — it matches anything and is often used with repetition to accept arbitrary input:
#![allow(unused)] fn main() { macro_rules! capture_tokens { ($($tt:tt)*) => { println!("Tokens: {}", stringify!($($tt)*)); }; } capture_tokens!(hello world 1 + 2); }
Multiple Patterns
#![allow(unused)] fn main() { macro_rules! vec_shorthand { // Empty vector () => { Vec::new() }; // Vector with elements ($($x:expr),+ $(,)?) => { { let mut vec = Vec::new(); $(vec.push($x);)+ vec } }; } let v1: Vec<i32> = vec_shorthand!(); let v2 = vec_shorthand![1, 2, 3]; let v3 = vec_shorthand![1, 2, 3,]; // Trailing comma ok }
Repetition Operators
*- Zero or more repetitions+- One or more repetitions?- Zero or one (optional)
#![allow(unused)] fn main() { macro_rules! create_enum { ($name:ident { $($variant:ident),* }) => { enum $name { $($variant,)* } }; } create_enum!(Color { Red, Green, Blue }); macro_rules! sum { ($x:expr) => ($x); ($x:expr, $($rest:expr),+) => { $x + sum!($($rest),+) }; } let total = sum!(1, 2, 3, 4); // 10 }
Hygienic Macros
Rust macros are hygienic - they don’t accidentally capture or interfere with variables:
#![allow(unused)] fn main() { macro_rules! using_a { ($e:expr) => { { let a = 42; $e } }; } let a = "outer"; let result = using_a!(a); // Uses outer 'a', not the one in macro }
To intentionally break hygiene:
#![allow(unused)] fn main() { macro_rules! create_and_use { ($name:ident) => { let $name = 42; println!("{}", $name); }; } create_and_use!(my_var); // Creates my_var in caller's scope }
Debugging Macros
Use cargo expand to see what a macro expands to:
cargo install cargo-expand
cargo expand
Procedural Macros
Procedural macros are more powerful but require a separate crate:
Types of Procedural Macros
- Custom Derive Macros
- Attribute Macros
- Function-like Macros
Setup
# Cargo.toml
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
proc-macro2 = "1.0"
Custom Derive Macro Example
// src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
let gen = quote! {
impl HelloMacro for #name {
fn hello() {
println!("Hello from {}!", stringify!(#name));
}
}
};
gen.into()
}
Usage:
trait HelloMacro {
fn hello();
}
#[derive(HelloMacro)]
struct MyStruct;
MyStruct::hello(); // Prints: Hello from MyStruct!
Attribute Macro Example
#[proc_macro_attribute]
pub fn route(args: TokenStream, input: TokenStream) -> TokenStream {
let item = parse_macro_input!(input as syn::ItemFn);
let args = parse_macro_input!(args as syn::LitStr);
// Modify function based on attribute arguments
quote! {
#[web::route(#args)]
item
}.into()
}
Usage:
#[route("/api/users")]
async fn get_users() -> Response {
// Handler implementation
}
Function-like Procedural Macro
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as syn::LitStr);
// Parse SQL and generate code
quote! {
// Generated code here
}.into()
}
Usage:
let query = sql!("SELECT * FROM users WHERE id = ?");
Derive Macros in Practice
You will encounter derive macros throughout the Day 4 exercise project. Understanding that they generate trait implementations from struct/enum definitions is the key concept — you rarely need to write your own proc macros.
Common derive macros in the Rust ecosystem:
serde:#[derive(Serialize, Deserialize)]— automatic JSON/TOML/etc. serialization (covered in Chapter 17)clap:#[derive(Parser)]— command-line argument parsing from struct fieldsderive_more:#[derive(From, Display)]— common trait implementations without boilerplate
These macros follow the same pattern: annotate your type with #[derive(MacroName)], and the proc macro generates the trait implementation at compile time.
Best Practices
- Prefer Functions Over Macros: Use macros only when functions can’t achieve your goal
- Keep Macros Simple: Complex macros are hard to debug and maintain
- Document Macro Behavior: Include examples and expansion examples
- Use Internal Rules: Hide implementation details with
@prefixed rules - Test Macro Expansions: Use
cargo expandto verify generated code - Consider Procedural Macros: For complex transformations, proc macros are clearer
- Maintain Hygiene: Avoid capturing external variables unless intentional
Limitations and Gotchas
- Type Information: Macros run before type checking
- Error Messages: Macro errors can be cryptic
- IDE Support: Limited autocomplete and navigation
- Compilation Time: Heavy macro use increases compile times
- Debugging: Harder to debug than regular code
Summary
Macros are a powerful metaprogramming tool in Rust:
- Declarative macros (
macro_rules!) for pattern-based code generation - Procedural macros for more complex AST transformations
- Hygiene prevents accidental variable capture
- Pattern matching on various syntax elements
- Repetition and recursion enable complex patterns
Use macros judiciously to eliminate boilerplate while maintaining code clarity.
Additional Resources
Chapter 16: Unsafe Rust & FFI
This chapter covers unsafe Rust operations and Foreign Function Interface (FFI) for interfacing with C/C++ code. Unsafe Rust provides low-level control when needed while FFI enables integration with existing system libraries and codebases.
Edition 2024 Note: Starting with Rust 1.85 and Edition 2024, all extern blocks must be marked as unsafe extern to make the unsafety of FFI calls explicit. This change improves clarity about where unsafe operations occur.
Part 1: Unsafe Rust Foundations
The Five Unsafe Superpowers
Unsafe Rust enables five specific operations that bypass Rust’s safety guarantees:
- Dereference raw pointers - Direct memory access
- Call unsafe functions/methods - Including FFI functions
- Access/modify mutable statics - Global state management
- Implement unsafe traits - Like
SendandSync - Access union fields - Memory reinterpretation
Raw Pointers
#![allow(unused)] fn main() { use std::ptr; // Creating raw pointers let mut num = 5; let r1 = &num as *const i32; // Immutable raw pointer let r2 = &mut num as *mut i32; // Mutable raw pointer // Dereferencing requires unsafe unsafe { println!("r1: {}", *r1); *r2 = 10; println!("r2: {}", *r2); } // Pointer arithmetic unsafe { let array = [1, 2, 3, 4, 5]; let ptr = array.as_ptr(); for i in 0..5 { println!("Value at offset {}: {}", i, *ptr.add(i)); } } }
Unsafe Functions and Methods
#![allow(unused)] fn main() { unsafe fn dangerous() { // Function body can perform unsafe operations } // Calling unsafe functions unsafe { dangerous(); } // Safe abstraction over unsafe code fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) { let len = values.len(); let ptr = values.as_mut_ptr(); assert!(mid <= len); unsafe { ( std::slice::from_raw_parts_mut(ptr, mid), std::slice::from_raw_parts_mut(ptr.add(mid), len - mid), ) } } }
Mutable Static Variables
#![allow(unused)] fn main() { // Note: In Edition 2024, references to `static mut` are a hard error // (`static_mut_refs` lint). Use atomics or raw pointers instead. // Better alternative: use atomic types use std::sync::atomic::{AtomicU32, Ordering}; static ATOMIC_COUNTER: AtomicU32 = AtomicU32::new(0); fn safe_increment() { ATOMIC_COUNTER.fetch_add(1, Ordering::SeqCst); } }
Unsafe Traits
#![allow(unused)] fn main() { unsafe trait Zeroable { // Trait is unsafe because implementor must guarantee safety } unsafe impl Zeroable for i32 { // We guarantee i32 can be safely zeroed } // Send and Sync are unsafe traits struct RawPointer(*const u8); unsafe impl Send for RawPointer {} unsafe impl Sync for RawPointer {} }
Unions
#![allow(unused)] fn main() { #[repr(C)] union IntOrFloat { i: i32, f: f32, } let mut u = IntOrFloat { i: 42 }; unsafe { // Accessing union fields is unsafe u.f = 3.14; println!("Float: {}", u.f); // Type punning (reinterpreting bits) println!("As int: {}", u.i); // Type punning: reinterprets float bits as int (well-defined for repr(C)) } }
Part 2: Calling C/C++ from Rust
Manual FFI Bindings
#![allow(unused)] fn main() { use std::ffi::{c_char, c_int, c_void, CString, CStr}; // Link to system libraries // Edition 2024 (Rust 1.85+): extern blocks must be marked `unsafe extern` #[link(name = "m")] // Math library unsafe extern "C" { fn sqrt(x: f64) -> f64; fn pow(base: f64, exponent: f64) -> f64; } // Safe wrapper pub fn safe_sqrt(x: f64) -> f64 { if x < 0.0 { panic!("Cannot take square root of negative number"); } unsafe { sqrt(x) } } // Working with strings unsafe extern "C" { fn strlen(s: *const c_char) -> usize; } pub fn string_length(s: &str) -> usize { let c_string = CString::new(s).expect("CString creation failed"); unsafe { strlen(c_string.as_ptr()) } } }
Complex C Structures
#![allow(unused)] fn main() { #[repr(C)] struct Point { x: f64, y: f64, } #[repr(C)] struct Rectangle { top_left: Point, bottom_right: Point, } unsafe extern "C" { fn calculate_area(rect: *const Rectangle) -> f64; } pub fn rect_area(rect: &Rectangle) -> f64 { unsafe { calculate_area(rect as *const Rectangle) } } }
Using Bindgen
# Cargo.toml
[build-dependencies]
bindgen = "0.70"
cc = "1.1"
// build.rs
use std::env;
use std::path::PathBuf;
fn main() {
// Compile C code
cc::Build::new()
.file("src/native.c")
.compile("native");
// Generate bindings
let bindings = bindgen::Builder::default()
.header("src/wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("Unable to generate bindings");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
}
// src/lib.rs
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
// Use generated bindings
pub fn use_native_function() {
unsafe {
let result = native_function(42);
println!("Result: {}", result);
}
}
Part 3: Exposing Rust to C/C++
Using cbindgen
# Cargo.toml
[lib]
crate-type = ["cdylib", "staticlib"]
[build-dependencies]
cbindgen = "0.29"
#![allow(unused)] fn main() { // src/lib.rs use std::ffi::{c_char, c_int, CStr}; #[no_mangle] pub extern "C" fn rust_add(a: c_int, b: c_int) -> c_int { a + b } #[no_mangle] pub extern "C" fn rust_greet(name: *const c_char) -> *mut c_char { let name = unsafe { assert!(!name.is_null()); CStr::from_ptr(name) }; let greeting = format!("Hello, {}!", name.to_string_lossy()); let c_string = std::ffi::CString::new(greeting).unwrap(); c_string.into_raw() } #[no_mangle] pub extern "C" fn rust_free_string(s: *mut c_char) { if s.is_null() { return; } unsafe { let _ = std::ffi::CString::from_raw(s); } } }
// build.rs
use std::env;
fn main() {
let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
cbindgen::Builder::new()
.with_crate(crate_dir)
.with_language(cbindgen::Language::C)
.generate()
.expect("Unable to generate bindings")
.write_to_file("include/rust_lib.h");
}
Part 4: C++ Integration with cxx
Using cxx for Safe C++ FFI
# Cargo.toml
[dependencies]
cxx = "1.0"
[build-dependencies]
cxx-build = "1.0"
// src/lib.rs
#[cxx::bridge]
mod ffi {
unsafe extern "C++" {
include!("cpp/include/blobstore.h");
type BlobstoreClient;
fn new_blobstore_client() -> UniquePtr<BlobstoreClient>;
fn put(&self, key: &str, value: &[u8]) -> Result<()>;
fn get(&self, key: &str) -> Vec<u8>;
}
extern "Rust" {
fn process_blob(data: &[u8]) -> Vec<u8>;
}
}
pub fn process_blob(data: &[u8]) -> Vec<u8> {
// Rust implementation
data.iter().map(|&b| b.wrapping_add(1)).collect()
}
pub fn use_blobstore() -> Result<(), Box<dyn std::error::Error>> {
let client = ffi::new_blobstore_client();
let key = "test_key";
let data = b"hello world";
client.put(key, data)?;
let retrieved = client.get(key);
Ok(())
}
// build.rs
fn main() {
cxx_build::bridge("src/lib.rs")
.file("cpp/src/blobstore.cc")
.std("c++17")
.compile("cxx-demo");
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=cpp/include/blobstore.h");
println!("cargo:rerun-if-changed=cpp/src/blobstore.cc");
}
Part 5: Platform-Specific Code & Conditional Compilation
#![allow(unused)] fn main() { #[cfg(target_os = "windows")] mod windows { use winapi::um::fileapi::GetFileAttributesW; use winapi::um::winnt::FILE_ATTRIBUTE_HIDDEN; use std::os::windows::ffi::OsStrExt; use std::ffi::OsStr; pub fn is_hidden(path: &std::path::Path) -> bool { let wide: Vec<u16> = OsStr::new(path) .encode_wide() .chain(Some(0)) .collect(); unsafe { let attrs = GetFileAttributesW(wide.as_ptr()); attrs != u32::MAX && (attrs & FILE_ATTRIBUTE_HIDDEN) != 0 } } } #[cfg(target_os = "linux")] mod linux { pub fn is_hidden(path: &std::path::Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .map(|name| name.starts_with('.')) .unwrap_or(false) } } }
Part 6: Safety Patterns and Best Practices
Safe Abstraction Pattern
pub struct SafeWrapper {
ptr: *mut SomeFFIType,
}
impl SafeWrapper {
pub fn new() -> Option<Self> {
unsafe {
let ptr = ffi_create_object();
if ptr.is_null() {
None
} else {
Some(SafeWrapper { ptr })
}
}
}
pub fn do_something(&self) -> Result<i32, String> {
unsafe {
let result = ffi_do_something(self.ptr);
if result < 0 {
Err("Operation failed".to_string())
} else {
Ok(result)
}
}
}
}
impl Drop for SafeWrapper {
fn drop(&mut self) {
unsafe {
if !self.ptr.is_null() {
ffi_destroy_object(self.ptr);
}
}
}
}
// Only implement these if the underlying C library is truly thread-safe!
// unsafe impl Send for SafeWrapper {}
// unsafe impl Sync for SafeWrapper {}
Error Handling Across FFI
The key principle: convert Rust’s Result/panic into C-compatible error codes at the FFI boundary. Common patterns:
- Return error codes (
0= success, negative = error) with an out-parameter for the result - Use a
*mut ErrorInfostruct to pass error details (code + message) - Catch panics with
std::panic::catch_unwindto prevent unwinding across FFI boundaries
Part 6: Testing FFI Code
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_ffi_wrapper() { // Mock the FFI functions in tests struct MockFFI; impl MockFFI { fn mock_function(&self, input: i32) -> i32 { input * 2 } } let mock = MockFFI; assert_eq!(mock.mock_function(21), 42); } #[test] fn test_error_handling() { let mut error = ErrorInfo { code: 0, message: ptr::null_mut(), }; let result = rust_operation( ptr::null(), &mut error as *mut ErrorInfo, ); assert!(result.is_null()); assert_eq!(unsafe { error.code }, 1); } } }
Part 7: Volatile Memory Access & HAL Patterns
In embedded systems, hardware registers are mapped to specific memory addresses. The compiler must not optimize away reads or writes to these addresses, even if the values appear unused — because the hardware side-effects matter.
Volatile Reads and Writes
core::ptr::read_volatile and core::ptr::write_volatile guarantee that every access reaches memory, preventing the compiler from eliding or reordering them:
#![allow(unused)] fn main() { use core::ptr; // Memory-mapped I/O: a hardware register at a fixed address const GPIO_OUTPUT_REG: *mut u32 = 0x6000_4004 as *mut u32; const GPIO_INPUT_REG: *const u32 = 0x6000_403C as *const u32; /// Set a GPIO pin high by writing to the output register. /// /// # Safety /// Caller must ensure the address is a valid, mapped hardware register. unsafe fn gpio_set_high(pin: u8) { let current = ptr::read_volatile(GPIO_OUTPUT_REG); ptr::write_volatile(GPIO_OUTPUT_REG, current | (1 << pin)); } /// Read the current state of all GPIO input pins. /// /// # Safety /// Caller must ensure the address is a valid, mapped hardware register. unsafe fn gpio_read_all() -> u32 { ptr::read_volatile(GPIO_INPUT_REG) } }
Why not just use *ptr? A normal dereference may be optimized away if the compiler decides the value is never “really” used, or it may be merged with adjacent accesses. Hardware registers have side effects on read (e.g. clearing an interrupt flag) or write (e.g. toggling a pin), so every access must be preserved.
The Memory-Mapped I/O (MMIO) Pattern
Embedded Rust crates typically wrap raw register addresses in a typed struct:
/// A register block representing a peripheral's control registers.
#[repr(C)]
struct GpioRegisters {
output: u32, // offset 0x00
output_set: u32, // offset 0x04
output_clr: u32, // offset 0x08
input: u32, // offset 0x0C
}
impl GpioRegisters {
/// # Safety
/// The base address must point to a valid GPIO register block.
unsafe fn from_base(base: usize) -> &'static mut Self {
&mut *(base as *mut Self)
}
fn set_pin(&mut self, pin: u8) {
// Safety: this struct is only constructed over valid MMIO memory
unsafe {
core::ptr::write_volatile(&mut self.output_set, 1 << pin);
}
}
fn read_input(&self) -> u32 {
unsafe { core::ptr::read_volatile(&self.input) }
}
}
The HAL Trait Pattern (embedded-hal)
The embedded-hal crate defines vendor-neutral traits that any microcontroller HAL can implement. This lets application code and drivers be portable across chips:
use embedded_hal::digital::OutputPin;
/// Blink an LED using any OutputPin — works on ESP32, STM32, nRF, etc.
fn blink<P: OutputPin>(led: &mut P, delay_ms: u32) {
led.set_high().ok();
// ... delay ...
led.set_low().ok();
}
Chip vendors (like esp-hal, stm32-hal) provide concrete types that implement these traits, wrapping volatile register access in safe abstractions. This is the same pattern we saw in Part 6 — safe wrappers around unsafe internals — applied to hardware.
Day 4 preview: In the ESP32-C3 exercises, you will use esp-hal which builds on these exact patterns — Output::new() returns a type implementing OutputPin, hiding all volatile register manipulation behind a safe, type-checked API.
Best Practices
- Minimize Unsafe Code: Keep unsafe blocks small and isolated
- Document Safety Requirements: Clearly state what callers must guarantee
- Use Safe Abstractions: Wrap unsafe code in safe APIs
- Validate All Inputs: Never trust data from FFI boundaries
- Handle Errors Gracefully: Convert panics to error codes at FFI boundaries
- Test Thoroughly: Include fuzzing and property-based testing
- Use Tools: Run Miri, Valgrind, and sanitizers on FFI code
Common Pitfalls
- Memory Management: Ensure consistent allocation/deallocation across FFI
- String Encoding: C uses null-terminated strings, Rust doesn’t
- ABI Compatibility: Always use
#[repr(C)]for FFI structs - Lifetime Management: Raw pointers don’t encode lifetimes
- Thread Safety: Verify thread safety of external libraries
Summary
Unsafe Rust and FFI provide powerful tools for systems programming:
- Unsafe Rust enables low-level operations with explicit opt-in
- FFI allows seamless integration with C/C++ codebases
- Safe abstractions wrap unsafe code in safe interfaces
- Tools like bindgen and cbindgen automate binding generation
- cxx provides safe C++ interop
Always prefer safe Rust, use unsafe only when necessary, and wrap it in safe abstractions.
Additional Resources
Chapter 17: Serde & Serialization
Serde is Rust’s de facto serialization framework. The name is a portmanteau of serialize and deserialize. Nearly every Rust project that reads or writes structured data – JSON APIs, configuration files, binary protocols, CSV exports – uses serde. This chapter introduces serde’s derive model, the serde_json crate, and the attributes you will use extensively in the Day 4 project (Chapters 22-23).
1. Why Serde?
Three properties make serde the standard choice:
-
Format-agnostic. You derive
SerializeandDeserializeonce on your types. The same derives work with JSON, TOML, YAML, MessagePack, bincode, CSV, and dozens of other formats – you just swap the format crate. -
Zero-cost abstraction. Serde uses Rust’s trait system and monomorphization to generate specialized code at compile time. There is no runtime reflection and no boxing overhead in the common path.
-
Zero-copy deserialization. For formats that support it, serde can deserialize borrowed data (
&str,&[u8]) without allocating, which matters in performance-sensitive applications.
Brief comparison with C#
C# developers typically reach for System.Text.Json (built-in since .NET Core 3.0) or Newtonsoft.Json. Serde fills the same role but is format-agnostic by design – System.Text.Json is JSON-only, whereas serde’s traits work across all supported formats. The derive-macro approach is similar to C#’s JSON source generators introduced in .NET 6.
2. The Derive Model
Serde provides two traits: Serialize (for turning Rust values into a data format) and Deserialize (for parsing a data format back into Rust values). You almost never implement these by hand. Instead, you use derive macros – the same mechanism covered in Chapter 15: Macros & Code Generation.
Cargo.toml setup
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
The "derive" feature flag enables the #[derive(Serialize, Deserialize)] proc macros. Without it, you would need to implement the traits manually.
Basic example
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
struct Sensor {
id: u32,
label: String,
temperature: f64,
active: bool,
}
fn main() {
let sensor = Sensor {
id: 42,
label: "main-hall".to_string(),
temperature: 21.5,
active: true,
};
// Serialize to JSON
let json = serde_json::to_string_pretty(&sensor).unwrap();
println!("{json}");
// Deserialize back
let parsed: Sensor = serde_json::from_str(&json).unwrap();
println!("{parsed:?}");
}
Output:
{
"id": 42,
"label": "main-hall",
"temperature": 21.5,
"active": true
}
Sensor { id: 42, label: "main-hall", temperature: 21.5, active: true }
Debug vs JSON: different things
Notice that Debug output ({:?}) and JSON output are distinct representations. Debug is for developer diagnostics and uses Rust syntax. JSON is a data interchange format with its own rules. Deriving both is standard practice, but they serve different purposes.
3. Working with serde_json
serde_json is the JSON format implementation for serde. It provides the functions you will use most often.
Serialization
use serde::Serialize;
#[derive(Serialize)]
struct Point {
x: f64,
y: f64,
}
fn main() -> Result<(), serde_json::Error> {
let p = Point { x: 1.0, y: 2.5 };
// Compact output: {"x":1.0,"y":2.5}
let compact = serde_json::to_string(&p)?;
println!("{compact}");
// Pretty-printed output
let pretty = serde_json::to_string_pretty(&p)?;
println!("{pretty}");
Ok(())
}
Deserialization
The target type must be specified so serde knows what to parse into. You can use a type annotation or the turbofish syntax:
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Point {
x: f64,
y: f64,
}
fn main() -> Result<(), serde_json::Error> {
let input = r#"{"x": 3.0, "y": 4.0}"#;
// Type annotation
let p1: Point = serde_json::from_str(input)?;
println!("{p1:?}");
// Turbofish syntax
let p2 = serde_json::from_str::<Point>(input)?;
println!("{p2:?}");
Ok(())
}
Untyped JSON with serde_json::Value
When you do not know the schema ahead of time – or want to inspect JSON without defining a full struct – use serde_json::Value:
use serde_json::Value;
fn main() -> Result<(), serde_json::Error> {
let input = r#"{"name": "Alice", "scores": [95, 87, 92]}"#;
let v: Value = serde_json::from_str(input)?;
// Index into the value with [] or .get()
println!("Name: {}", v["name"]);
println!("First score: {}", v["scores"][0]);
// .get() returns Option<&Value> -- safer than indexing
if let Some(name) = v.get("name").and_then(Value::as_str) {
println!("Name as &str: {name}");
}
Ok(())
}
Value is an enum with variants Null, Bool(bool), Number(Number), String(String), Array(Vec<Value>), and Object(Map<String, Value>). It is similar to JsonNode (.NET 6+) or JToken/JObject (Newtonsoft.Json) in C#.
You can also build JSON values programmatically with the json! macro:
use serde_json::json;
let response = json!({
"status": "ok",
"count": 3,
"items": ["a", "b", "c"]
});
println!("{}", serde_json::to_string_pretty(&response).unwrap());
This macro is used in the Day 4 Axum handlers (Chapter 22) for constructing ad-hoc JSON responses.
Error handling
serde_json::from_str returns Result<T, serde_json::Error>. The error type provides location information (line and column) for parse failures:
use serde::Deserialize;
#[derive(Deserialize)]
struct Config {
port: u16,
}
fn main() {
let bad_input = r#"{"port": "not_a_number"}"#;
match serde_json::from_str::<Config>(bad_input) {
Ok(config) => println!("Port: {}", config.port),
Err(e) => eprintln!("Parse error: {e}"),
// Output: Parse error: invalid type: string "not_a_number",
// expected u16 at line 1 column 23
}
}
4. Serde Attributes
Serde attributes let you control how fields and types map to the data format without writing custom serialization logic. These are the attributes you will encounter in the Day 4 project.
#[serde(rename_all = "...")]
Applies a naming convention to all fields or variants:
use serde::{Serialize, Deserialize};
// camelCase -- common for JSON APIs
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UserProfile {
first_name: String, // -> "firstName"
last_name: String, // -> "lastName"
email_address: String, // -> "emailAddress"
}
// lowercase -- used on enums in Day 4 (Chapter 23)
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum JobState {
Pending, // -> "pending"
Processing, // -> "processing"
Complete, // -> "complete"
Failed, // -> "failed"
}
Other supported conventions: "UPPERCASE", "PascalCase", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE".
#[serde(skip_serializing_if = "...")]
Omits a field from the output when a condition is true. Most commonly used with Option fields to skip null values:
use serde::Serialize;
#[derive(Serialize)]
struct JobStatus {
id: String,
state: String,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
// When error is None, the JSON output omits the field entirely:
// {"id":"abc","state":"complete"}
//
// When error is Some, it appears:
// {"id":"abc","state":"failed","error":"disk full"}
This is used on JobStatus in Day 4 (Chapter 22) so that successful jobs produce cleaner JSON responses.
#[serde(default)]
Uses Default::default() for fields missing during deserialization:
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct ServerConfig {
host: String,
#[serde(default)]
port: u16, // defaults to 0 if missing
#[serde(default)]
debug: bool, // defaults to false if missing
#[serde(default)]
tags: Vec<String>, // defaults to empty Vec if missing
}
fn main() {
let input = r#"{"host": "localhost"}"#;
let config: ServerConfig = serde_json::from_str(input).unwrap();
println!("{config:?}");
// ServerConfig { host: "localhost", port: 0, debug: false, tags: [] }
}
You can also provide a custom default function: #[serde(default = "default_port")] where fn default_port() -> u16 { 8080 }.
#[serde(rename = "...")]
Renames an individual field. Useful when the JSON key is a Rust keyword or follows a different convention:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct ApiResponse {
#[serde(rename = "type")]
kind: String, // JSON key is "type", which is a Rust keyword
#[serde(rename = "error_code")]
code: u32, // JSON key differs from Rust field name
}
#[serde(flatten)]
Inlines the fields of a nested struct into the parent:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct Pagination {
page: u32,
per_page: u32,
}
#[derive(Serialize, Deserialize)]
struct UserList {
users: Vec<String>,
#[serde(flatten)]
pagination: Pagination,
}
// Without flatten: {"users":["Alice"],"pagination":{"page":1,"per_page":10}}
// With flatten: {"users":["Alice"],"page":1,"per_page":10}
Attribute summary
| Attribute | Level | Effect |
|---|---|---|
#[serde(rename_all = "camelCase")] | Struct / Enum | Rename all fields or variants |
#[serde(rename = "x")] | Field / Variant | Rename a single field or variant |
#[serde(skip_serializing_if = "...")] | Field | Omit field when condition is true |
#[serde(default)] | Field / Struct | Use Default for missing fields |
#[serde(flatten)] | Field | Inline nested struct fields |
#[serde(skip)] | Field | Never serialize or deserialize |
#[serde(alias = "x")] | Field | Accept an alternative name during deserialization |
The full list is available at serde.rs/attributes.html.
5. Enums in Serde
Rust enums are more expressive than C# enums – they can carry data in each variant. Serde supports four representations for enums with data, controlled by container attributes.
Externally tagged (default)
Each variant wraps its data under the variant name as a key:
use serde::Serialize;
#[derive(Serialize)]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}
// Shape::Circle { radius: 5.0 } serializes to:
// {"Circle":{"radius":5.0}}
Internally tagged
The tag is a field inside the object. This is the most common pattern for API discriminated unions:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Event {
Login { user: String },
Logout { user: String, reason: String },
Heartbeat,
}
// Event::Login { user: "alice".into() } serializes to:
// {"type":"Login","user":"alice"}
//
// Event::Heartbeat serializes to:
// {"type":"Heartbeat"}
This is similar to C#’s [JsonDerivedType] with a type discriminator, available since .NET 7.
Adjacently tagged
The tag and content are sibling fields:
use serde::Serialize;
#[derive(Serialize)]
#[serde(tag = "t", content = "c")]
enum Message {
Text(String),
Image { url: String, alt: String },
}
// Message::Text("hello".into()) serializes to:
// {"t":"Text","c":"hello"}
Untagged
No discriminator – serde tries each variant in order until one matches:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
enum StringOrNumber {
Num(f64),
Str(String),
}
fn main() {
let a: StringOrNumber = serde_json::from_str("42").unwrap();
let b: StringOrNumber = serde_json::from_str(r#""hello""#).unwrap();
println!("{a:?}, {b:?}");
// Num(42.0), Str("hello")
}
Untagged enums are convenient but produce poor error messages on failure, because serde cannot tell you which variant was “closest” to matching. Prefer tagged representations when possible.
Enum representation summary
| Attribute | JSON shape | Best for |
|---|---|---|
| (none – default) | {"Variant":{...}} | Rust-to-Rust communication |
#[serde(tag = "type")] | {"type":"Variant",...} | APIs with discriminator field |
#[serde(tag = "t", content = "c")] | {"t":"Variant","c":{...}} | APIs separating tag and payload |
#[serde(untagged)] | {...} | Flexible input parsing |
Practical example: API event stream
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
enum WebhookEvent {
OrderPlaced {
order_id: u64,
total_cents: u64,
},
OrderShipped {
order_id: u64,
tracking_number: String,
},
OrderCancelled {
order_id: u64,
reason: String,
},
}
fn main() {
let events_json = r#"[
{"event":"order_placed","order_id":1001,"total_cents":4999},
{"event":"order_shipped","order_id":1001,"tracking_number":"1Z999AA10123456784"},
{"event":"order_cancelled","order_id":1002,"reason":"customer request"}
]"#;
let events: Vec<WebhookEvent> = serde_json::from_str(events_json).unwrap();
for event in &events {
println!("{event:?}");
}
}
6. Other Formats
Because Serialize and Deserialize are format-agnostic traits, the same struct works with any format crate. You only change the serialization call.
Switching from JSON to TOML
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
struct DatabaseConfig {
host: String,
port: u16,
name: String,
}
fn main() {
let config = DatabaseConfig {
host: "localhost".to_string(),
port: 5432,
name: "myapp".to_string(),
};
// Serialize to JSON
let json = serde_json::to_string_pretty(&config).unwrap();
println!("JSON:\n{json}\n");
// Serialize to TOML -- same struct, different format crate
let toml = toml::to_string_pretty(&config).unwrap();
println!("TOML:\n{toml}");
}
JSON:
{
"host": "localhost",
"port": 5432,
"name": "myapp"
}
TOML:
host = "localhost"
port = 5432
name = "myapp"
Common format crates
| Crate | Format | Typical use case |
|---|---|---|
serde_json | JSON | Web APIs, configuration |
toml | TOML | Configuration files (Cargo.toml itself is TOML) |
serde_yml | YAML | Kubernetes manifests, CI configs (replaces deprecated serde_yaml) |
bincode | Binary | Compact binary serialization, IPC |
csv | CSV | Tabular data import/export |
rmp-serde | MessagePack | Efficient binary alternative to JSON |
All of these crates expose to_string / from_str (or to_vec / from_slice for binary formats) with the same pattern. Once you know serde, switching formats is a one-line change.
7. Comparison with C#
| C# / .NET | Rust (serde) | Notes |
|---|---|---|
[JsonPropertyName("x")] | #[serde(rename = "x")] | Field renaming |
JsonNamingPolicy.CamelCase | #[serde(rename_all = "camelCase")] | Naming convention |
[JsonIgnore(Condition = WhenWritingNull)] | #[serde(skip_serializing_if = "Option::is_none")] | Skip null fields |
JsonSerializer.Serialize(obj) | serde_json::to_string(&obj)? | Serialize to string |
JsonSerializer.Deserialize<T>(str) | serde_json::from_str::<T>(str)? | Deserialize from string |
JsonDocument / JObject | serde_json::Value | Untyped JSON access |
[JsonDerivedType] discriminator | #[serde(tag = "type")] | Tagged unions |
| Source generators (.NET 6+) | Derive macros | Compile-time code generation |
JsonSerializerOptions (global) | Per-type attributes | Configuration scope |
Key difference: in C#, serialization settings are typically configured globally via JsonSerializerOptions. In serde, attributes are placed directly on each type, making the serialization contract explicit and local. This means you can look at a struct definition and immediately understand its JSON representation without checking a distant configuration object.
8. Exercise
Define a WeatherReport struct with nested data, derive Serialize and Deserialize, apply serde attributes, and round-trip through JSON.
Starter code
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct WeatherReport {
station_id: String,
location: Location,
current: CurrentConditions,
#[serde(skip_serializing_if = "Option::is_none")]
alert: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Location {
city: String,
country: String,
#[serde(rename = "lat")]
latitude: f64,
#[serde(rename = "lon")]
longitude: f64,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct CurrentConditions {
temperature_celsius: f64,
humidity_percent: u8,
#[serde(default)]
wind_speed_kmh: f64,
condition: WeatherCondition,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
enum WeatherCondition {
Sunny,
Cloudy,
Rainy,
Snowy,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_report() -> WeatherReport {
WeatherReport {
station_id: "ZRH-01".to_string(),
location: Location {
city: "Zurich".to_string(),
country: "CH".to_string(),
latitude: 47.3769,
longitude: 8.5417,
},
current: CurrentConditions {
temperature_celsius: 18.5,
humidity_percent: 65,
wind_speed_kmh: 12.0,
condition: WeatherCondition::Sunny,
},
alert: None,
}
}
#[test]
fn serialize_and_deserialize_round_trip() {
let report = sample_report();
let json = serde_json::to_string(&report).unwrap();
let parsed: WeatherReport = serde_json::from_str(&json).unwrap();
assert_eq!(report, parsed);
}
#[test]
fn camel_case_field_names() {
let report = sample_report();
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("stationId"));
assert!(json.contains("temperatureCelsius"));
assert!(json.contains("humidityPercent"));
assert!(!json.contains("station_id"));
}
#[test]
fn location_fields_renamed() {
let report = sample_report();
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains(r#""lat""#));
assert!(json.contains(r#""lon""#));
assert!(!json.contains("latitude"));
}
#[test]
fn none_alert_is_omitted() {
let report = sample_report();
let json = serde_json::to_string(&report).unwrap();
assert!(!json.contains("alert"));
}
#[test]
fn some_alert_is_included() {
let mut report = sample_report();
report.alert = Some("Heat warning".to_string());
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("alert"));
assert!(json.contains("Heat warning"));
}
#[test]
fn enum_serializes_lowercase() {
let report = sample_report();
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains(r#""sunny""#));
assert!(!json.contains("Sunny"));
}
#[test]
fn missing_wind_speed_uses_default() {
let input = r#"{
"stationId": "ZRH-01",
"location": {"city":"Zurich","country":"CH","lat":47.3769,"lon":8.5417},
"current": {
"temperatureCelsius": 18.5,
"humidityPercent": 65,
"condition": "sunny"
}
}"#;
let report: WeatherReport = serde_json::from_str(input).unwrap();
assert!((report.current.wind_speed_kmh - 0.0).abs() < f64::EPSILON);
}
#[test]
fn deserialize_from_json_string() {
let input = r#"{
"stationId": "BER-03",
"location": {"city":"Berlin","country":"DE","lat":52.52,"lon":13.405},
"current": {
"temperatureCelsius": -2.0,
"humidityPercent": 80,
"windSpeedKmh": 25.0,
"condition": "snowy"
},
"alert": "Freezing conditions"
}"#;
let report: WeatherReport = serde_json::from_str(input).unwrap();
assert_eq!(report.station_id, "BER-03");
assert_eq!(report.location.city, "Berlin");
assert_eq!(report.current.condition, WeatherCondition::Snowy);
assert_eq!(report.alert, Some("Freezing conditions".to_string()));
}
}
Summary
| Concept | Key takeaway |
|---|---|
Serialize / Deserialize | Derive macros that generate format-agnostic serialization code |
serde_json::to_string | Serialize a value to a JSON string |
serde_json::from_str | Deserialize a JSON string into a typed value |
serde_json::Value | Untyped JSON for dynamic or unknown schemas |
#[serde(rename_all = "...")] | Apply a naming convention to all fields or variants |
#[serde(rename = "...")] | Rename a single field or variant |
#[serde(skip_serializing_if = "...")] | Conditionally omit a field |
#[serde(default)] | Provide a default for missing fields during deserialization |
#[serde(flatten)] | Inline nested struct fields |
#[serde(tag = "...")] | Internally tagged enum representation |
#[serde(untagged)] | Try each variant in order, no discriminator |
| Format-agnostic | Same derives work with JSON, TOML, YAML, bincode, etc. |
This chapter prepares you for Day 4, where serde is used in the ESP32-C3 project to serialize temperature readings and commands as JSON over USB serial (Chapter 23).
Chapter 18: Async and Concurrency
Learning Objectives
- Master thread-based concurrency with Arc, Mutex, and channels
- Understand async/await syntax and the Future trait
- Compare threads vs async for different workloads
- Build concurrent applications with Tokio
- Apply synchronization patterns effectively
Concurrency in Rust: Two Approaches
Rust provides two main models for concurrent programming, each with distinct advantages:
| Aspect | Threads | Async/Await |
|---|---|---|
| Best for | CPU-intensive work | I/O-bound operations |
| Memory overhead | ~2MB per thread | ~2KB per task |
| Scheduling | OS kernel | User-space runtime |
| Blocking operations | Normal | Must use async variants |
| Ecosystem maturity | Complete | Mature (tokio, axum, etc.) |
| Learning curve | Moderate | Steeper initially |
Part 1: Thread-Based Concurrency
The Problem with Shared Mutable State
Rust prevents data races at compile time through its ownership system:
use std::thread;
// This won't compile - Rust prevents the data race
fn broken_example() {
let mut counter = 0;
let handle = thread::spawn(|| {
counter += 1; // Error: cannot capture mutable reference
});
handle.join().unwrap();
}
Arc: Shared Ownership Across Threads
Arc<T> (Atomic Reference Counting) enables multiple threads to share ownership of the same data:
use std::sync::Arc;
use std::thread;
fn share_immutable_data() {
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let data_clone = Arc::clone(&data);
let handle = thread::spawn(move || {
println!("Thread {}: sum = {}", i, data_clone.iter().sum::<i32>());
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
Key properties of Arc:
- Reference counting is atomic (thread-safe)
- Cloning is cheap (only increments counter)
- Data is immutable by default
- Memory freed when last reference drops
Mutex: Safe Mutable Access
Mutex<T> provides mutual exclusion for mutable data:
use std::sync::{Arc, Mutex};
use std::thread;
fn safe_shared_counter() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
for _ in 0..100 {
let mut num = counter_clone.lock().unwrap();
*num += 1;
// Lock automatically released when guard drops
}
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap());
}
RwLock: Optimizing for Readers
When reads significantly outnumber writes, RwLock<T> provides better performance:
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
fn reader_writer_pattern() {
let data = Arc::new(RwLock::new(vec![1, 2, 3]));
let mut handles = vec![];
// Multiple readers can access simultaneously
for i in 0..5 {
let data = Arc::clone(&data);
handles.push(thread::spawn(move || {
let guard = data.read().unwrap();
println!("Reader {}: {:?}", i, *guard);
}));
}
// Single writer waits for all readers
let data_clone = Arc::clone(&data);
handles.push(thread::spawn(move || {
let mut guard = data_clone.write().unwrap();
guard.push(4);
println!("Writer: added element");
}));
for handle in handles {
handle.join().unwrap();
}
}
Channels: Message Passing
Channels avoid shared state entirely through message passing:
use std::sync::mpsc;
use std::thread;
fn channel_example() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let values = vec!["hello", "from", "thread"];
for val in values {
tx.send(val).unwrap();
}
});
for received in rx {
println!("Got: {}", received);
}
}
// Multiple producers
fn fan_in_pattern() {
let (tx, rx) = mpsc::channel();
for i in 0..3 {
let tx_clone = tx.clone();
thread::spawn(move || {
tx_clone.send(format!("Message from thread {}", i)).unwrap();
});
}
drop(tx); // Close original sender
for msg in rx {
println!("{}", msg);
}
}
Part 2: Async Programming
Understanding Futures
Futures represent values that will be available at some point:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
// Futures are state machines polled to completion
trait SimpleFuture {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
// async/await is syntactic sugar for futures
async fn simple_async() -> i32 {
42 // Returns impl Future<Output = i32>
}
Async Blocks
Just as closures create anonymous functions, async blocks create anonymous futures. They are written async { ... } or async move { ... }:
use std::future::Future;
fn make_future(x: i32) -> impl Future<Output = i32> {
async move {
x + 1 // captures `x` by move, returns i32 when awaited
}
}
#[tokio::main]
async fn main() {
// Inline async block — useful in combinators, closures, or spawn calls
let fut = async {
let a = fetch_value().await;
let b = fetch_value().await;
a + b
};
let result = fut.await;
println!("result = {}", result);
}
async fn fetch_value() -> i32 { 42 }
Key points:
- An async block produces a value of type
impl Future<Output = T>whereTis the type of the last expression. async move { ... }captures variables by value (likemove ||closures). Withoutmove, variables are captured by reference.- Async blocks are lazy — nothing runs until the future is
.awaited or polled. - They are commonly used to construct futures inline, e.g. when passing to
tokio::spawn,join!, or iterator adapters likemap.
The Tokio Runtime
Tokio provides a production-ready async runtime:
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
println!("Starting");
sleep(Duration::from_millis(100)).await;
println!("Done after 100ms");
}
// Alternative runtime configurations
fn runtime_options() {
// Single-threaded runtime
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
// Multi-threaded runtime
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.unwrap();
rt.block_on(async {
// Your async code here
});
}
Concurrent Async Operations
Multiple futures can run concurrently without threads:
use tokio::time::{sleep, Duration};
async fn concurrent_operations() {
// Sequential - takes 300ms total
operation("A", 100).await;
operation("B", 100).await;
operation("C", 100).await;
// Concurrent - takes 100ms total
tokio::join!(
operation("X", 100),
operation("Y", 100),
operation("Z", 100)
);
}
async fn operation(name: &str, ms: u64) {
println!("Starting {}", name);
sleep(Duration::from_millis(ms)).await;
println!("Completed {}", name);
}
Spawning Async Tasks
Tasks are the async equivalent of threads:
use tokio::task;
async fn spawn_tasks() {
let mut handles = vec![];
for i in 0..10 {
let handle = task::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
i * i // Return value
});
handles.push(handle);
}
let mut results = vec![];
for handle in handles {
results.push(handle.await.unwrap());
}
println!("Results: {:?}", results);
}
Select: Racing Futures
The select! macro enables complex control flow:
use tokio::time::{sleep, Duration, timeout};
async fn select_example() {
loop {
tokio::select! {
_ = sleep(Duration::from_secs(1)) => {
println!("Timer expired");
}
result = async_operation() => {
println!("Operation completed: {}", result);
break;
}
_ = tokio::signal::ctrl_c() => {
println!("Interrupted");
break;
}
}
}
}
async fn async_operation() -> String {
sleep(Duration::from_millis(500)).await;
"Success".to_string()
}
Async I/O Operations
Async excels at I/O-bound work:
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
async fn file_io() -> Result<(), Box<dyn std::error::Error>> {
// Read file
let mut file = File::open("input.txt").await?;
let mut contents = String::new();
file.read_to_string(&mut contents).await?;
// Write file
let mut output = File::create("output.txt").await?;
output.write_all(contents.as_bytes()).await?;
Ok(())
}
async fn tcp_server() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
loop {
let (mut socket, addr) = listener.accept().await?;
tokio::spawn(async move {
let mut buf = vec![0; 1024];
loop {
let n = match socket.read(&mut buf).await {
Ok(n) if n == 0 => return, // Connection closed
Ok(n) => n,
Err(e) => {
eprintln!("Failed to read: {}", e);
return;
}
};
if let Err(e) = socket.write_all(&buf[0..n]).await {
eprintln!("Failed to write: {}", e);
return;
}
}
});
}
}
Error Handling in Async Code
Error handling follows the same patterns with async-specific considerations:
use std::time::Duration;
use tokio::time::timeout;
async fn with_timeout() -> Result<String, Box<dyn std::error::Error>> {
// Timeout wraps the future
timeout(Duration::from_secs(5), long_operation()).await?
}
async fn long_operation() -> Result<String, std::io::Error> {
// Simulated long operation
tokio::time::sleep(Duration::from_secs(2)).await;
Ok("Completed".to_string())
}
// Retry with exponential backoff
async fn retry_operation<F, Fut, T, E>(
mut f: F,
max_attempts: u32,
) -> Result<T, E>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, E>>,
E: std::fmt::Debug,
{
let mut delay = Duration::from_millis(100);
for attempt in 1..=max_attempts {
match f().await {
Ok(val) => return Ok(val),
Err(e) if attempt == max_attempts => return Err(e),
Err(e) => {
eprintln!("Attempt {} failed: {:?}, retrying...", attempt, e);
tokio::time::sleep(delay).await;
delay *= 2; // Exponential backoff
}
}
}
unreachable!()
}
Choosing Between Threads and Async
When to Use Threads
Threads are optimal for:
- CPU-intensive work: Computation, data processing, cryptography
- Parallel algorithms: Matrix operations, image processing
- Blocking operations: Legacy libraries, system calls
- Simple concurrency: Independent units of work
Example of CPU-bound work better suited for threads:
use std::thread;
fn parallel_computation(data: Vec<u64>) -> u64 {
let chunk_size = data.len() / num_cpus::get();
let mut handles = vec![];
for chunk in data.chunks(chunk_size) {
let chunk = chunk.to_vec();
let handle = thread::spawn(move || {
chunk.iter().map(|&x| x * x).sum::<u64>()
});
handles.push(handle);
}
handles.into_iter()
.map(|h| h.join().unwrap())
.sum()
}
When to Use Async
Async is optimal for:
- I/O-bound work: Network requests, file operations, databases
- Many concurrent operations: Thousands of connections
- Resource efficiency: Limited memory environments
- Coordinated I/O: Complex workflows with dependencies
Example of I/O-bound work better suited for async:
async fn fetch_many_urls(urls: Vec<String>) -> Vec<Result<String, reqwest::Error>> {
let futures = urls.into_iter().map(|url| {
async move {
reqwest::get(&url).await?.text().await
}
});
futures::future::join_all(futures).await
}
Hybrid Approaches: spawn_blocking
Real applications often combine async I/O with CPU-bound work. The key tool is tokio::task::spawn_blocking, which moves a closure onto a dedicated thread pool separate from Tokio’s async worker threads.
use tokio::task;
async fn hybrid_processing(data: Vec<Data>) -> Vec<Result<Processed, Error>> {
let mut handles = vec![];
for chunk in data.chunks(100) {
let chunk = chunk.to_vec();
// Spawn blocking task for CPU work
let handle = task::spawn_blocking(move || {
process_cpu_intensive(chunk)
});
handles.push(handle);
}
// Await all CPU tasks
let mut results = vec![];
for handle in handles {
results.extend(handle.await.expect("task panicked"));
}
// Async I/O for results
store_results_async(&results).await;
results
}
Common Pitfalls and Solutions
Blocking in Async Context
// BAD: Blocks the async runtime
async fn bad_example() {
std::thread::sleep(Duration::from_secs(1)); // Blocks executor
}
// GOOD: Use async sleep
async fn good_example() {
tokio::time::sleep(Duration::from_secs(1)).await;
}
// GOOD: Move blocking work to dedicated thread
async fn blocking_work() {
let result = tokio::task::spawn_blocking(|| {
// CPU-intensive or blocking operation
expensive_computation()
}).await.unwrap();
}
Async Mutex vs Sync Mutex
Rule of thumb: use std::sync::Mutex when the critical section is short and never crosses an .await point. Use tokio::sync::Mutex only when you need to hold the lock across an .await. The Tokio documentation itself recommends std::sync::Mutex for brief operations — it’s faster for sync-only access.
// Use tokio::sync::Mutex for async contexts
use tokio::sync::Mutex as AsyncMutex;
use std::sync::Mutex as SyncMutex;
async fn async_mutex_example() {
let data = Arc::new(AsyncMutex::new(vec![]));
let data_clone = Arc::clone(&data);
tokio::spawn(async move {
let mut guard = data_clone.lock().await; // Async lock
guard.push(1);
});
}
// Use std::sync::Mutex only for brief critical sections
fn sync_mutex_in_async() {
let data = Arc::new(SyncMutex::new(vec![]));
// OK if lock is held briefly and doesn't cross await points
{
let mut guard = data.lock().unwrap();
guard.push(1);
} // Lock released before any await
}
Best Practices
- Start simple: Use threads for CPU work, async for I/O
- Avoid blocking: Never block the async runtime
- Choose appropriate synchronization: Arc+Mutex for threads, channels for both
- Profile and measure: Don’t assume, benchmark your specific use case
- Handle errors properly: Both models require careful error handling
- Consider the ecosystem: Check library support for your chosen model
Async Beyond Tokio: Embassy for Embedded Systems
Everything above uses tokio, which requires an operating system with threads and a heap allocator. But what about no_std embedded targets like the ESP32-C3? That is where Embassy comes in.
Embassy: A no_std Async Runtime
Embassy is an async executor designed for bare-metal microcontrollers. It provides the same async/await syntax you already know, but runs without an OS, without threads, and without a heap.
| Aspect | Tokio | Embassy |
|---|---|---|
| Target | Desktop / server (Linux, macOS, Windows) | Bare-metal microcontrollers (no OS) |
| Scheduling | Thread-pool executor | Interrupt-driven single-core executor |
| Memory | Heap-allocated tasks (Box<dyn Future>) | Statically allocated tasks (no heap) |
| Blocking | spawn_blocking for CPU work | Everything is cooperative; blocking = stalling |
| Timer | OS timers via epoll/kqueue | Hardware peripheral timers |
| Ecosystem | axum, reqwest, sqlx, … | embassy-net, embassy-usb, esp-hal, … |
Task Model Comparison
Tokio spawns tasks onto a shared thread pool:
// Tokio: tasks are heap-allocated, run on worker threads
tokio::spawn(async move {
let data = fetch_from_network().await;
process(data).await;
});
Embassy uses a static task macro — each task is a known-size state machine allocated at compile time:
// Embassy: tasks are statically allocated, run on a single-threaded executor
#[embassy_executor::task]
async fn sensor_task(sensor: TemperatureSensor) {
loop {
let temp = sensor.read().await; // Yields to executor while waiting
process(temp);
Timer::after_secs(1).await; // Hardware timer, not OS timer
}
}
How Embassy Scheduling Works
Tokio uses OS threads and epoll/kqueue to multiplex tasks. Embassy instead hooks directly into hardware interrupts:
- The executor puts the CPU to sleep (low-power wait-for-interrupt).
- A hardware event fires (timer expires, UART byte received, GPIO edge detected).
- The interrupt handler wakes the corresponding task.
- The executor polls that task until it yields again.
This means zero context-switch overhead and automatic low-power behavior — the CPU sleeps whenever no task is ready.
Why This Matters for Day 4
In the ESP32-C3 exercises (Day 4), you will use esp-hal which provides async-capable peripheral drivers. The patterns are the same async/await you learned here — only the runtime is different. Understanding that Rust’s Future trait is runtime-agnostic is the key insight: your async fn works with tokio or Embassy, because the language-level mechanism is identical.
Exercise: Parallel WordCounter
Build a thread-safe word frequency counter that processes text chunks in parallel. This exercise focuses on Part 1 of the chapter (threads, Arc<Mutex<>>, channels) — no async runtime needed.
Setup
cp -r solutions/day3/18_concurrency/ mysolutions/day3/18_concurrency/
cd mysolutions/day3/18_concurrency
cargo test # all tests should fail initially
What you implement
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
pub struct WordCounter {
counts: Arc<Mutex<HashMap<String, usize>>>,
}
impl WordCounter {
pub fn new() -> Self;
/// Count words in a single string (single-threaded).
pub fn count(&self, text: &str);
/// Count words across multiple texts in parallel (one thread per text).
pub fn count_parallel(&self, texts: &[&str]);
/// Get the frequency of a specific word (case-insensitive).
pub fn get(&self, word: &str) -> usize;
/// Get the top N most frequent words, ordered by count descending.
pub fn top_n(&self, n: usize) -> Vec<(String, usize)>;
/// Merge another WordCounter's counts into this one.
pub fn merge(&self, other: &WordCounter);
/// Reset all counts.
pub fn reset(&self);
}
/// Channel-based variant: count words using mpsc channels.
pub fn count_with_channel(texts: &[&str]) -> HashMap<String, usize>;
What the tests cover
| # | Test | Concept |
|---|---|---|
| 1 | count_single_text | Basic Mutex locking |
| 2 | count_ignores_case_and_punctuation | Word normalisation |
| 3 | count_empty_input | Edge case |
| 4 | parallel_matches_sequential | thread::spawn + Arc<Mutex<>> |
| 5 | top_n_returns_most_frequent | Sorting by frequency |
| 6 | top_n_alphabetical_on_tie | Tie-breaking |
| 7 | merge_combines_counts | Multi-lock coordination |
| 8 | reset_clears_all_counts | HashMap::clear under lock |
| 9 | channel_counting_matches_sequential | mpsc::channel |
| 10 | concurrent_access_is_safe | Arc<WordCounter> shared across 10 threads |
Tips
- Normalise words to lowercase and strip leading/trailing ASCII punctuation.
- In
count_parallel, build a localHashMapper thread first, then merge into the shared map under a single lock — this minimises lock contention. - For
count_with_channel, remember todrop(tx)after spawning all threads so therxiterator terminates. top_nshould break ties alphabetically (ascending) when counts are equal.
Solution
The reference solution is in solutions/day3/18_concurrency/src/lib.rs.
Summary
Rust provides two powerful concurrency models:
- Threads: Best for CPU-intensive work and simple parallelism
- Async: Best for I/O-bound work and massive concurrency
Both models provide:
- Memory safety without garbage collection
- Data race prevention at compile time
- Zero-cost abstractions
- Excellent performance
Choose based on your workload characteristics, and don’t hesitate to combine both approaches when appropriate. The key is understanding the trade-offs and selecting the right tool for each part of your application.
Chapter 19: Rust Design Patterns
Learning Objectives
- Apply library-first project structure with a thin
main.rs - Use trait-based backends for testability and runtime flexibility
- Define crate-level error enums with
derive_more::Fromand aResult<T>alias - Use feature flags to conditionally compile modules and dependencies
- Recognize the Builder, Newtype, and Type-State patterns
- Understand RAII via the
Droptrait and zero-copy techniques withCow
This chapter covers design patterns that come up repeatedly in production Rust code. Several of them are applied directly in the Day 4 ESP32-C3 embedded project.
1. Thin main.rs – Library-First Structure
In idiomatic Rust, main.rs does as little as possible. It parses configuration (or CLI arguments), then delegates to the library crate defined in lib.rs.
fn main() -> myapp::Result<()> {
let config = myapp::Config::parse();
myapp::run(config)
}
All application logic lives in the library. This has concrete benefits:
- Testability – integration tests (
tests/*.rs) can only access the library crate, notmain.rs. If your logic is inmain, it cannot be tested from integration tests. - Reusability – other binaries in the same crate (e.g., a CLI and a server) share the library without duplication.
- Benchmarks – criterion benchmarks import from the library crate the same way tests do.
A typical project layout:
myapp/
Cargo.toml
src/
main.rs # 3-5 lines: parse config, call lib
lib.rs # declares modules, re-exports public API
config.rs # CLI argument parsing (clap)
error.rs # crate-level Error enum + Result alias
transform.rs # core logic
tests/
integration.rs # imports myapp as a library
Production Rust projects like ripgrep and cargo itself follow this pattern. The Day 4 ESP32-C3 project uses it from Chapter 22 onward: a lib.rs exports testable temperature and communication modules, while bin/main.rs only initializes hardware and runs the main loop.
2. Trait-Based Backends
Define behavior as a trait, then provide multiple implementations. Consumers depend on the trait, not a concrete type.
pub trait Transform: Send + Sync {
fn apply(&self, input: &[u8], op: &Operation) -> Result<Vec<u8>>;
fn name(&self) -> &str;
}
Two backends can implement this trait:
pub struct ImageRsBackend;
impl Transform for ImageRsBackend {
fn apply(&self, input: &[u8], op: &Operation) -> Result<Vec<u8>> {
// Use the pure-Rust `image` crate
todo!()
}
fn name(&self) -> &str { "image-rs" }
}
pub struct MockBackend;
impl Transform for MockBackend {
fn apply(&self, input: &[u8], _op: &Operation) -> Result<Vec<u8>> {
// Return input unchanged -- useful for tests
Ok(input.to_vec())
}
fn name(&self) -> &str { "mock" }
}
Selecting the backend
At compile time (static dispatch via generics):
fn process<T: Transform>(backend: &T, data: &[u8], op: &Operation) -> Result<Vec<u8>> {
backend.apply(data, op)
}
At runtime (dynamic dispatch via trait objects):
fn create_backend(use_turbo: bool) -> Box<dyn Transform> {
if use_turbo {
Box::new(TurboJpegBackend::new())
} else {
Box::new(ImageRsBackend)
}
}
The Send + Sync bounds on the trait allow the boxed backend to be shared across threads (e.g., stored in Arc and passed to async handlers).
This pattern enables testing without modifying production code – pass MockBackend in tests, the real implementation in production. In Day 4, the ESP32-C3 project uses this approach with a TemperatureSensorHal trait: the real Esp32TemperatureSensor runs on hardware, while MockTemperatureSensor enables desktop testing (Chapter 22).
3. Crate-Level Error Enum + Result<T> Alias
A single Error enum per crate (or architectural layer) collects every error kind the crate can produce. A type alias shortens function signatures.
use derive_more::From;
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug, From)]
pub enum Error {
// Domain errors -- constructed manually at call sites
UnsupportedFormat,
DimensionTooLarge { width: u32, height: u32 },
// External errors -- auto-converted via `?`
#[from]
Io(std::io::Error),
#[from]
Image(image::ImageError),
}
impl core::fmt::Display for Error {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
write!(f, "{self:?}")
}
}
impl std::error::Error for Error {}
How it works
#[from]on a variant auto-generates aFrom<std::io::Error> for Errorimpl (and similarly forimage::ImageError). This is what makes?propagation work: when a function returnsstd::io::Errorand the caller returnsResult<T, Error>, the compiler uses theFromimpl to convert automatically.- Domain errors like
UnsupportedFormathave no#[from]attribute. They are constructed explicitly at the call site:return Err(Error::UnsupportedFormat). This is intentional – domain errors represent decisions, not mechanical conversions. DisplayasDebug(write!(f, "{self:?}")) is a pragmatic shortcut. For CLI and server error output, the Debug representation is often sufficient. If you later need user-friendly messages, implementDisplayproperly per variant.core::result::Resulton the right-hand side of the type alias makes it visually clear that we refer to the standard library’sResult, not recursively referencing the alias being defined. Bothcore::result::Resultandstd::result::Resultare the same type.
Comparison with alternatives
| Approach | Pros | Cons |
|---|---|---|
derive_more::From | Lightweight, selective #[from], minimal proc-macro | Manual Display impl |
thiserror | Generates Display from #[error("...")] attributes | Heavier proc-macro for the same From generation |
anyhow | Minimal boilerplate, good for scripts and prototypes | Erases error type – callers cannot match on variants |
For libraries and applications where callers need to handle specific error variants, derive_more::From or thiserror are appropriate. anyhow is suited for top-level binaries where you only need to print the error and exit.
This exact pattern works equally well in embedded and desktop Rust projects.
4. Feature-Gated Modules
Cargo feature flags enable conditional compilation of entire modules and their dependencies.
Declaring features in Cargo.toml
[features]
default = []
server = ["dep:axum", "dep:tokio"]
gui = ["dep:eframe", "dep:egui"]
[dependencies]
axum = { version = "0.8", optional = true }
tokio = { version = "1", features = ["full"], optional = true }
eframe = { version = "0.30", optional = true }
egui = { version = "0.30", optional = true }
The dep: prefix (stabilized in Rust 1.60) makes the dependency optional without implicitly creating a feature of the same name. Before dep:, writing axum = { optional = true } would create both a dependency and a feature named axum, which led to confusion.
Gating modules in lib.rs
#[cfg(feature = "server")]
pub mod server;
#[cfg(feature = "gui")]
pub mod gui;
When a feature is not enabled, the module is not compiled at all – its optional dependencies are not compiled, its code is not checked, and it does not appear in the binary.
Gating within a function
pub fn create_backend() -> Box<dyn Transform> {
#[cfg(feature = "turbojpeg")]
{
return Box::new(TurboJpegBackend::new());
}
#[cfg(not(feature = "turbojpeg"))]
{
Box::new(ImageRsBackend)
}
}
Benefits
- Smaller binaries – users who only need the CLI do not carry the HTTP server stack.
- Faster compile times – fewer dependencies to download and build.
- No unused code – the compiler does not process gated modules unless requested.
Feature flags are used throughout Day 4: the ESP32-C3 project gates hardware dependencies behind an embedded feature so that business logic can be tested on desktop without ESP-specific crates (Chapters 22-24).
5. Builder Pattern
The Builder pattern is useful when a struct has many optional fields or requires validation before construction. Rust has no function overloading or default parameter values, so builders fill that role.
use std::time::Duration; #[derive(Debug)] pub struct ServerConfig { host: String, port: u16, max_connections: usize, timeout: Duration, } impl ServerConfig { fn builder() -> ServerConfigBuilder { ServerConfigBuilder::default() } } #[derive(Default)] pub struct ServerConfigBuilder { host: Option<String>, port: Option<u16>, max_connections: Option<usize>, timeout: Option<Duration>, } impl ServerConfigBuilder { pub fn host(mut self, host: impl Into<String>) -> Self { self.host = Some(host.into()); self } pub fn port(mut self, port: u16) -> Self { self.port = Some(port); self } pub fn max_connections(mut self, n: usize) -> Self { self.max_connections = Some(n); self } pub fn timeout(mut self, t: Duration) -> Self { self.timeout = Some(t); self } pub fn build(self) -> Result<ServerConfig, &'static str> { Ok(ServerConfig { host: self.host.ok_or("host is required")?, port: self.port.unwrap_or(8080), max_connections: self.max_connections.unwrap_or(100), timeout: self.timeout.unwrap_or(Duration::from_secs(30)), }) } } fn main() -> Result<(), &'static str> { let config = ServerConfig::builder() .host("localhost") .port(3000) .build()?; println!("{:?}", config); Ok(()) }
Each setter method takes self by value (not &mut self), which enables method chaining. The build method can enforce invariants and return a Result if required fields are missing.
The derive_builder crate can generate builder implementations automatically, but writing them by hand is straightforward and avoids a proc-macro dependency.
6. Newtype Pattern
Wrap a primitive type in a single-field struct to give it a distinct type. The compiler prevents accidental mixing of values that share the same underlying representation.
struct Kilometers(f64); struct Miles(f64); struct Liters(f64); struct KmPerLiter(f64); impl Kilometers { fn to_miles(&self) -> Miles { Miles(self.0 * 0.621371) } } fn calculate_fuel_efficiency(distance: Kilometers, fuel: Liters) -> KmPerLiter { KmPerLiter(distance.0 / fuel.0) } fn main() { let dist = Kilometers(100.0); let fuel = Liters(8.5); let efficiency = calculate_fuel_efficiency(dist, fuel); println!("{:.1} km/L", efficiency.0); }
Calling calculate_fuel_efficiency(fuel, dist) with swapped arguments is a compile-time error, not a silent bug. The newtype has zero runtime cost – the wrapper is erased during compilation.
Newtypes are also useful for implementing external traits on external types (the orphan rule requires that either the trait or the type is defined in the current crate).
7. Type-State Pattern
Encode state transitions in the type system so that invalid sequences are compile-time errors. The key ingredient is PhantomData<State> — a zero-sized marker type that exists only at compile time and tells the compiler which state the struct is in, without using any runtime memory.
struct Draft; struct PendingReview; struct Published; struct Post<State> { content: String, _state: std::marker::PhantomData<State>, } impl Post<Draft> { fn new(content: impl Into<String>) -> Self { Post { content: content.into(), _state: std::marker::PhantomData, } } fn submit(self) -> Post<PendingReview> { Post { content: self.content, _state: std::marker::PhantomData, } } } impl Post<PendingReview> { fn approve(self) -> Post<Published> { Post { content: self.content, _state: std::marker::PhantomData, } } fn reject(self) -> Post<Draft> { Post { content: self.content, _state: std::marker::PhantomData, } } } impl Post<Published> { fn content(&self) -> &str { &self.content } } fn main() { let post = Post::new("Hello, world!") .submit() .approve(); println!("{}", post.content()); // This would not compile -- cannot call .content() on a Draft: // let draft = Post::new("draft"); // println!("{}", draft.content()); }
Each state is a zero-sized type. The generic parameter State controls which methods are available. Calling .content() on a Post<Draft> is a compile-time error – the method simply does not exist for that type. The state types carry no runtime data, so the pattern has zero cost.
This pattern appears in libraries like hyper (request builders) and tower (service layers).
8. RAII and Drop Pattern
Rust’s Drop trait provides deterministic resource cleanup. When a value goes out of scope, its drop method runs automatically. This is Rust’s version of C++ RAII – but enforced by the ownership system, so there is no risk of use-after-free.
use std::path::PathBuf;
struct TempFile {
path: PathBuf,
}
impl TempFile {
fn new(name: &str, content: &str) -> std::io::Result<Self> {
let path = std::env::temp_dir().join(name);
std::fs::write(&path, content)?;
Ok(TempFile { path })
}
fn path(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
Usage:
fn process() -> std::io::Result<()> {
let temp = TempFile::new("work.tmp", "temporary data")?;
// ... use temp.path() ...
Ok(())
} // temp is dropped here -- file is deleted automatically
The file is cleaned up whether the function returns normally or propagates an error via ?. This is the same guarantee that C++ destructors provide, but Rust additionally prevents accessing temp after it has been moved or dropped.
Common uses of Drop in practice: closing file handles, releasing locks, flushing buffers, cleaning up temporary directories, and disconnecting network connections.
9. Performance Patterns
Zero-Copy with Borrowing
Passing &[u8] or &str instead of Vec<u8> or String avoids allocations when the caller already owns the data.
fn count_words(text: &str) -> usize { text.split_whitespace().count() } fn main() { let owned = String::from("hello world from Rust"); let count = count_words(&owned); // borrows, no allocation println!("{count} words"); }
Cow – Clone on Write
Cow<'a, T> holds either a borrowed reference or an owned value. It only allocates when modification is needed.
use std::borrow::Cow; fn normalize_whitespace<'a>(input: &'a str) -> Cow<'a, str> { if input.contains('\n') { Cow::Owned(input.replace('\n', " ")) } else { Cow::Borrowed(input) } } fn main() { let clean = "no newlines here"; let dirty = "has\nnewlines\nin it"; let result1 = normalize_whitespace(clean); // Borrowed -- no allocation let result2 = normalize_whitespace(dirty); // Owned -- allocated println!("{result1}"); println!("{result2}"); }
Cow is particularly useful in functions where most inputs pass through unchanged but some need transformation. You avoid allocating in the common case while still supporting the uncommon one.
Memory Layout Control
The #[repr(C)] attribute gives a struct C-compatible memory layout, which is required for FFI and sometimes useful for memory-mapped data.
#[repr(C)] struct NetworkPacket { header: [u8; 4], length: u32, payload: [u8; 1024], } fn main() { println!("Packet size: {} bytes", std::mem::size_of::<NetworkPacket>()); }
Without #[repr(C)], the Rust compiler is free to reorder and pad struct fields for optimal alignment. With it, fields appear in declaration order with C-standard padding rules.
10. Best Practices
- Keep
main.rsthin – parse arguments, call into the library, print errors. Nothing else. - Define a crate-level
ErrorandResult<T>– propagation with?should work across your entire crate without manual conversions. - Use traits to abstract behavior – this enables testing with mocks and swapping implementations via feature flags.
- Gate optional functionality behind feature flags – compile only what is needed.
- Make invalid states unrepresentable – use the type system (enums, newtypes, type-state) instead of runtime checks.
- Prefer borrowing over cloning – pass
&strand&[u8]where ownership is not needed. - Use
Cowwhen most inputs pass through unchanged – avoid allocations in the common path. - Run clippy – it catches unidiomatic patterns and common mistakes. Treat warnings as errors in CI.
- Start flat, nest when earned – do not create deep module hierarchies before they are needed.
Summary
This chapter covered ten patterns that appear in real Rust projects:
| Pattern | Purpose |
|---|---|
Thin main.rs | Testability and reuse via library-first design |
| Trait-based backends | Swappable implementations, mockable in tests |
Error enum + Result<T> alias | Unified error handling with ? propagation |
| Feature-gated modules | Conditional compilation of entire subsystems |
| Builder | Flexible construction with validation |
| Newtype | Type-safe wrappers over primitives |
| Type-State | Compile-time enforcement of state transitions |
| RAII / Drop | Deterministic resource cleanup |
| Zero-copy / Cow | Avoid unnecessary allocations |
| Best practices | Guidelines for idiomatic Rust project structure |
The first four patterns are applied directly in Day 4 when building the ESP32-C3 embedded project. The remaining patterns are general techniques that appear across the Rust ecosystem.
Chapter 20: Hello World
Learning Objectives
- Install the ESP32-C3 toolchain
- Generate a project with
esp-generate - Understand the generated project structure
- Build, flash, and monitor a Hello World program
Prerequisites
Install the tools we need:
cargo install espflash --locked
cargo install esp-generate --locked
- espflash: flashes firmware to ESP chips over USB and provides a serial monitor
- esp-generate: scaffolds new ESP32 projects with the correct build config, dependencies, and linker setup
On Linux, add yourself to the dialout group so you can access the serial port:
sudo usermod -a -G dialout $USER
# Log out and back in for this to take effect
Plug in your ESP32-C3 board via USB. Verify it’s detected:
espflash board-info
Create a Project
Generate a new project:
esp-generate --chip esp32c3 hello
The TUI will ask a series of questions — accept the defaults for everything. This gives us a minimal no_std project.
Generated Files Explained
hello/
├── .cargo/
│ └── config.toml # Build target + espflash as runner
├── rust-toolchain.toml # Stable Rust + riscv32imc target
├── build.rs # Linker script setup
├── Cargo.toml # Dependencies: esp-hal, esp-bootloader-esp-idf
└── src/
└── bin/
└── main.rs # Entry point
.cargo/config.toml
[target.riscv32imc-unknown-none-elf]
runner = "espflash flash --monitor --chip esp32c3"
[build]
rustflags = ["-C", "force-frame-pointers"]
target = "riscv32imc-unknown-none-elf"
[unstable]
build-std = ["core"]
Key points:
- runner:
cargo runwill flash the chip and open a serial monitor - target: ESP32-C3 uses the RISC-V
riscv32imc-unknown-none-elftarget - build-std: Rebuilds
corefrom source (needed forno_stdtargets)
rust-toolchain.toml
[toolchain]
channel = "stable"
components = ["rust-src"]
targets = ["riscv32imc-unknown-none-elf"]
We need rust-src because build-std compiles core from source.
Cargo.toml
[dependencies]
esp-hal = { version = "1.0.0", features = ["esp32c3"] }
esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32c3"] }
critical-section = "1.2.0"
- esp-hal: Hardware Abstraction Layer — gives us GPIO, timers, peripherals
- esp-bootloader-esp-idf: Provides the bootloader app descriptor
- critical-section: Required for interrupt-safe shared state
src/bin/main.rs (generated)
#![no_std] #![no_main] use esp_hal::clock::CpuClock; use esp_hal::main; use esp_hal::time::{Duration, Instant}; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { loop {} } esp_bootloader_esp_idf::esp_app_desc!(); #[main] fn main() -> ! { let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let _peripherals = esp_hal::init(config); loop { let delay_start = Instant::now(); while delay_start.elapsed() < Duration::from_millis(500) {} } }
Let’s break this down:
| Line | Purpose |
|---|---|
#![no_std] | No standard library — we’re on bare metal |
#![no_main] | No normal main — the HAL provides the entry point |
#[panic_handler] | Required in no_std — what to do on panic (loop forever) |
esp_app_desc!() | App descriptor required by the ESP-IDF bootloader |
#[main] | ESP-HAL’s entry point macro |
-> ! | Never returns — embedded programs run forever |
esp_hal::init() | Initializes all hardware, returns peripheral handles |
Add Hello World
The generated code just loops doing nothing. Let’s add serial output.
Add the esp-println dependency to Cargo.toml:
[dependencies]
esp-hal = { version = "1.0.0", features = ["esp32c3"] }
esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32c3"] }
critical-section = "1.2.0"
esp-println = { version = "0.14.0", features = ["esp32c3"] }
Now modify src/bin/main.rs:
#![no_std] #![no_main] use esp_hal::clock::CpuClock; use esp_hal::main; use esp_hal::time::{Duration, Instant}; use esp_println::println; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { loop {} } esp_bootloader_esp_idf::esp_app_desc!(); #[main] fn main() -> ! { let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let _peripherals = esp_hal::init(config); let mut count = 0u32; loop { println!("Hello, world! (count: {})", count); count += 1; let delay_start = Instant::now(); while delay_start.elapsed() < Duration::from_secs(1) {} } }
esp_println::println! works just like the standard println! but sends output over the USB serial connection.
Build & Flash
cargo run --release
This does three things:
- Builds the firmware (cross-compiling to RISC-V)
- Flashes it to the ESP32-C3 via USB
- Opens a serial monitor so you see the output
You should see:
Hello, world! (count: 0)
Hello, world! (count: 1)
Hello, world! (count: 2)
...
Press Ctrl+R to reset the chip. Press Ctrl+C to exit the monitor.
Tip: Use
--releasefor faster builds and smaller binaries. The[profile.dev]already setsopt-level = "s", but release mode enables LTO and other optimizations.
How It Works
Unlike a normal Rust program, our code runs directly on the hardware with no operating system:
┌──────────────────────────┐
│ Your Application │
├──────────────────────────┤
│ esp-hal (HAL layer) │
├──────────────────────────┤
│ ESP32-C3 Hardware │
│ (CPU, GPIO, UART, ...) │
└──────────────────────────┘
- No OS: No threads, no filesystem, no heap (unless you add one)
- Direct hardware access:
esp_hal::init()gives you handles to all peripherals - Never returns:
-> !meansmainloops forever — there’s nowhere to return to
Troubleshooting
| Problem | Solution |
|---|---|
Permission denied on serial port | sudo usermod -a -G dialout $USER and re-login |
espflash can’t find the chip | Try a different USB cable (some are charge-only) |
| Build fails with linker errors | Make sure build.rs and .cargo/config.toml are present |
rust-src component missing | Run rustup component add rust-src |
Exercise
Modify the Hello World program to also print the loop iteration time. Use Instant::now() and elapsed() to measure how long each iteration takes. Does it match your delay?
Chapter 21: Blinky
Learning Objectives
- Control GPIO pins to drive an LED
- Understand digital output (high/low)
- Implement a busy-wait delay loop
- Build the classic “Hello World” of embedded systems
The Classic Blinky
Blinking an LED is the embedded equivalent of “Hello World” — it proves you can control hardware. Most ESP32-C3 development boards have a built-in LED connected to GPIO8.
Starting Point
We start from the same esp-generate project structure as Chapter 20. The only dependency we need is esp-hal — no esp-println required.
[dependencies]
esp-hal = { version = "1.0.0", features = ["esp32c3"] }
esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32c3"] }
critical-section = "1.2.0"
The Code
#![no_std] #![no_main] use esp_hal::clock::CpuClock; use esp_hal::gpio::{Level, Output, OutputConfig}; use esp_hal::main; use esp_hal::time::{Duration, Instant}; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { loop {} } esp_bootloader_esp_idf::esp_app_desc!(); #[main] fn main() -> ! { let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals = esp_hal::init(config); // Configure GPIO8 as a digital output, starting LOW (LED off) let mut led = Output::new(peripherals.GPIO8, Level::Low, OutputConfig::default()); loop { led.set_high(); // LED on let delay_start = Instant::now(); while delay_start.elapsed() < Duration::from_millis(500) {} led.set_low(); // LED off let delay_start = Instant::now(); while delay_start.elapsed() < Duration::from_millis(500) {} } }
How GPIO Works
GPIO stands for General Purpose Input/Output. Each pin can be configured as either input or output:
ESP32-C3
┌───────────┐
│ │
GPIO8 ──────── │ Output │ ──── LED
│ Register │
│ │
└───────────┘
set_high() → pin outputs 3.3V → LED turns on
set_low() → pin outputs 0V → LED turns off
Output::new()
#![allow(unused)] fn main() { let mut led = Output::new( peripherals.GPIO8, // Which pin Level::Low, // Initial state OutputConfig::default(), // Default drive strength, no pull-up/down ); }
The three arguments:
- Pin:
peripherals.GPIO8— Rust’s ownership system ensures only one part of your code controls this pin - Initial level:
Level::Low— start with LED off - Config: Drive strength and other electrical settings (defaults are fine)
Ownership
Notice that peripherals.GPIO8 is moved into Output::new(). This is Rust’s ownership system at work — you can’t accidentally configure the same pin twice from different parts of your code. This is a compile-time guarantee that prevents a whole class of hardware bugs.
The Busy-Wait Delay
#![allow(unused)] fn main() { let delay_start = Instant::now(); while delay_start.elapsed() < Duration::from_millis(500) {} }
This is a busy-wait (also called spin-loop): the CPU continuously checks the clock until 500ms have passed. It’s simple but wasteful — the CPU can’t do anything else during the wait.
In Chapter 22 we’ll see how Embassy’s async runtime solves this —
Timer::after(Duration::from_millis(500)).awaitlets the CPU sleep or do other work while waiting.
Build & Flash
cargo run --release
You should see the LED blinking at 1 Hz (on for 500ms, off for 500ms).
Exercise
- Change the blink rate to 200ms on, 800ms off (asymmetric blink)
- Add
esp-printlnand print “ON” / “OFF” each time the LED changes state - Create a pattern: 3 fast blinks (100ms), then a 1-second pause, repeat
Chapter 22: Blinky with Embassy
Learning Objectives
- Understand what Embassy is and why it matters
- Convert a blocking blink to an async blink
- Run multiple async tasks concurrently
- Compare blocking vs async approaches
What is Embassy?
Embassy is an async runtime for embedded systems — think of it as tokio for bare metal. Instead of busy-waiting (spinning the CPU doing nothing), Embassy lets you await timers, I/O, and other events while the CPU sleeps or runs other tasks.
Blocking (Chapter 21) Async (Embassy)
┌──────────────┐ ┌──────────────┐
│ set_high() │ │ set_high() │
│ SPIN 500ms │ ← CPU busy │ await 500ms │ ← CPU sleeps
│ set_low() │ │ set_low() │
│ SPIN 500ms │ ← CPU busy │ await 500ms │ ← CPU sleeps
└──────────────┘ └──────────────┘
With Embassy you can also run multiple tasks concurrently on a single core — no threads, no OS needed.
Generate a New Project
esp-generate --chip esp32c3 -o embassy -o unstable-hal blinky-embassy
Select the defaults in the TUI. The -o flag pre-selects TUI options so you skip the interactive prompts for those choices: -o embassy adds Embassy support via esp-rtos, and -o unstable-hal enables the unstable HAL features that Embassy requires.
Dependencies
[dependencies]
esp-hal = { version = "1.0.0", features = ["esp32c3", "unstable"] }
esp-rtos = { version = "0.2.0", features = ["embassy", "esp32c3"] }
esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32c3"] }
embassy-executor = { version = "0.9.1", features = [] }
embassy-time = "0.5.0"
critical-section = "1.2.0"
The new dependencies compared to Chapter 21:
- esp-rtos: Lightweight RTOS layer that integrates Embassy with esp-hal (sets up timers and the executor)
- embassy-executor: The async task executor (scheduler)
- embassy-time: Async timers (
Timer::after()) - unstable feature on esp-hal: Required for Embassy integration
Async Blink
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_time::{Duration, Timer}; use esp_hal::clock::CpuClock; use esp_hal::gpio::{Level, Output, OutputConfig}; use esp_hal::timer::timg::TimerGroup; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { loop {} } esp_bootloader_esp_idf::esp_app_desc!(); #[esp_rtos::main] async fn main(_spawner: Spawner) -> ! { let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals = esp_hal::init(config); // Initialize the async runtime let timg0 = TimerGroup::new(peripherals.TIMG0); let sw_interrupt = esp_hal::interrupt::software::SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0); let mut led = Output::new(peripherals.GPIO8, Level::Low, OutputConfig::default()); loop { led.toggle(); Timer::after(Duration::from_millis(500)).await; } }
What Changed
| Blocking (Ch 21) | Async (Embassy) |
|---|---|
#[esp_hal::main] | #[esp_rtos::main] |
fn main() -> ! | async fn main(_spawner: Spawner) -> ! |
use esp_hal::time::{Duration, Instant} | use embassy_time::{Duration, Timer} |
| Busy-wait spin loop | Timer::after(...).await |
set_high() + set_low() | led.toggle() |
| — | esp_rtos::start(...) to initialize the runtime |
Key differences:
#[esp_rtos::main]: Entry point macro that sets up the Embassy executoresp_rtos::start(): Connects Embassy to a hardware timer and software interrupt for task schedulingasync fn main: Ourmainis now an async taskSpawner: Used to spawn additional async tasks (we’ll use it below)Timer::after().await: Suspends the task — the CPU can sleep instead of spinningled.toggle(): Flips the pin state — simpler than separateset_high/set_low
Multiple Tasks
The real power of Embassy is running concurrent tasks. Let’s add a second task that prints a message periodically:
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_time::{Duration, Timer}; use esp_hal::clock::CpuClock; use esp_hal::gpio::{Level, Output, OutputConfig}; use esp_hal::timer::timg::TimerGroup; use esp_println::println; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { loop {} } esp_bootloader_esp_idf::esp_app_desc!(); #[embassy_executor::task] async fn heartbeat() { let mut count = 0u32; loop { println!("heartbeat: {}", count); count += 1; Timer::after(Duration::from_secs(2)).await; } } #[esp_rtos::main] async fn main(spawner: Spawner) -> ! { let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals = esp_hal::init(config); let timg0 = TimerGroup::new(peripherals.TIMG0); let sw_interrupt = esp_hal::interrupt::software::SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0); // Spawn the heartbeat task — runs concurrently spawner.spawn(heartbeat()).unwrap(); let mut led = Output::new(peripherals.GPIO8, Level::Low, OutputConfig::default()); loop { led.toggle(); Timer::after(Duration::from_millis(500)).await; } }
Both tasks run on the same core, interleaving at await points. No threads, no mutexes, no data races.
Time ──────────────────────────────────────────►
LED task: [on] await [off] await [on] await [off] await
Heartbeat: [print] await ... [print] await
CPU: run sleep run sleep run sleep run sleep
Task Rules
- Tasks are defined with
#[embassy_executor::task] - Tasks must be
async fnwith no return value (or-> !) - Tasks are spawned with
spawner.spawn(task_name()).unwrap() - Each task function can only have one instance running at a time (Embassy limitation)
- Tasks cannot borrow local data from
main— they must own their data or usestatics
Blocking vs Async: When to Use What
| Blocking | Async (Embassy) | |
|---|---|---|
| Complexity | Simpler | Slightly more setup |
| CPU usage | 100% during delays | Near 0% during delays |
| Multiple activities | Hard (manual state machines) | Easy (spawn tasks) |
| Best for | Simple single-purpose code | Anything with concurrency |
For most real projects, Embassy is worth the small added complexity.
Exercise
Starting from the single-task async blink example (not the multi-task version):
- Add
esp-printlnto the dependencies - Create a second task that prints “tick” every 3 seconds
- Observe how both the LED and the serial output run concurrently
- Challenge: Create a task that blinks the LED in a pattern (e.g., SOS in Morse code:
··· −−− ···)
Chapter 23: Temperature Sensor & Shared State
Learning Objectives
- Read the ESP32-C3 on-die temperature sensor
- Share state between Embassy async tasks
- Use
embassy_sync::mutex::Mutexfor safe concurrent access - Build a two-task architecture with producer/consumer pattern
The On-Die Temperature Sensor
The ESP32-C3 has a built-in temperature sensor (tsens) that measures the die temperature. It’s not precise for ambient readings (the die runs ~30°C above room temperature), but it’s perfect for learning sensor patterns without extra hardware.
┌─────────────────────────────┐
│ ESP32-C3 │
│ │
│ ┌───────────────────────┐ │
│ │ Temperature Sensor │ │
│ │ (on-die, ~50-60°C) │ │
│ └───────────────────────┘ │
│ │
│ ┌───────────────────────┐ │
│ │ GPIO8 — LED │ │
│ └───────────────────────┘ │
│ │
│ ┌───────────────────────┐ │
│ │ USB Serial Output │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
Single-Task Version
Let’s start simple — read the sensor and print every 2 seconds:
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_time::{Duration, Timer}; use esp_hal::clock::CpuClock; use esp_hal::timer::timg::TimerGroup; use esp_hal::tsens::{Config, TemperatureSensor}; use esp_println::println; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { loop {} } esp_bootloader_esp_idf::esp_app_desc!(); #[esp_rtos::main] async fn main(_spawner: Spawner) -> ! { let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals = esp_hal::init(config); let timg0 = TimerGroup::new(peripherals.TIMG0); let sw_interrupt = esp_hal::interrupt::software::SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0); let temp_sensor = TemperatureSensor::new(peripherals.TSENS, Config::default()).unwrap(); println!("Temperature sensor ready"); loop { let temp = temp_sensor.get_temperature(); println!("Die temperature: {}C", temp.to_celsius()); Timer::after(Duration::from_secs(2)).await; } }
Key points:
TemperatureSensor::new()takes theTSENSperipheralget_temperature()returns a temperature value;.to_celsius()converts it tof32- The sensor measures die temperature, not ambient — expect values around 50–60°C
Sharing State Between Tasks
In Chapter 22 we spawned independent tasks. But what if one task produces data and another consumes it? We need shared state.
In Embassy, tasks cannot borrow from each other — they’re independent async functions. The solution is a static variable protected by a mutex:
┌──────────────┐ ┌───────────────────────┐ ┌──────────────┐
│ main task │────►│ static LATEST_TEMP │◄────│ display_task │
│ (producer) │ │ Mutex<Option<f32>> │ │ (consumer) │
│ writes temp │ └───────────────────────┘ │ reads temp │
│ every 2s │ │ every 5s │
└──────────────┘ └──────────────┘
The Mutex Type
In no_std there’s no std::sync::Mutex. Embassy provides its own Mutex that takes a raw mutex implementation as a type parameter. For single-core microcontrollers like ESP32-C3, CriticalSectionRawMutex is the right choice — it briefly disables interrupts to guarantee exclusive access:
#![allow(unused)] fn main() { use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::mutex::Mutex; // Shared state: latest temperature reading static LATEST_TEMP: Mutex<CriticalSectionRawMutex, Option<f32>> = Mutex::new(None); }
CriticalSectionRawMutex— the locking strategy (disable interrupts, safe on single-core)Option<f32>—Noneuntil the first reading arrivesstatic— lives for the entire program, accessible from any task
Why static instead of Arc<Mutex<T>>?
On day 3, we shared state between threads using Arc<Mutex<T>> — reference-counted smart pointers that track ownership at runtime. On a no_std microcontroller, there’s no heap allocator (so no Arc), and Embassy tasks aren’t OS threads. Instead, we use a static variable: it has a fixed memory address known at compile time, so any task can access it without heap allocation or reference counting. The mutex still ensures only one task accesses the data at a time.
Two-Task Architecture
Now let’s split reading and displaying into separate tasks:
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::mutex::Mutex; use embassy_time::{Duration, Timer}; use esp_hal::clock::CpuClock; use esp_hal::timer::timg::TimerGroup; use esp_hal::tsens::{Config, TemperatureSensor}; use esp_println::println; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { loop {} } esp_bootloader_esp_idf::esp_app_desc!(); // Shared state: latest temperature reading static LATEST_TEMP: Mutex<CriticalSectionRawMutex, Option<f32>> = Mutex::new(None); #[embassy_executor::task] async fn display_task() { loop { Timer::after(Duration::from_secs(5)).await; let temp = { let guard = LATEST_TEMP.lock().await; *guard }; match temp { Some(t) => println!("[display] Latest die temp: {}C", t), None => println!("[display] No reading yet"), } } } #[esp_rtos::main] async fn main(spawner: Spawner) -> ! { let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals = esp_hal::init(config); let timg0 = TimerGroup::new(peripherals.TIMG0); let sw_interrupt = esp_hal::interrupt::software::SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0); let temp_sensor = TemperatureSensor::new(peripherals.TSENS, Config::default()).unwrap(); // Spawn the display consumer task spawner.spawn(display_task()).unwrap(); println!("Temperature monitor started"); println!(" Main task: reads sensor every 2s"); println!(" Display task: prints latest reading every 5s"); // Main task: read sensor and update shared state loop { let celsius = temp_sensor.get_temperature().to_celsius(); { let mut guard = LATEST_TEMP.lock().await; *guard = Some(celsius); } println!("[sensor] Read: {}C", celsius); Timer::after(Duration::from_secs(2)).await; } }
Why Does the Sensor Stay in Main?
The TemperatureSensor type is not Send — it can’t be moved across task boundaries. Since Embassy tasks are like independent async functions, the sensor must live in the task that created it. We keep it in main and share only the f32 result through the mutex.
Lock Scope
Notice the braces around the lock:
#![allow(unused)] fn main() { { let mut guard = LATEST_TEMP.lock().await; *guard = Some(celsius); } // lock released here }
The mutex guard is dropped at the closing brace, releasing the lock immediately. This minimizes the time the lock is held, which is good practice even on single-core systems.
How It Works
Time ──────────────────────────────────────────────────────►
Sensor: [read] await 2s [read] await 2s [read] await 2s
Display: await 5s [print] await 5s
Shared: None → Some(52.1) → Some(52.3) → Some(51.8)
The display task always sees the most recent reading, even though the sensor reads more frequently than the display prints.
Exercise
Add a third task: an LED that blinks based on temperature.
- Create a
led_taskthat readsLATEST_TEMPevery second - If the temperature is above 55°C, blink fast (200ms)
- If below 55°C, blink slow (1000ms)
- Hint: You’ll need to pass the LED pin. Since Embassy tasks can’t take non-
Sendborrows, create theOutputinside the task by passing the GPIO pin, or use anotherstaticfor the threshold
Challenge: Instead of a fixed threshold, add a second static Mutex for the threshold value. Have the display task update the threshold based on the average of the last few readings.
Chapter 24: Managed Temperature Store with Tests
Learning Objectives
- Build a testable
no_stdlibrary for embedded projects - Use
#![cfg_attr(not(test), no_std)]for dual-target code - Write and run unit tests on the host machine
- Integrate tested library code with Embassy firmware
From Raw Readings to Useful Data
The on-die temperature sensor from Chapter 23 gives us raw die temperature — typically ~30°C above ambient. A raw reading of 52°C probably means the room is about 22°C.
Let’s build a TemperatureStore that:
- Stores the latest raw reading
- Computes estimated ambient temperature (raw minus an offset)
- Converts to Fahrenheit
- Is fully testable on the host — no ESP32 needed
The TemperatureStore
This is pure Rust — no hardware dependencies, no unsafe, just math:
#![allow(unused)] fn main() { pub struct TemperatureStore { raw_celsius: Option<f32>, offset: f32, } impl TemperatureStore { pub fn new(offset: f32) -> Self { Self { raw_celsius: None, offset, } } pub fn update(&mut self, raw_celsius: f32) { self.raw_celsius = Some(raw_celsius); } pub fn raw_celsius(&self) -> Option<f32> { self.raw_celsius } pub fn ambient_celsius(&self) -> Option<f32> { self.raw_celsius.map(|raw| raw - self.offset) } pub fn ambient_fahrenheit(&self) -> Option<f32> { self.ambient_celsius().map(|c| c * 9.0 / 5.0 + 32.0) } } }
This is a simple struct — and that’s the point. By keeping the logic free of hardware dependencies, we can test it anywhere.
Project Structure for Testing
Here’s the key challenge: our project needs to compile for two targets:
riscv32imc-unknown-none-elf— for the ESP32-C3 (no_std)- Host machine — for running tests (std available)
chapter24_temperature_store/
├── .cargo/config.toml # Sets default target to riscv32imc
├── Cargo.toml # Feature-gated dependencies
├── build.rs # Linker setup (only for embedded)
├── rust-toolchain.toml
├── test.sh # Runs tests on host
└── src/
├── lib.rs # #![cfg_attr(not(test), no_std)]
├── store.rs # TemperatureStore + tests
└── bin/main.rs # Embassy firmware
The cfg_attr Trick
#![allow(unused)] fn main() { // src/lib.rs #![cfg_attr(not(test), no_std)] mod store; pub use store::TemperatureStore; }
- When building for the ESP32:
no_stdis active — no standard library - When running
cargo test:no_stdis not set — tests get the full standard library
Feature-Gated Dependencies
[features]
default = ["embedded"]
embedded = [
"dep:esp-hal",
"dep:esp-rtos",
"dep:esp-bootloader-esp-idf",
"dep:embassy-executor",
"dep:embassy-time",
"dep:embassy-sync",
"dep:critical-section",
"dep:esp-println",
]
[dependencies]
esp-hal = { version = "1.0.0", features = ["esp32c3", "unstable"], optional = true }
esp-rtos = { version = "0.2.0", features = ["embassy", "esp32c3"], optional = true }
esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32c3"], optional = true }
embassy-executor = { version = "0.9.1", optional = true }
embassy-time = { version = "0.5.0", optional = true }
embassy-sync = { version = "0.7.2", optional = true }
critical-section = { version = "1.2.0", optional = true }
esp-println = { version = "0.14.0", features = ["esp32c3"], optional = true }
All ESP/Embassy dependencies are optional = true and gated behind the embedded feature. When running tests with --no-default-features, none of them are compiled.
Writing Tests
The store.rs module contains both the implementation and its tests:
#![allow(unused)] fn main() { // src/store.rs pub struct TemperatureStore { raw_celsius: Option<f32>, offset: f32, } impl TemperatureStore { pub const fn new(offset: f32) -> Self { Self { raw_celsius: None, offset, } } pub fn update(&mut self, raw_celsius: f32) { self.raw_celsius = Some(raw_celsius); } pub fn raw_celsius(&self) -> Option<f32> { self.raw_celsius } pub fn ambient_celsius(&self) -> Option<f32> { self.raw_celsius.map(|raw| raw - self.offset) } pub fn ambient_fahrenheit(&self) -> Option<f32> { self.ambient_celsius().map(|c| c * 9.0 / 5.0 + 32.0) } } #[cfg(test)] mod tests { use super::*; #[test] fn none_before_first_update() { let store = TemperatureStore::new(30.0); assert_eq!(store.raw_celsius(), None); assert_eq!(store.ambient_celsius(), None); assert_eq!(store.ambient_fahrenheit(), None); } #[test] fn update_stores_raw_value() { let mut store = TemperatureStore::new(30.0); store.update(52.0); assert_eq!(store.raw_celsius(), Some(52.0)); } #[test] fn ambient_celsius_subtracts_offset() { let mut store = TemperatureStore::new(30.0); store.update(52.0); // 52.0 - 30.0 = 22.0 let ambient = store.ambient_celsius().unwrap(); assert!((ambient - 22.0).abs() < f32::EPSILON); } #[test] fn ambient_fahrenheit_converts_correctly() { let mut store = TemperatureStore::new(30.0); store.update(52.0); // ambient = 22.0C → 22 * 9/5 + 32 = 71.6F let fahrenheit = store.ambient_fahrenheit().unwrap(); assert!((fahrenheit - 71.6).abs() < 0.1); } #[test] fn negative_ambient_temperature() { let mut store = TemperatureStore::new(30.0); store.update(20.0); // 20.0 - 30.0 = -10.0C let ambient = store.ambient_celsius().unwrap(); assert!((ambient - (-10.0)).abs() < f32::EPSILON); } #[test] fn update_overwrites_previous() { let mut store = TemperatureStore::new(30.0); store.update(50.0); store.update(55.0); assert_eq!(store.raw_celsius(), Some(55.0)); } } }
These tests exercise:
- Initial state (all
None) - Basic update and retrieval
- Ambient calculation (raw minus offset)
- Fahrenheit conversion
- Negative temperatures
- Overwriting previous values
Running Tests
There’s a catch: .cargo/config.toml sets the default target to riscv32imc-unknown-none-elf, and build.rs adds embedded linker scripts. Both interfere with host compilation.
The test.sh script moves them aside temporarily:
#!/bin/bash
set -e
echo "Running tests on host..."
# Move embedded-specific files aside
mv .cargo/config.toml .cargo/config.toml.bak
mv build.rs build.rs.bak
# Restore on exit (success or failure)
trap 'mv .cargo/config.toml.bak .cargo/config.toml; mv build.rs.bak build.rs' EXIT
# Run tests without embedded features
cargo test --lib --no-default-features
echo "Tests passed!"
$ chmod +x test.sh
$ ./test.sh
Running tests on host...
running 6 tests
test store::tests::none_before_first_update ... ok
test store::tests::update_stores_raw_value ... ok
test store::tests::ambient_celsius_subtracts_offset ... ok
test store::tests::ambient_fahrenheit_converts_correctly ... ok
test store::tests::negative_ambient_temperature ... ok
test store::tests::update_overwrites_previous ... ok
test result: ok. 6 passed; 0 failed
Tests passed!
Why This Works
| Step | What happens |
|---|---|
Move .cargo/config.toml | Cargo no longer defaults to riscv32imc target — uses host |
Move build.rs | No embedded linker scripts injected |
--no-default-features | Disables embedded feature — all ESP deps are skipped |
--lib | Only tests the library, not the binary (which needs ESP deps) |
Integrating with Embassy
The bin/main.rs uses the store wrapped in a static Mutex, same pattern as Chapter 23:
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_sync::mutex::Mutex; use embassy_time::{Duration, Timer}; use esp_hal::clock::CpuClock; use esp_hal::timer::timg::TimerGroup; use esp_hal::tsens::{Config, TemperatureSensor}; use esp_println::println; use chapter24_temperature_store::TemperatureStore; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { loop {} } esp_bootloader_esp_idf::esp_app_desc!(); static STORE: Mutex<critical_section::RawMutex, TemperatureStore> = Mutex::new(TemperatureStore::new(30.0)); #[embassy_executor::task] async fn display_task() { loop { Timer::after(Duration::from_secs(5)).await; let guard = STORE.lock().await; match (guard.raw_celsius(), guard.ambient_celsius(), guard.ambient_fahrenheit()) { (Some(raw), Some(amb_c), Some(amb_f)) => { println!("[display] Raw: {}C | Ambient: {}C / {}F", raw, amb_c, amb_f); } _ => { println!("[display] No reading yet"); } } } } #[esp_rtos::main] async fn main(spawner: Spawner) -> ! { let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals = esp_hal::init(config); let timg0 = TimerGroup::new(peripherals.TIMG0); let sw_interrupt = esp_hal::interrupt::software::SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0); let temp_sensor = TemperatureSensor::new(peripherals.TSENS, Config::default()).unwrap(); spawner.spawn(display_task()).unwrap(); println!("Temperature store monitor started (offset: 30.0C)"); loop { let celsius = temp_sensor.get_temperature().to_celsius(); { let mut guard = STORE.lock().await; guard.update(celsius); } println!("[sensor] Raw: {}C", celsius); Timer::after(Duration::from_secs(2)).await; } }
The only difference from Chapter 23: instead of storing a raw f32, we store a TemperatureStore that computes ambient and Fahrenheit for us.
Note on const fn new()
For the static STORE to work, TemperatureStore::new() must be const:
#![allow(unused)] fn main() { pub const fn new(offset: f32) -> Self { Self { raw_celsius: None, offset, } } }
This lets us initialize the store at compile time inside the Mutex::new() call.
Exercise
Extend TemperatureStore with a TemperatureHistory that keeps the last N readings:
- Add a fixed-size array (e.g.,
[f32; 8]) and a count/index toTemperatureStore - Each
update()stores the reading in the ring buffer - Add methods:
average() -> Option<f32>,min() -> Option<f32>,max() -> Option<f32> - Write tests for all three methods, including edge cases (empty, single reading, full buffer, wrap-around)
- Run the tests with
./test.sh
Hint: A ring buffer with a fixed array works well in no_std — no heap allocation needed.
Chapter 25: Serial Commands & JSON Output
Learning Objectives
- Read serial input using
UsbSerialJtagwith async - Parse text commands in
no_std - Serialize data to JSON with
serdeandserde-json-core - Build a testable command parser
What’s New
Chapters 20–24 only wrote to serial via println!. Now we’ll also read from it — turning the ESP32-C3 into an interactive device that responds to typed commands with JSON output.
| Chapter | What we learned |
|---|---|
| 20 | Hello World — serial output, project setup |
| 21 | Blinky — GPIO, blocking delays |
| 22 | Embassy — async tasks, concurrency |
| 23 | Temperature sensor — shared state with Mutex |
| 24 | Temperature store — testable no_std library |
| 25 | Serial input, command parsing, serde + JSON |
Architecture
USB Serial (same cable as flashing)
│
┌──────────┴──────────┐
│ UsbSerialJtag │
│ TX ──► println! │ (output — we've been using this)
│ RX ◄── terminal │ (input — NEW)
└─────────────────────┘
│
┌──────────┴──────────┐
│ command_task │
│ reads lines, │
│ parses commands │
└──────────┬──────────┘
│ locks
▼
┌──────────────────────┐
│ static STORE │
│ Mutex<TempStore> │
└──────────────────────┘
▲ locks
│
┌──────────┴──────────┐
│ main task │
│ reads sensor, │
│ updates store │
└─────────────────────┘
The user types commands in their terminal (e.g., picocom, minicom, or the VS Code serial monitor). The command_task reads bytes asynchronously, assembles lines, and responds.
Reading Serial Input
The UsbSerialJtag Peripheral
The ESP32-C3’s USB port is a USB Serial/JTAG peripheral. Until now, esp_println wrote to it directly using raw register access. To read from it, we use the esp_hal::usb_serial_jtag::UsbSerialJtag HAL driver:
#![allow(unused)] fn main() { use esp_hal::usb_serial_jtag::UsbSerialJtag; // Take the peripheral, convert to async mode, split into RX and TX let usb_serial = UsbSerialJtag::new(peripherals.USB_DEVICE).into_async(); let (rx, _tx) = usb_serial.split(); }
into_async()— enables interrupt-driven async I/O (no busy-waiting)split()— separates into independent RX and TX halves so they can live in different tasks- We keep
_txunused becauseesp_printlnhandles output via direct register writes
Async Byte Reading
The RX half implements embedded_io_async::Read, which gives us an .await-able read:
#![allow(unused)] fn main() { use embedded_io_async::Read; let mut byte = [0u8; 1]; let n = Read::read(&mut rx, &mut byte).await; }
This call yields to the executor until a byte arrives — no CPU cycles wasted spinning. Compare this to the blocking read_byte() from nb which would monopolize the core.
Building Lines from Bytes
Serial terminals send one byte at a time. We accumulate them into a buffer until we see \r or \n:
#![allow(unused)] fn main() { #[embassy_executor::task] async fn command_task( mut rx: UsbSerialJtagRx<'static, Async>, ) { let mut buf = [0u8; 64]; let mut pos = 0usize; loop { let mut byte = [0u8; 1]; let n = embedded_io_async::Read::read(&mut rx, &mut byte).await; if n == Ok(0) { continue; } let b = byte[0]; if b == b'\r' || b == b'\n' { if pos > 0 { if let Ok(line) = core::str::from_utf8(&buf[..pos]) { handle_command(line).await; } pos = 0; } continue; } if pos < buf.len() { buf[pos] = b; pos += 1; } } } }
Key points:
- Fixed
[u8; 64]buffer — no heap allocation needed core::str::from_utf8validates that the bytes are valid UTF-8 before parsing- The
posindex resets after each command
The Command Parser
This is pure Rust — no hardware, fully testable on the host:
#![allow(unused)] fn main() { // src/command.rs #[derive(Debug, PartialEq)] pub enum Command<'a> { Status, SetOffset(f32), Help, Unknown(&'a str), } pub fn parse(input: &str) -> Command<'_> { let trimmed = input.trim(); if trimmed.is_empty() { return Command::Unknown(trimmed); } let (cmd, arg) = match trimmed.find(' ') { Some(pos) => (&trimmed[..pos], trimmed[pos + 1..].trim()), None => (trimmed, ""), }; match cmd { "status" => Command::Status, "help" => Command::Help, "offset" => match arg.parse::<f32>() { Ok(val) => Command::SetOffset(val), Err(_) => Command::Unknown(trimmed), }, _ => Command::Unknown(trimmed), } } }
Note the Command<'a> lifetime — Unknown borrows from the input string rather than allocating a copy. This is idiomatic no_std Rust: avoid allocation by borrowing.
Testing the Parser
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn parse_status() { assert_eq!(parse("status"), Command::Status); assert_eq!(parse(" status "), Command::Status); } #[test] fn parse_offset_valid() { assert_eq!(parse("offset 25.5"), Command::SetOffset(25.5)); assert_eq!(parse("offset -10"), Command::SetOffset(-10.0)); } #[test] fn parse_offset_missing_value() { assert_eq!(parse("offset"), Command::Unknown("offset")); } #[test] fn parse_unknown() { assert_eq!(parse("reboot"), Command::Unknown("reboot")); } } }
These run on your laptop with ./test.sh — no ESP32 needed.
JSON Output with Serde
Why Serde in no_std?
Standard serde_json needs an allocator. For embedded, serde-json-core serializes directly into a fixed-size [u8] buffer — zero heap allocation.
A Serializable Reading
#![allow(unused)] fn main() { use serde::Serialize; #[derive(Serialize)] pub struct Reading { pub raw_celsius: f32, pub ambient_celsius: f32, pub ambient_fahrenheit: f32, pub offset: f32, } }
The TemperatureStore produces a Reading snapshot:
#![allow(unused)] fn main() { impl TemperatureStore { pub fn reading(&self) -> Option<Reading> { let raw = self.raw_celsius?; let amb_c = raw - self.offset; Some(Reading { raw_celsius: raw, ambient_celsius: amb_c, ambient_fahrenheit: amb_c * 9.0 / 5.0 + 32.0, offset: self.offset, }) } } }
Serializing to a Buffer
#![allow(unused)] fn main() { let mut json_buf = [0u8; 128]; let len = serde_json_core::to_slice(&reading, &mut json_buf).unwrap(); let json = core::str::from_utf8(&json_buf[..len]).unwrap(); println!("{}", json); }
Output:
{"raw_celsius":52.3,"ambient_celsius":22.3,"ambient_fahrenheit":72.14,"offset":30.0}
Testing Serialization
#![allow(unused)] fn main() { #[test] fn reading_serializes_to_json() { let mut store = TemperatureStore::new(30.0); store.update(52.0); let reading = store.reading().unwrap(); let mut buf = [0u8; 128]; let len = serde_json_core::to_slice(&reading, &mut buf).unwrap(); let json = core::str::from_utf8(&buf[..len]).unwrap(); assert!(json.contains("\"raw_celsius\"")); assert!(json.contains("\"ambient_celsius\"")); assert!(json.contains("\"offset\"")); } }
Handling Commands
The handle_command function connects the parser to the store:
#![allow(unused)] fn main() { async fn handle_command(line: &str) { match command::parse(line) { Command::Status => { let guard = STORE.lock().await; match guard.reading() { Some(reading) => { let mut json_buf = [0u8; 128]; match serde_json_core::to_slice(&reading, &mut json_buf) { Ok(len) => { if let Ok(json) = core::str::from_utf8(&json_buf[..len]) { println!("{}", json); } } Err(_) => println!("{{\"error\":\"serialization failed\"}}"), } } None => println!("{{\"error\":\"no reading yet\"}}"), } } Command::SetOffset(val) => { let mut guard = STORE.lock().await; guard.set_offset(val); println!("OK offset={}", val); } Command::Help => { println!("Commands:"); println!(" status — print current reading as JSON"); println!(" offset <value> — set die-to-ambient offset"); println!(" help — show this message"); } Command::Unknown(input) => { println!("Unknown command: '{}'. Type 'help' for usage.", input); } } } }
Note the {{ in the error messages — that’s Rust’s escape for literal braces inside println! format strings.
Dependencies
[dependencies]
serde = { version = "1.0", default-features = false, features = ["derive"] }
serde-json-core = "0.6"
# ... embedded deps unchanged from chapter 24, plus:
embedded-io-async = { version = "0.6", optional = true }
serdewithdefault-features = false— disables std, keeps#[derive(Serialize)]serde-json-core— no_std JSON serializer using fixed buffersembedded-io-async— theReadtrait for async serial I/O
Expected Session
=== Temperature Monitor ===
Type 'help' for commands
> help
Commands:
status — print current reading as JSON
offset <value> — set die-to-ambient offset
help — show this message
> status
{"raw_celsius":52.3,"ambient_celsius":22.3,"ambient_fahrenheit":72.14,"offset":30.0}
> offset 28
OK offset=28
> status
{"raw_celsius":52.1,"ambient_celsius":24.1,"ambient_fahrenheit":75.38,"offset":28.0}
Day 4 Recap
Over six chapters, we went from zero to an interactive embedded system:
| Chapter | Concept | Key takeaway |
|---|---|---|
| 20 | Hello World | no_std, no_main, serial output |
| 21 | Blinky | GPIO, blocking delays, hardware control |
| 22 | Embassy | Async tasks, await instead of spin |
| 23 | Temperature sensor | Shared state with Mutex, producer/consumer |
| 24 | Temperature store | Testable no_std library, cfg_attr, feature flags |
| 25 | Serial commands | Serial input, command parsing, serde + JSON |
Each chapter built on the previous one. The same Rust concepts from Days 1–3 (ownership, traits, modules, testing) apply directly to embedded — just with no_std constraints.
Exercise
Add a history command that returns the last N readings as a JSON array:
- Extend
TemperatureStorewith a ring buffer (fixed-size array, e.g.,[Option<f32>; 8]) - Each
update()stores the reading in the next slot - Add a
history()method that returns a serializable struct containing the buffered readings - Parse
"history"as a newCommandvariant - Respond with JSON like:
{"readings":[52.1,51.8,52.3],"count":3} - Write tests for the ring buffer (empty, partial, full, wrap-around) and run them with
./test.sh
Hint: heapless::Vec<f32, 8> (already in your dependency tree via embassy) is a stack-allocated Vec that implements Serialize — it’s a convenient alternative to a raw array + index.