IS 4010: Application Development with Artificial Intelligence

Week 13: Idiomatic Rust

Brandon M. Greenwell

Week 13 overview

Session 1: iterators and closures

  • Iterator methods (.map(), .filter(), .fold(), .collect())
  • Closures and capture semantics
  • Closure traits: Fn, FnMut, FnOnce
  • Building data processing pipelines
  • Lazy evaluation and allocation choices

Session 2: smart pointers and error handling

  • Smart pointers: Box<T>, Rc<T>, RefCell<T>
  • Interior mutability patterns
  • When to use each smart pointer
  • Idiomatic error handling with Result<T, E>
  • The ? operator and custom error types

Why idiomatic Rust matters

What the patterns provide:

  • Functional style: Process data without manual loops
  • Flexible ownership: Build complex data structures safely
  • Explicit errors: Expected failure categories in return types
  • Iterator composition: Lazy adaptors that can avoid intermediate collections

Project examples:

  • Ripgrep: Fast search tool using iterators
  • Servo: Browser engine with smart pointers
  • Tokio: Async runtime with Result-based APIs

Session 1: iterators and closures

The problem: manual iteration

Traditional loop-based approach:

let numbers = vec![1, 2, 3, 4, 5, 6];
let mut result = Vec::new();

for num in &numbers {
    if num % 2 == 0 {  // Keep evens
        let squared = num * num;  // Square them
        result.push(squared);
    }
}
// result = [4, 16, 36]

Try it yourself: Run in Rust Playground

Problems:

  • Mutable state (result)
  • Multiple steps spread across lines
  • Intent buried in implementation
  • Error-prone (easy to forget to push, etc.)

Introducing iterators

Functional approach with iterator chains:

let numbers = vec![1, 2, 3, 4, 5, 6];

let result: Vec<i32> = numbers
    .iter()
    .filter(|&&n| n % 2 == 0)  // Keep evens
    .map(|&n| n * n)            // Square them
    .collect();

// result = [4, 16, 36]

Try it yourself: Run in Rust Playground

Benefits:

  • Declarative: says what to do, not how
  • Composable: chain operations together
  • Lazy: only evaluates when needed
  • Optimizable: iterator chains can compile to efficient loops, but measure when performance matters

Iterator trait documentation

Common iterator methods

Transformation:

// map: transform each element
vec![1, 2, 3].iter().map(|x| x * 2)  // [2, 4, 6]

// filter: keep elements matching condition
vec![1, 2, 3, 4].iter().filter(|&&x| x % 2 == 0)  // [2, 4]

// filter_map: transform and filter in one step
vec!["1", "two", "3"]
    .iter()
    .filter_map(|s| s.parse::<i32>().ok())  // [1, 3]

Aggregation:

// sum: add all elements
vec![1, 2, 3].iter().sum::<i32>()  // 6

// fold: custom aggregation
vec![1, 2, 3].iter().fold(0, |acc, x| acc + x)  // 6

// collect: gather results into a collection
(1..=5).collect::<Vec<i32>>()  // [1, 2, 3, 4, 5]

Iterator methods

Practical example: line lengths

Trim nonempty lines and record their lengths:

fn nonempty_line_lengths(text: &str) -> Vec<usize> {
    text.lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(str::len)
        .collect()
}

Try it yourself: Run in Rust Playground

Try it yourself: iterator chains

Challenge: Build an iterator pipeline

// Given this vector:
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// Use iterator methods to:
// 1. Keep values greater than 5
// 2. Double each value
// 3. Sum the results
// Expected: 80

let result: i32 = numbers
    .iter()
    .filter(|&&n| n > 5)
    .map(|&n| n * 2)
    .sum();

Try on Rust Playground

Closures: anonymous functions

Closures are functions that can capture their environment:

// Regular function
fn add_one(x: i32) -> i32 {
    x + 1
}

// Closure (short form)
let add_one = |x: i32| x + 1;

// Closure with type inference (Rust figures it out!)
let add_one = |x| x + 1;

// Use it:
let result = add_one(5);  // 6

