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 --version and cargo --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 clippy to catch common mistakes and improve your code.
  • rustfmt: Run with cargo fmt to 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 your PATH; without it, builds will fail with errors such as error calling dlltool 'dlltool.exe': program not found.

After installation, verify:

rustc --version
cargo --version

Understanding the Rust Toolchain

ToolPurposeC++ Equivalent.NET Equivalent
rustcCompilerg++, clang++csc, dotnet build
cargoBuild system & package managercmake + conan/vcpkgdotnet CLI + NuGet
rustupToolchain manager-.NET SDK manager
clippyLinterclang-tidyCode analyzers
rustfmtFormatterclang-formatdotnet 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

CommandPurposeSimilar to
cargo newCreate new projectdotnet new, cmake init
cargo buildCompile projectmake, dotnet build
cargo runBuild & run./a.out, dotnet run
cargo testRun testsctest, dotnet test
cargo docGenerate documentationdoxygen
cargo checkFast syntax/type checkIncremental 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.toml is like .csproj
  • crates.io is like NuGet
  • No garbage collector - deterministic destruction

Quick Wins: Why You’ll Love Rust’s Tooling

  1. Unified tooling: Everything works together seamlessly
  2. Excellent error messages: The compiler teaches you Rust
  3. Fast incremental compilation: cargo check is lightning fast
  4. Built-in testing: No need for external test frameworks
  5. 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

IssueSolution
“rustc not found”Restart terminal after installation
Slow compilationEnable sccache: cargo install sccache
Can’t debugZed has built-in debugging support
Windows linker errorsEnsure 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 tree
  • cargo doc --open - Generate and view documentation
  • cargo clippy - Run the linter

Exercise 1.2: Build Configurations

  1. Create a simple program that prints the numbers 1 to 1_000_000
  2. Time the difference between debug and release builds
  3. Compare binary sizes

Exercise 1.3: First Debugging Session

  1. Create a program with an intentional panic
  2. Set a breakpoint in Zed
  3. 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:

  1. Memory Safety: Prevent segfaults, buffer overflows, and memory leaks
  2. Thread Safety: Eliminate data races at compile time

Comparison with Familiar Languages

ConceptC++C#/.NETRust
Null checkingRuntime (segfaults)Runtime (NullReferenceException)Compile-time (Option)
Memory managementManual (new/delete)GCCompile-time (ownership)
Thread safetyRuntime (mutexes)Runtime (locks)Compile-time (Send/Sync)
Type inferenceauto (C++11+)varExtensive

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> or std::pair<T1, T2>
  • C#: (int, double, byte) value tuples or Tuple<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] or std::array<int, 5>
  • C#: int[] arr = new int[5] (heap) or Span<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

AspectC++C#/.NETRust
Return syntaxreturn x;return x;x (no semicolon)
Parameter typesint xint xx: i32
Return typeint 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

FeatureC++C#/.NETRust
for-eachfor (auto& x : vec)foreach (var x in list)for x in &vec
Index loopfor (int i = 0; i < n; i++)for (int i = 0; i < n; i++)for i in 0..n
Infinitewhile (true)while (true)loop
Break with valueNot supportedNot supportedbreak 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

TypeC++ EquivalentC#/.NET EquivalentRust
Ownedstd::stringstringString
View/Slicestd::string_viewReadOnlySpan<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

  1. Immutability by default encourages safer, more predictable code
  2. Type inference is powerful but explicit types help with clarity
  3. String handling is more complex but prevents many Unicode bugs
  4. Collections are memory-safe with compile-time bounds checking
  5. 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:

  1. Defines a function calculate_bmi(height: f64, weight: f64) -> f64
  2. Uses the function to calculate BMI for several people
  3. 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:

  1. Takes a sentence as input
  2. Returns the longest word in the sentence
  3. 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

FeatureC++C#/.NETRust
Definitionstruct Person { std::string name; };class Person { public string Name; }struct Person { name: String }
InstantiationPerson p{"Alice"};var p = new Person { Name = "Alice" };Person { name: "Alice".to_string() }
Field Accessp.namep.Namep.name
MethodsInside structInside classSeparate 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

