Week 13: Idiomatic Rust
Session 1: iterators and closures
.map(), .filter(), .fold(), .collect())Fn, FnMut, FnOnceSession 2: smart pointers and error handling
Box<T>, Rc<T>, RefCell<T>Result<T, E>? operator and custom error typesWhat the patterns provide:
Project examples:
Traditional loop-based approach:
Try it yourself: Run in Rust Playground
Problems:
result)Functional approach with iterator chains:
Try it yourself: Run in Rust Playground
Benefits:
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:
Trim nonempty lines and record their lengths:
Try it yourself: Run in Rust Playground
Challenge: Build an iterator pipeline
Try on Rust Playground
Closures are functions that can capture their environment:
Try it yourself: Run in Rust Playground
Closures can capture variables:
Try it yourself: Run in Rust Playground
Fn, FnMut, and FnOnceRust has three closure traits based on how they capture variables:
Fn - Borrows immutably:
Try it yourself: Run in Rust Playground
FnMut - Borrows mutably:
Try it yourself: Run in Rust Playground
FnOnce - Takes ownership:
Try it yourself: Run in Rust Playground
A closure can update a value from its environment:
Try it yourself: Run in Rust Playground
Key concepts:
FnMut because the closure mutates captured totalProcessing 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.
What we covered:
.map(), .filter(), .sum(), .collect())Fn, FnMut, FnOnce)Next session: Smart pointers and error handling
Rust’s ownership rules are strict:
Solution: Smart pointers!
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 structuresRc<T> alone: Shared read-only dataRc<RefCell<T>>: Shared mutable data (single-threaded)Box<T>: heap allocationBox<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.
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 countingRc<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)); // 2Try it yourself: Run in Rust Playground
Important: Rc::clone is cheap - it only increments a counter, doesn’t copy data!
RefCell<T>: interior mutabilityRefCell<T> allows mutation through shared references (runtime borrow checking):
Try it yourself: Run in Rust Playground
Key difference: Borrows checked at runtime instead of compile-time.
Panic if rules violated:
Rc and RefCellPattern: 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); // 2Try it yourself: Run in Rust Playground
Pattern breakdown:
Rc<T>: Multiple ownersRefCell<T>: Interior mutabilityRc<RefCell<T>>: Multiple owners can all mutateDecision 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 dataRust uses Result<T, E> for recoverable errors rather than catchable exceptions.
What this provides:
Result is an enum whose representation and runtime cost depend on its variants and useTwo approaches:
Result<T, E> (file not found, parse error)panic! (programming bugs, invariant violations)Result<T, E> basicsResult 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}");
}? operator: error propagationThe ? 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.”
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
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()); // NegativeHint: Use ? operator and .map_err() for error conversion.
Try on Rust Playground
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 you’ll use these patterns:
Iterators:
Smart pointers:
Error handling:
Review prompt: For each pattern, name the ownership or error-handling constraint that motivates it.
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
If you want to go deeper:
Custom Iterators:
Iterator traitfilter and mapMore Smart Pointers:
Weak<T>: Non-owning references (break cycles)Cow<T>: Clone-on-writePin<T>: Prevent moves (async/await)Error Libraries:
None of these are required for Lab 13, but great for projects!
This week’s lab focuses on two parts:
Part 1: iterators and closures
Part 2: error handling with Result
ResultDisplay messages for the supplied ParseError variantsResult<i32, ParseError>Confirm the Week 13 README badge is green; that badge is the complete 10-point grade. See week13/lab13.md.
What makes Rust code “idiomatic”:
Key takeaways:
Result<T, E> makes errors part of your APINext week: Build a complete command-line application with Lab 14!
Official Documentation:
Practice:
Community:
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!
IS 4010: App Dev with AI