Try it yourself: Run in Rust Playground

Closures can capture variables:

let threshold = 10;

// Closure captures 'threshold' from environment
let is_large = |x: i32| x > threshold;

println!("{}", is_large(5));   // false
println!("{}", is_large(15));  // true

Try it yourself: Run in Rust Playground

Closures in Rust Book

Closure traits: Fn, FnMut, and FnOnce

Rust has three closure traits based on how they capture variables:

Fn - Borrows immutably:

let x = 10;
let print_x = || println!("{}", x);  // Borrows x
print_x();  // Can call multiple times

Try it yourself: Run in Rust Playground

FnMut - Borrows mutably:

let mut count = 0;
let mut increment = || { count += 1; };  // Mutably borrows count
increment();  // count = 1
increment();  // count = 2

Try it yourself: Run in Rust Playground

FnOnce - Takes ownership:

let data = vec![1, 2, 3];
let consume = || drop(data);  // Takes ownership of data
consume();  // data is moved, can't call again

Try it yourself: Run in Rust Playground

Fn traits

Practical example: captured state

A closure can update a value from its environment:

let mut total = 0;
let mut add_to_total = |amount| {
    total += amount;
    total
};

println!("{}", add_to_total(4));  // 4
println!("{}", add_to_total(7));  // 11

Try it yourself: Run in Rust Playground

Key concepts:

  • FnMut because the closure mutates captured total
  • The mutable borrow lasts while the closure may still be used

Iterator and closure pattern

Processing collections with closures:

#[derive(Debug)]
struct Student {
    name: String,
    grade: f64,
}

let students = vec![Student { name: "Alice".to_string(), grade: 92.0 },
    Student { name: "Bob".to_string(), grade: 78.0 },
    Student { name: "Charlie".to_string(), grade: 85.0 },
];

// Find honor roll students (grade >= 80)
let honor_roll: Vec<&str> = students
    .iter()
    .filter(|student| student.grade >= 80.0)
    .map(|student| student.name.as_str())
    .collect();

// honor_roll = ["Alice", "Charlie"]

Try it yourself: Run in Rust Playground

Transfer: The same filter-map-collect pipeline appears in many Rust APIs.

End of session 1

What we covered:

  • Iterator methods (.map(), .filter(), .sum(), .collect())
  • Building data-processing pipelines
  • Closures and capture semantics
  • Closure traits (Fn, FnMut, FnOnce)
  • Combining iterators and closures

Next session: Smart pointers and error handling

Session 2: smart pointers

The problem: ownership limitations

Rust’s ownership rules are strict:

// ❌ Can't do this - multiple ownership
let data = vec![1, 2, 3];
let owner1 = data;
let owner2 = data;  // ERROR: value moved!
// ❌ Can't do this - recursive types have unknown size
struct Node {
    value: i32,
    next: Node,  // ERROR: infinite size!
}
// ❌ Can't do this - mutation through shared reference
let x = 5;
let y = &x;
*y = 10;  // ERROR: cannot assign through &T

Solution: Smart pointers!

Smart pointer overview

Three essential smart pointers:

Type Purpose Use When
Box<T> Heap allocation Recursive types, large data
Rc<T> Reference counting Multiple owners (single-threaded)
RefCell<T> Interior mutability Runtime borrowing checks

Key insight: Smart pointers provide additional capabilities while maintaining Rust’s safety guarantees.

Common combinations:

  • Box<T> alone: Recursive data structures
  • Rc<T> alone: Shared read-only data
  • Rc<RefCell<T>>: Shared mutable data (single-threaded)

Smart Pointers in Rust Book

Box<T>: heap allocation

Box<T> stores data on the heap with known size:

// Simple heap allocation
let boxed_int = Box::new(5);
println!("{}", *boxed_int);  // 5 (dereference with *)

// Main use case: recursive types
#[derive(Debug)]
enum BinaryTree<T> {
    Empty,
    Node {
        value: T,
        left: Box<BinaryTree<T>>,   // Box enables recursion!
        right: Box<BinaryTree<T>>,
    },
}