LanguageNull RepresentationSafety
C++nullptr, raw pointersRuntime crashes
C#/.NETnull, Nullable<T>Runtime exceptions
RustOption<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

  1. Structs group related data - similar to classes but with explicit memory layout
  2. Methods are separate from data definition in impl blocks
  3. Enums are powerful - they can hold data and represent complex state
  4. Pattern matching is exhaustive - compiler ensures all cases are handled
  5. Option and Result eliminate null pointer exceptions and improve error handling
  6. 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

LanguageMemory ManagementCommon IssuesPerformanceSafety
C++Manual (new/delete, RAII)Memory leaks, double-free, dangling pointersHighRuntime crashes
C#/.NETGarbage CollectorGC pauses, memory pressureMediumRuntime exceptions
RustCompile-time ownershipCompiler errors (not runtime!)HighCompile-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

  1. Ownership prevents entire classes of bugs at compile time
  2. Move semantics are default - be explicit when you want copies
  3. Borrowing allows safe sharing without ownership transfer
  4. Lifetimes ensure references are always valid but often inferred
  5. The compiler is your friend - ownership errors are caught early
  6. 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

FeatureRegular ReferenceSmart Pointer
OwnershipBorrows dataOwns data
Memory locationStack or heapUsually heap
DeallocationAutomatic (owner drops)Automatic (smart pointer drops)
Runtime overheadNoneSome (depends on type)

Comparison with C++/.NET

RustC++ EquivalentC#/.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 equivalentLock-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

  1. Large data: Move large structs to heap to avoid stack overflow
  2. Recursive types: Enable recursive data structures
  3. Trait objects: Store different types behind a common trait
  4. 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 PointerAllocationReference CountingThread SafetyInterior Mutability
Box<T>HeapNoNoNo
Rc<T>HeapYes (non-atomic)NoNo
Arc<T>HeapYes (atomic)YesNo
RefCell<T>Stack/HeapNoNoYes (runtime)
Weak<T>No allocationWeak countingDepends on targetNo

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

  1. Box for single ownership heap allocation and recursive types
  2. Rc for shared ownership in single-threaded contexts
  3. RefCell for interior mutability with runtime borrow checking
  4. Arc for shared ownership across threads
  5. Weak to break reference cycles and avoid memory leaks
  6. Combine smart pointers for complex sharing patterns (e.g., Rc<RefCell<T>>)
  7. 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

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