// Create a tree
let tree = BinaryTree::Node {
    value: 5,
    left: Box::new(BinaryTree::Empty),
    right: Box::new(BinaryTree::Empty),
};

Try it yourself: Run in Rust Playground

Why it works: Box<T> has fixed size (just a pointer), even though T might be recursive.

Box documentation

Practical example: binary tree

Building a binary tree with Box:

impl<T> BinaryTree<T> {
    /// Creates a new empty tree
    fn new() -> Self {
        BinaryTree::Empty
    }

    /// Creates a leaf node (no children)
    fn leaf(value: T) -> Self {
        BinaryTree::Node {
            value,
            left: Box::new(BinaryTree::Empty),
            right: Box::new(BinaryTree::Empty),
        }
    }

    /// Creates a node with children
    fn node(value: T, left: BinaryTree<T>, right: BinaryTree<T>) -> Self {
        BinaryTree::Node {
            value,
            left: Box::new(left),
            right: Box::new(right),
        }
    }
}

// Usage:
let tree = BinaryTree::node(
    10,
    BinaryTree::leaf(5),
    BinaryTree::leaf(15),
);

Rc<T>: reference counting

Rc<T> enables multiple owners of the same data:

use std::rc::Rc;

#[derive(Debug)]
struct SharedData {
    value: i32,
}

let data = Rc::new(SharedData { value: 42 });
println!("Count: {}", Rc::strong_count(&data));  // 1

// Create additional owners by cloning the Rc
let owner1 = Rc::clone(&data);  // Increments count
let owner2 = Rc::clone(&data);  // Increments count

println!("Count: {}", Rc::strong_count(&data));  // 3
println!("Value: {}", owner1.value);  // 42

// When all Rc's drop, data is freed
drop(owner1);
println!("Count: {}", Rc::strong_count(&data));  // 2

Try it yourself: Run in Rust Playground

Important: Rc::clone is cheap - it only increments a counter, doesn’t copy data!

Rc documentation

RefCell<T>: interior mutability

RefCell<T> allows mutation through shared references (runtime borrow checking):

use std::cell::RefCell;

let data = RefCell::new(5);

// Borrow immutably
{
    let value = data.borrow();
    println!("{}", *value);  // 5
} // borrow ends here

// Borrow mutably
{
    let mut value = data.borrow_mut();
    *value += 10;
} // borrow ends here

println!("{}", *data.borrow());  // 15

Try it yourself: Run in Rust Playground

Key difference: Borrows checked at runtime instead of compile-time.

Panic if rules violated:

let value1 = data.borrow_mut();
let value2 = data.borrow();  // ❌ PANIC: already borrowed mutably!

RefCell documentation

Combining Rc and RefCell

Pattern: Shared mutable data (single-threaded)

use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
struct Counter {
    value: i32,
}

let counter = Rc::new(RefCell::new(Counter { value: 0 }));

// Create multiple owners
let counter_ref1 = Rc::clone(&counter);
let counter_ref2 = Rc::clone(&counter);

// All can mutate through RefCell
counter_ref1.borrow_mut().value += 1;
counter_ref2.borrow_mut().value += 1;

println!("{}", counter.borrow().value);  // 2

Try it yourself: Run in Rust Playground

Pattern breakdown:

  • Rc<T>: Multiple owners
  • RefCell<T>: Interior mutability
  • Rc<RefCell<T>>: Multiple owners can all mutate

When to use each smart pointer

Decision tree:

Do you need multiple owners?
├─ NO → Use Box<T>
│   └─ For: Recursive types, large data on heap
│
└─ YES → Do you need mutation?
    ├─ NO → Use Rc<T>
    │   └─ For: Shared read-only data
    │
    └─ YES → Use Rc<RefCell<T>>
        └─ For: Shared mutable data (single-threaded)

Multi-threaded variants (for future study):

  • Arc<T>: Thread-safe Rc<T> (Atomic Reference Counting)
  • Mutex<T>: Thread-safe RefCell<T> (Mutual Exclusion)
  • Arc<Mutex<T>>: Thread-safe shared mutable data