CollectionUse When You NeedPerformance
Vec<T>Ordered sequence, index accessO(1) index, O(n) search
HashMap<K,V>Fast key-value lookupsO(1) average all operations
HashSet<T>Unique values, fast membership testO(1) average all operations
BTreeMap<K,V>Sorted keys, range queriesO(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:

  1. add_grade() method:

    • Use self.grades.entry(student).or_insert_with(HashMap::new)
    • Then insert the grade: .insert(subject, grade)
  2. get_student_average():

    • Use self.grades.get(student)? to get the student’s grades
    • Use .values().sum::<f64>() / values.len() as f64
  3. 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
  4. get_top_students():

    • Use map() to convert students to (name, average) pairs
    • Use collect::<Vec<_>>() and sort_by() with float comparison
    • Use take(n) to get top N

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

  1. HashMap<K,V> for fast key-value lookups with the Entry API for efficiency
  2. HashSet for unique values and set operations
  3. BTreeMap/BTreeSet when you need sorted data or range queries
  4. Custom keys must implement Hash + Eq (or Ord for BTree*)
  5. Can’t modify while iterating - collect changes first
  6. 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

ConceptC++C#/JavaRust
InterfacePure virtual classInterfaceTrait
Multiple inheritanceYes (complex)No (interfaces only)Yes (traits)
Default implementationsNoYes (C# 8+, Java 8+)Yes
Associated typesNoNoYes
Static dispatchTemplatesGenericsGenerics
Dynamic dispatchVirtual functionsVirtual methodsTrait 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

  1. Traits define shared behavior across different types
  2. Static dispatch (generics) is faster but increases code size
  3. Dynamic dispatch (trait objects) enables runtime polymorphism
  4. Associated types provide cleaner APIs than generic parameters
  5. Operator overloading is done through standard traits
  6. Supertraits allow building trait hierarchies
  7. From/Into traits enable type conversions
  8. 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

FeatureRustC++ Templates.NET Generics
CompilationMonomorphizationTemplate instantiationRuntime generics
Type checkingAt definitionAt instantiationAt definition
ConstraintsTrait boundsConcepts (C++20)Where clauses
Code bloatYes (like C++)YesNo
PerformanceZero-costZero-costSmall 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:

  1. PriorityQueue methods:

    • new(): Return PriorityQueue { items: Vec::new() }
    • enqueue(): Push item then sort with self.items.sort()
    • dequeue(): Use self.items.pop() (gets highest after sorting)
    • peek(): Use self.items.last()
  2. Task::priority():

    • Return self.urgency
  3. Task::cmp():

    • Use self.urgency.cmp(&other.urgency)
  4. AdvancedQueue::enqueue():

    • Use binary_search_by_key() to find insertion point
    • Use insert() to maintain sorted order
  5. QueueOperations trait implementation:

    • Implement for PriorityQueue<T> by delegating to existing methods

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

FeatureC/C++ switchC# switchRust match
ExhaustivenessNoPartial (warnings)Yes (enforced)
Complex patternsNoLimitedFull destructuring
GuardsNoLimited (when)Yes
Return valuesNoExpression (C# 8+)Always expression
Fall-throughDefault (dangerous)NoNot 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

  1. Exhaustiveness - Rust’s compiler ensures you handle all possible cases
  2. Pattern matching is an expression - Every match arm must return the same type
  3. Use if let for simple Option/Result handling instead of verbose match
  4. Match guards enable complex conditional logic within patterns
  5. Destructuring allows you to extract values from complex data structures
  6. Order matters - More specific patterns should come before general ones
  7. @ binding lets you capture values while pattern matching
  8. 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

TypeExamplesRust Approach
RecoverableFile not found, network timeoutResult<T, E>
UnrecoverableArray out of bounds, null pointerpanic!

Comparison with Other Languages

LanguageApproachProsCons
C++Exceptions, error codesFamiliarRuntime overhead, can be ignored
C#/.NETExceptionsClean syntaxPerformance cost, hidden control flow
GoExplicit error returnsExplicit, fastVerbose
RustResult<T, E>Explicit, zero-costMust 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

  1. Use Result<T, E> for recoverable errors, panic! for unrecoverable ones
  2. The ? operator makes error propagation clean and efficient
  3. Custom error types should implement Display and Error traits
  4. Error conversion with From trait enables seamless ? usage
  5. anyhow is great for applications, thiserror for libraries
  6. Chain operations with Result for clean error handling
  7. Test error cases as thoroughly as success cases
  8. 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]
}
}
#![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

  1. Iterators are lazy - nothing happens until you consume them
  2. Zero-cost abstraction - same performance as hand-written loops
  3. Composable - chain operations for clean, readable code
  4. collect() is powerful - converts to any collection type
  5. Closures capture environment - be aware of borrowing vs moving
  6. Error handling - Result<Vec, E> vs Vec<Result<T, E>>
  7. 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 pub and privacy rules
  • Use the use keyword 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

  1. Modules organize code into logical units with clear boundaries
  2. Privacy by default - items are private unless marked pub
  3. The use keyword brings items into scope for convenience
  4. File structure mirrors module structure for large projects
  5. pub use for re-exports creates clean public APIs
  6. Visibility modifiers (pub(crate), pub(super)) provide fine-grained control
  7. Module design should hide implementation details and expose minimal APIs
  8. 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

EditionReleasedDefault ResolverKey Changes
2015Rust 1.0v1Original edition, extern crate required
2018Rust 1.31v1Module system improvements, async/await, NLL
2021Rust 1.56v2Disjoint captures, into_iter() arrays, reserved identifiers
2024Rust 1.85v3MSRV-aware resolver, gen keyword, unsafe env functions

Key Edition Changes

Edition 2018:

  • No more extern crate declarations (except for macros)
  • Uniform path syntax in use statements
  • async/await keywords 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)
  • gen keyword reserved (for future generator blocks; not yet stabilized)
  • std::env::set_var and remove_var marked unsafe
  • Tail expression temporary lifetime changes
  • extern blocks must now be written as unsafe extern (items inside can be marked safe)

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 TypeRecommended EditionRationale
New projectsLatest stableAccess to all improvements
LibrariesConservative (2018/2021)Wider compatibility
ApplicationsLatest stableModern features
Legacy codeKeep currentMigrate when beneficial

2. Toolchain Channels