Idiomatic error handling

Rust uses Result<T, E> for recoverable errors rather than catchable exceptions.

What this provides:

  • Errors are explicit in function signatures
  • Code must inspect or transform the result to access the success value
  • Expected failure categories are visible to callers
  • Result is an enum whose representation and runtime cost depend on its variants and use

Two approaches:

  1. Recoverable errors: Use Result<T, E> (file not found, parse error)
  2. Unrecoverable errors: Use panic! (programming bugs, invariant violations)

Result<T, E> basics

Result is an enum with two variants:

enum Result<T, E> {
    Ok(T),   // Success - contains value
    Err(E),  // Failure - contains error
}

// Example: parsing a network port
fn parse_port(input: &str) -> Result<u16, String> {
    input
        .trim()
        .parse::<u16>()
        .map_err(|_| format!("Invalid port: {input}"))
}

// Usage with match:
match parse_port("8080") {
    Ok(port) => println!("Port: {port}"),
    Err(e) => println!("Error: {}", e),
}

// Or with if let:
if let Ok(port) = parse_port("3000") {
    println!("Port: {port}");
}

Result documentation

The ? operator: error propagation

The ? operator makes error handling ergonomic:

use std::fs::File;
use std::io::{self, Read};

// Without ?
fn read_file_verbose(path: &str) -> io::Result<String> {
    let file_result = File::open(path);
    let mut file = match file_result {
        Ok(f) => f,
        Err(e) => return Err(e),  // Early return on error
    };

    let mut contents = String::new();
    match file.read_to_string(&mut contents) {
        Ok(_) => Ok(contents),
        Err(e) => Err(e),
    }
}

// With ? (much cleaner!)
fn read_file(path: &str) -> io::Result<String> {
    let mut file = File::open(path)?;  // ? propagates error
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;  // ? propagates error
    Ok(contents)
}

Try it yourself: Run in Rust Playground

? means: “If Ok, unwrap the value. If Err, return the error immediately.”

? operator

Custom error types

Create domain-specific errors:

use std::fmt;

#[derive(Debug, Clone)]
enum ParseError {
    EmptyInput,
    InvalidNumber(String),
    InvalidPercentage(i32),
}

// Implement Display for pretty error messages
impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ParseError::EmptyInput => write!(f, "Input string is empty"),
            ParseError::InvalidNumber(s) => write!(f, "Invalid number: {}", s),
            ParseError::InvalidPercentage(n) => {
                write!(f, "Percentage {n} must be between 0 and 100")
            }
        }
    }
}

// Use custom error type
fn parse_percentage(input: &str) -> Result<i32, ParseError> {
    if input.trim().is_empty() {
        return Err(ParseError::EmptyInput);
    }

    let num: i32 = input.trim().parse()
        .map_err(|_| ParseError::InvalidNumber(input.to_string()))?;

    if !(0..=100).contains(&num) {
        return Err(ParseError::InvalidPercentage(num));
    }

    Ok(num)
}

Try it yourself: Run in Rust Playground

Try it yourself: error handling

Challenge: Parse user input with good error messages

// Given this function signature:
fn parse_age(input: &str) -> Result<u8, String> {
    // TODO: Parse age (0-120), return helpful errors
}

// Test cases:
assert_eq!(parse_age("25"), Ok(25));
assert!(parse_age("").is_err());           // Empty
assert!(parse_age("abc").is_err());        // Not a number
assert!(parse_age("150").is_err());        // Out of range
assert!(parse_age("-5").is_err());         // Negative

Hint: Use ? operator and .map_err() for error conversion.

Try on Rust Playground

Integrative pattern: three concepts

Combining iterators, smart pointers, and error handling:

use std::rc::Rc;
use std::cell::RefCell;
use std::fmt;

#[derive(Debug, Clone)]
struct Config {
    min_length: usize,
    max_length: usize,
}

#[derive(Debug)]
enum ProcessError {
    LineTooShort(String),
    LineTooLong(String),
}

impl fmt::Display for ProcessError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ProcessError::LineTooShort(line) => write!(f, "Line too short: {}", line),
            ProcessError::LineTooLong(line) => write!(f, "Line too long: {}", line),
        }
    }
}

fn process_lines(
    lines: &[String],
    config: Rc<RefCell<Config>>,  // Smart pointer for shared config
) -> Result<Vec<String>, ProcessError> {  // Result for error handling
    lines
        .iter()  // Iterator
        .map(|line| {
            let cfg = config.borrow();
            let len = line.len();

            if len < cfg.min_length {
                Err(ProcessError::LineTooShort(line.clone()))
            } else if len > cfg.max_length {
                Err(ProcessError::LineTooLong(line.clone()))
            } else {
                Ok(line.to_uppercase())  // Transform on success
            }
        })
        .collect()  // Collect into Result<Vec<_>, _>
}

Try it yourself: Run in Rust Playground

Where these patterns appear

Where you’ll use these patterns:

Iterators:

  • Data processing pipelines (ETL, analytics)
  • Web server request filtering
  • Database query building
  • JSON/XML parsing

Smart pointers:

  • Graph databases (Rc for shared nodes)
  • Game engines (entity-component systems)
  • Compilers (AST with shared subtrees)
  • UI frameworks (shared state management)

Error handling:

  • Library APIs that expose recoverable failure
  • File I/O, network requests, parsing
  • Database operations
  • Command-line tools

Review prompt: For each pattern, name the ownership or error-handling constraint that motivates it.

Common pitfalls and practices

Iterators:

❌ Collecting unnecessarily: .collect::<Vec<_>>() then iterate again ✅ Chain operations: .map().filter().sum() in one go

❌ Using clone() in iterators when reference works ✅ Use references or Copy types

Smart Pointers:

❌ Using Rc<RefCell<T>> when &mut T works ✅ Start simple, add smart pointers only when needed

❌ Creating reference cycles with Rc (memory leak!) ✅ Use Weak<T> for back-references

Error Handling:

❌ Using .unwrap() everywhere (panics in production!) ✅ Use ? operator and handle errors properly

❌ String errors: Err("something failed".to_string()) ✅ Custom error enums with specific variants

Optional advanced topics

If you want to go deeper:

Custom Iterators:

  • Implement the Iterator trait
  • Create infinite sequences
  • Build adapters like filter and map

More Smart Pointers:

  • Weak<T>: Non-owning references (break cycles)
  • Cow<T>: Clone-on-write
  • Pin<T>: Prevent moves (async/await)

Error Libraries:

None of these are required for Lab 13, but great for projects!

Lab 13 preview

This week’s lab focuses on two parts:

Part 1: iterators and closures

  • Text analysis with iterator chains
  • Custom data processing pipelines
  • Stateful closures

Part 2: error handling with Result

  • Division with Result
  • Display messages for the supplied ParseError variants
  • Parsing a positive integer into Result<i32, ParseError>

Lab 13 workflow

cargo test
cargo fmt
cargo clippy -- -D warnings
git add week13/
git commit -m "Complete Lab 13"
git push origin main

Confirm the Week 13 README badge is green; that badge is the complete 10-point grade. See week13/lab13.md.

Week 13 summary

What makes Rust code “idiomatic”:

  • Iterators: Composable data processing
  • Closures: Functions that can capture their environment
  • Smart pointers: Optional tools for ownership patterns that references cannot express
  • Error handling: Explicit, recoverable errors

Key takeaways:

  • Iterator chains are lazy and often optimize well; collection and algorithm choices still matter
  • Smart pointers add capabilities and complexity; start with ordinary ownership and borrowing
  • Result<T, E> makes errors part of your API
  • These patterns appear throughout Rust’s standard library and ecosystem

Next week: Build a complete command-line application with Lab 14!

Resources and further reading

Official Documentation:

Practice:

Community:

Questions?

Office hours: See syllabus

AI assistance: Browser chat, GitHub Copilot, Copilot CLI, Antigravity CLI

Lab 13: Follow the due date shown in Canvas

Good luck and have fun with idiomatic Rust!