Rust uses a release train model with three channels:

Nightly (daily) → Beta (6 weeks) → Stable (6 weeks)
ChannelRelease CycleStabilityUse Case
Stable6 weeksGuaranteed stableProduction
Beta6 weeksGenerally stableTesting upcoming releases
NightlyDailyMay breakExperimental 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

ResolverDefault ForBehavior
v1Edition 2015/2018Unifies features across all uses
v2Edition 2021Independent feature resolution per target
v3Edition 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-version is 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 TypeCommit?Reason
Binary/ApplicationYesReproducible builds
LibraryNoAllow flexible version resolution
Workspace rootYesConsistent 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 TypeSuggested MSRVRationale
Foundational libraries6-12 months oldMaximum compatibility
Application libraries3-6 months oldBalance features/compatibility
ApplicationsCurrent stableUse latest features
Internal toolsLatest stableNo 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:

  1. Committed Cargo.lock for applications
  2. Pinned toolchain via rust-toolchain.toml
  3. --locked flag in CI builds
  4. 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:

MacroPurposePanics when
assert!(expr)Boolean checkexpr is false
assert_eq!(left, right)Equality checkleft != right
assert_ne!(left, right)Inequality checkleft == 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! and assert_ne! require the compared types to implement both PartialEq and Debug. Most standard types do; for your own types, add #[derive(Debug, PartialEq)].

Comparison with C#/.NET

C# / .NETRustNotes
[TestClass]#[cfg(test)] mod testsTest 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:

KindLocationCompiles asTests…
Unit testssrc/*.rs inside #[cfg(test)]Part of the cratePrivate + public API
Integration teststests/*.rs directorySeparate cratePublic API only
Doc tests/// comments in sourceSeparate compilationExamples 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

QuestionAnswer
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., Display impls, 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

SituationApproach
Known specific inputs and expected outputsStandard #[test]
Mathematical invariants (commutativity, associativity)proptest
Parsers: parse(format(x)) == xproptest
“This should never panic for any input”proptest
Testing a function against a simpler reference implementationproptest

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

#TestTechnique
1plain_text_strips_headingsassert_eq!
2plain_text_strips_bold_and_italicassert_eq!
3plain_text_converts_links_to_textassert_eq!
4extract_links_finds_all_linksassert_eq! on Vec
5extract_links_returns_empty_for_no_linksassert!(_.is_empty())
6count_headings_by_levelHashMap assertions
7transform_emphasis_bold_to_uppercaseassert_eq!
8transform_emphasis_italic_to_lowercaseassert_eq!
9transform_emphasis_panics_on_unmatched_bold#[should_panic(expected = "...")]
10round_trip_plain_text_is_stableResult<(), String> return
11large_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_matches to 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 -- --nocapture to see println! output from your code while debugging.

Solution

The reference solution is in solutions/day3/14_testing/src/lib.rs.

Summary

WhatHow
Write a test#[test] fn name() { ... }
Check equalityassert_eq!(actual, expected)
Expect a panic#[should_panic(expected = "msg")]
Return Result from testfn test() -> Result<(), E> { ... }
Test private functionsPut tests in #[cfg(test)] mod tests inside the same file
Integration teststests/*.rs directory
Doc testsCode blocks in /// comments
Run all testscargo test
Filter testscargo test name_filter
See outputcargo test -- --nocapture
Skip slow tests#[ignore], run with cargo test -- --ignored
Code coveragecargo llvm-cov --open
Property testingproptest! 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:

SpecifierMatchesExample
$x:exprExpressions5 + 3, foo(), if a { b } else { c }
$x:identIdentifiersmy_var, String, foo
$x:tyTypesi32, Vec<String>, &str
$x:ttSingle token treeAny token or ()/[]/{}-delimited group
$x:patPatternsSome(x), 0..=9, _
$x:blockCode blocks{ stmt; stmt; expr }
$x:stmtStatementslet x = 5, x.push(1)
$x:itemItemsfn, struct, impl, mod definitions
$x:pathPathsstd::vec::Vec, crate::module::Type
$x:literalLiterals42, "hello", true
$x:visVisibilitypub, pub(crate), (empty)
$x:lifetimeLifetimes'a, 'static
$x:metaAttributesderive(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

  1. Custom Derive Macros
  2. Attribute Macros
  3. 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 fields
  • derive_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

  1. Prefer Functions Over Macros: Use macros only when functions can’t achieve your goal
  2. Keep Macros Simple: Complex macros are hard to debug and maintain
  3. Document Macro Behavior: Include examples and expansion examples
  4. Use Internal Rules: Hide implementation details with @ prefixed rules
  5. Test Macro Expansions: Use cargo expand to verify generated code
  6. Consider Procedural Macros: For complex transformations, proc macros are clearer
  7. Maintain Hygiene: Avoid capturing external variables unless intentional

Limitations and Gotchas

  1. Type Information: Macros run before type checking
  2. Error Messages: Macro errors can be cryptic
  3. IDE Support: Limited autocomplete and navigation
  4. Compilation Time: Heavy macro use increases compile times
  5. 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:

  1. Dereference raw pointers - Direct memory access
  2. Call unsafe functions/methods - Including FFI functions
  3. Access/modify mutable statics - Global state management
  4. Implement unsafe traits - Like Send and Sync
  5. 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 ErrorInfo struct to pass error details (code + message)
  • Catch panics with std::panic::catch_unwind to 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

  1. Minimize Unsafe Code: Keep unsafe blocks small and isolated
  2. Document Safety Requirements: Clearly state what callers must guarantee
  3. Use Safe Abstractions: Wrap unsafe code in safe APIs
  4. Validate All Inputs: Never trust data from FFI boundaries
  5. Handle Errors Gracefully: Convert panics to error codes at FFI boundaries
  6. Test Thoroughly: Include fuzzing and property-based testing
  7. Use Tools: Run Miri, Valgrind, and sanitizers on FFI code

Common Pitfalls

  1. Memory Management: Ensure consistent allocation/deallocation across FFI
  2. String Encoding: C uses null-terminated strings, Rust doesn’t
  3. ABI Compatibility: Always use #[repr(C)] for FFI structs
  4. Lifetime Management: Raw pointers don’t encode lifetimes
  5. 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:

  1. Format-agnostic. You derive Serialize and Deserialize once 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.

  2. 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.

  3. 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

AttributeLevelEffect
#[serde(rename_all = "camelCase")]Struct / EnumRename all fields or variants
#[serde(rename = "x")]Field / VariantRename a single field or variant
#[serde(skip_serializing_if = "...")]FieldOmit field when condition is true
#[serde(default)]Field / StructUse Default for missing fields
#[serde(flatten)]FieldInline nested struct fields
#[serde(skip)]FieldNever serialize or deserialize
#[serde(alias = "x")]FieldAccept 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

AttributeJSON shapeBest 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

CrateFormatTypical use case
serde_jsonJSONWeb APIs, configuration
tomlTOMLConfiguration files (Cargo.toml itself is TOML)
serde_ymlYAMLKubernetes manifests, CI configs (replaces deprecated serde_yaml)
bincodeBinaryCompact binary serialization, IPC
csvCSVTabular data import/export
rmp-serdeMessagePackEfficient 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# / .NETRust (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 / JObjectserde_json::ValueUntyped JSON access
[JsonDerivedType] discriminator#[serde(tag = "type")]Tagged unions
Source generators (.NET 6+)Derive macrosCompile-time code generation
JsonSerializerOptions (global)Per-type attributesConfiguration 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

ConceptKey takeaway
Serialize / DeserializeDerive macros that generate format-agnostic serialization code
serde_json::to_stringSerialize a value to a JSON string
serde_json::from_strDeserialize a JSON string into a typed value
serde_json::ValueUntyped 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-agnosticSame 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:

AspectThreadsAsync/Await
Best forCPU-intensive workI/O-bound operations
Memory overhead~2MB per thread~2KB per task
SchedulingOS kernelUser-space runtime
Blocking operationsNormalMust use async variants
Ecosystem maturityCompleteMature (tokio, axum, etc.)
Learning curveModerateSteeper 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> where T is the type of the last expression.
  • async move { ... } captures variables by value (like move || closures). Without move, 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 like map.

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

  1. Start simple: Use threads for CPU work, async for I/O
  2. Avoid blocking: Never block the async runtime
  3. Choose appropriate synchronization: Arc+Mutex for threads, channels for both
  4. Profile and measure: Don’t assume, benchmark your specific use case
  5. Handle errors properly: Both models require careful error handling
  6. 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.

AspectTokioEmbassy
TargetDesktop / server (Linux, macOS, Windows)Bare-metal microcontrollers (no OS)
SchedulingThread-pool executorInterrupt-driven single-core executor
MemoryHeap-allocated tasks (Box<dyn Future>)Statically allocated tasks (no heap)
Blockingspawn_blocking for CPU workEverything is cooperative; blocking = stalling
TimerOS timers via epoll/kqueueHardware peripheral timers
Ecosystemaxum, 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:

  1. The executor puts the CPU to sleep (low-power wait-for-interrupt).
  2. A hardware event fires (timer expires, UART byte received, GPIO edge detected).
  3. The interrupt handler wakes the corresponding task.
  4. 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

#TestConcept
1count_single_textBasic Mutex locking
2count_ignores_case_and_punctuationWord normalisation
3count_empty_inputEdge case
4parallel_matches_sequentialthread::spawn + Arc<Mutex<>>
5top_n_returns_most_frequentSorting by frequency
6top_n_alphabetical_on_tieTie-breaking
7merge_combines_countsMulti-lock coordination
8reset_clears_all_countsHashMap::clear under lock
9channel_counting_matches_sequentialmpsc::channel
10concurrent_access_is_safeArc<WordCounter> shared across 10 threads

Tips

  • Normalise words to lowercase and strip leading/trailing ASCII punctuation.
  • In count_parallel, build a local HashMap per thread first, then merge into the shared map under a single lock — this minimises lock contention.
  • For count_with_channel, remember to drop(tx) after spawning all threads so the rx iterator terminates.
  • top_n should 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::From and a Result<T> alias
  • Use feature flags to conditionally compile modules and dependencies
  • Recognize the Builder, Newtype, and Type-State patterns
  • Understand RAII via the Drop trait and zero-copy techniques with Cow

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, not main.rs. If your logic is in main, 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 a From<std::io::Error> for Error impl (and similarly for image::ImageError). This is what makes ? propagation work: when a function returns std::io::Error and the caller returns Result<T, Error>, the compiler uses the From impl to convert automatically.
  • Domain errors like UnsupportedFormat have 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.
  • Display as Debug (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, implement Display properly per variant.
  • core::result::Result on the right-hand side of the type alias makes it visually clear that we refer to the standard library’s Result, not recursively referencing the alias being defined. Both core::result::Result and std::result::Result are the same type.

Comparison with alternatives

ApproachProsCons
derive_more::FromLightweight, selective #[from], minimal proc-macroManual Display impl
thiserrorGenerates Display from #[error("...")] attributesHeavier proc-macro for the same From generation
anyhowMinimal boilerplate, good for scripts and prototypesErases 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

  1. Keep main.rs thin – parse arguments, call into the library, print errors. Nothing else.
  2. Define a crate-level Error and Result<T> – propagation with ? should work across your entire crate without manual conversions.
  3. Use traits to abstract behavior – this enables testing with mocks and swapping implementations via feature flags.
  4. Gate optional functionality behind feature flags – compile only what is needed.
  5. Make invalid states unrepresentable – use the type system (enums, newtypes, type-state) instead of runtime checks.
  6. Prefer borrowing over cloning – pass &str and &[u8] where ownership is not needed.
  7. Use Cow when most inputs pass through unchanged – avoid allocations in the common path.
  8. Run clippy – it catches unidiomatic patterns and common mistakes. Treat warnings as errors in CI.
  9. 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:

PatternPurpose
Thin main.rsTestability and reuse via library-first design
Trait-based backendsSwappable implementations, mockable in tests
Error enum + Result<T> aliasUnified error handling with ? propagation
Feature-gated modulesConditional compilation of entire subsystems
BuilderFlexible construction with validation
NewtypeType-safe wrappers over primitives
Type-StateCompile-time enforcement of state transitions
RAII / DropDeterministic resource cleanup
Zero-copy / CowAvoid unnecessary allocations
Best practicesGuidelines 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 run will flash the chip and open a serial monitor
  • target: ESP32-C3 uses the RISC-V riscv32imc-unknown-none-elf target
  • build-std: Rebuilds core from source (needed for no_std targets)

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:

LinePurpose
#![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:

  1. Builds the firmware (cross-compiling to RISC-V)
  2. Flashes it to the ESP32-C3 via USB
  3. 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 --release for faster builds and smaller binaries. The [profile.dev] already sets opt-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: -> ! means main loops forever — there’s nowhere to return to

Troubleshooting

ProblemSolution
Permission denied on serial portsudo usermod -a -G dialout $USER and re-login
espflash can’t find the chipTry a different USB cable (some are charge-only)
Build fails with linker errorsMake sure build.rs and .cargo/config.toml are present
rust-src component missingRun 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)).await lets 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

  1. Change the blink rate to 200ms on, 800ms off (asymmetric blink)
  2. Add esp-println and print “ON” / “OFF” each time the LED changes state
  3. 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
#![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 loopTimer::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 executor
  • esp_rtos::start(): Connects Embassy to a hardware timer and software interrupt for task scheduling
  • async fn main: Our main is now an async task
  • Spawner: Used to spawn additional async tasks (we’ll use it below)
  • Timer::after().await: Suspends the task — the CPU can sleep instead of spinning
  • led.toggle(): Flips the pin state — simpler than separate set_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 fn with 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 use statics

Blocking vs Async: When to Use What

BlockingAsync (Embassy)
ComplexitySimplerSlightly more setup
CPU usage100% during delaysNear 0% during delays
Multiple activitiesHard (manual state machines)Easy (spawn tasks)
Best forSimple single-purpose codeAnything 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):

  1. Add esp-println to the dependencies
  2. Create a second task that prints “tick” every 3 seconds
  3. Observe how both the LED and the serial output run concurrently
  4. 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::Mutex for 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 the TSENS peripheral
  • get_temperature() returns a temperature value; .to_celsius() converts it to f32
  • 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>None until the first reading arrives
  • static — 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.

  1. Create a led_task that reads LATEST_TEMP every second
  2. If the temperature is above 55°C, blink fast (200ms)
  3. If below 55°C, blink slow (1000ms)
  4. Hint: You’ll need to pass the LED pin. Since Embassy tasks can’t take non-Send borrows, create the Output inside the task by passing the GPIO pin, or use another static for 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_std library 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:

  1. riscv32imc-unknown-none-elf — for the ESP32-C3 (no_std)
  2. 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_std is active — no standard library
  • When running cargo test: no_std is 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

StepWhat happens
Move .cargo/config.tomlCargo no longer defaults to riscv32imc target — uses host
Move build.rsNo embedded linker scripts injected
--no-default-featuresDisables embedded feature — all ESP deps are skipped
--libOnly 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:

  1. Add a fixed-size array (e.g., [f32; 8]) and a count/index to TemperatureStore
  2. Each update() stores the reading in the ring buffer
  3. Add methods: average() -> Option<f32>, min() -> Option<f32>, max() -> Option<f32>
  4. Write tests for all three methods, including edge cases (empty, single reading, full buffer, wrap-around)
  5. 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 UsbSerialJtag with async
  • Parse text commands in no_std
  • Serialize data to JSON with serde and serde-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.

ChapterWhat we learned
20Hello World — serial output, project setup
21Blinky — GPIO, blocking delays
22Embassy — async tasks, concurrency
23Temperature sensor — shared state with Mutex
24Temperature store — testable no_std library
25Serial 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 _tx unused because esp_println handles 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_utf8 validates that the bytes are valid UTF-8 before parsing
  • The pos index 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 }
  • serde with default-features = false — disables std, keeps #[derive(Serialize)]
  • serde-json-core — no_std JSON serializer using fixed buffers
  • embedded-io-async — the Read trait 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:

ChapterConceptKey takeaway
20Hello Worldno_std, no_main, serial output
21BlinkyGPIO, blocking delays, hardware control
22EmbassyAsync tasks, await instead of spin
23Temperature sensorShared state with Mutex, producer/consumer
24Temperature storeTestable no_std library, cfg_attr, feature flags
25Serial commandsSerial 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:

  1. Extend TemperatureStore with a ring buffer (fixed-size array, e.g., [Option<f32>; 8])
  2. Each update() stores the reading in the next slot
  3. Add a history() method that returns a serializable struct containing the buffered readings
  4. Parse "history" as a new Command variant
  5. Respond with JSON like: {"readings":[52.1,51.8,52.3],"count":3}
  6. 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.