Closures and Functional Programming in Rust
/ 7 min read
Table of Contents
Rust closures are way more powerfull than I initially thought. Coming from languages where closures have runtime overhead, I was surprised to learn that Rust closures can be zero-cost abstractions.
What Closures Actually Are
Closures are anonymous functions that can capture variables from their surrounding scope:
let multiplier = 10;let closure = |x| x * multiplier; // Captures multiplier from environment
println!("{}", closure(5)); // Prints 50Unlike regular functions, closures can “close over” variables from their environment, hence the name.
Three Types of Capture
Rust closures can capture variables in three ways:
fn demonstrate_captures() { let mut count = 0; let message = String::from("Hello"); let flag = true;
// FnOnce - takes ownership, can only be called once let consume_message = move || { println!("{}", message); // message is moved into closure count += 1; // count is also moved };
// Fn - borrows immutably, can be called multiple times let read_flag = || { println!("Flag is: {}", flag); // Borrows flag immutably };
// FnMut - borrows mutably, can be called multiple times but needs &mut let mut increment = || { count += 1; // Borrows count mutably };
read_flag(); // Can call multiple times read_flag();
increment(); // Need mutable access to call increment();
consume_message(); // Can only call once // consume_message(); // Error! Already consumed}When I Actually Use Closures
Most of the time, I use closures for:
- Iterator transformations: The bread and butter of functional programming:
fn process_user_data(users: Vec<User>) -> Vec<String> { users .into_iter() .filter(|user| user.is_active) // Keep only active users .filter(|user| user.age >= 18) // Adults only .map(|user| format!("{} <{}>", user.name, user.email)) // Format display .collect()}
// More complex transformationsfn analyze_sales_data(sales: &[Sale]) -> SalesReport { let total_revenue: f64 = sales .iter() .map(|sale| sale.amount) .sum();
let high_value_sales: Vec<&Sale> = sales .iter() .filter(|&&sale| sale.amount > 1000.0) .collect();
let sales_by_region: HashMap<String, f64> = sales .iter() .fold(HashMap::new(), |mut acc, sale| { *acc.entry(sale.region.clone()).or_insert(0.0) += sale.amount; acc });
SalesReport { total_revenue, high_value_count: high_value_sales.len(), regional_breakdown: sales_by_region, }}- Event handling and callbacks: Capturing context for later execution:
struct EventSystem { handlers: Vec<Box<dyn Fn(&Event)>>,}
impl EventSystem { fn new() -> Self { Self { handlers: Vec::new() } }
fn on_event<F>(&mut self, handler: F) where F: Fn(&Event) + 'static { self.handlers.push(Box::new(handler)); }
fn emit(&self, event: Event) { for handler in &self.handlers { handler(&event); } }}
// Usage with closures that capture environmentfn setup_event_system() { let mut system = EventSystem::new(); let mut error_count = 0; let log_file = "app.log";
// Closure captures error_count and log_file system.on_event(move |event| { match event.kind { EventKind::Error => { error_count += 1; // Modifies captured variable eprintln!("Error #{}: {}", error_count, event.message); // Would also write to log_file in real implementation }, EventKind::Info => { println!("Info: {}", event.message); } } });}- Configuration and customization: Creating specialized behavior:
struct HttpClient { retry_policy: Box<dyn Fn(u32, &Error) -> bool>, timeout_strategy: Box<dyn Fn(u32) -> Duration>,}
impl HttpClient { fn with_retry_policy<F>(retry_policy: F) -> Self where F: Fn(u32, &Error) -> bool + 'static { Self { retry_policy: Box::new(retry_policy), timeout_strategy: Box::new(|attempt| Duration::from_secs(2_u64.pow(attempt))), } }
fn with_timeout_strategy<F>(mut self, timeout_strategy: F) -> Self where F: Fn(u32) -> Duration + 'static { self.timeout_strategy = Box::new(timeout_strategy); self }}
// Create clients with custom behaviorlet conservative_client = HttpClient::with_retry_policy(|attempt, error| { attempt < 2 && matches!(error, Error::Timeout | Error::NetworkError)});
let aggressive_client = HttpClient::with_retry_policy(|attempt, _| attempt < 5) .with_timeout_strategy(|attempt| Duration::from_millis(500 * attempt as u64));- Lazy computation and caching: Computing values only when needed:
use std::cell::RefCell;
struct LazyValue<T, F>where F: FnOnce() -> T,{ computation: RefCell<Option<F>>, cached_value: RefCell<Option<T>>,}
impl<T, F> LazyValue<T, F>where F: FnOnce() -> T,{ fn new(computation: F) -> Self { Self { computation: RefCell::new(Some(computation)), cached_value: RefCell::new(None), } }
fn get(&self) -> &T { if self.cached_value.borrow().is_none() { let computation = self.computation.borrow_mut().take().unwrap(); let value = computation(); *self.cached_value.borrow_mut() = Some(value); }
// This is unsafe in practice - would need better lifetime management unsafe { &*(self.cached_value.as_ptr() as *const Option<T>).cast::<T>() } }}
// Usagelet expensive_computation = LazyValue::new(|| { println!("Computing expensive value..."); std::thread::sleep(Duration::from_millis(100)); 42});
println!("Value: {}", expensive_computation.get()); // Computes hereprintln!("Value: {}", expensive_computation.get()); // Uses cached valueClosure Performance
One of the coolest things about Rust closures is that their zero-cost:
// This closure...let numbers = vec![1, 2, 3, 4, 5];let doubled: Vec<i32> = numbers.iter().map(|x| x * 2).collect();
// ...compiles to roughly the same code as this loop:let mut doubled = Vec::new();for x in &numbers { doubled.push(x * 2);}The compiler inlines the closure and optimizes it away completely.
Real World Example
Here’s a data processing pipeline I built using closures:
use std::collections::HashMap;
#[derive(Debug, Clone)]struct LogEntry { timestamp: String, level: String, module: String, message: String, response_time: Option<u64>,}
struct LogAnalyzer { filters: Vec<Box<dyn Fn(&LogEntry) -> bool>>, transformers: Vec<Box<dyn Fn(LogEntry) -> LogEntry>>,}
impl LogAnalyzer { fn new() -> Self { Self { filters: Vec::new(), transformers: Vec::new(), } }
fn filter_by_level(mut self, level: String) -> Self { self.filters.push(Box::new(move |entry| entry.level == level)); self }
fn filter_by_module(mut self, module: String) -> Self { self.filters.push(Box::new(move |entry| entry.module == module)); self }
fn filter_slow_requests(mut self, threshold_ms: u64) -> Self { self.filters.push(Box::new(move |entry| { entry.response_time.map_or(false, |time| time > threshold_ms) })); self }
fn transform_timestamps(mut self) -> Self { self.transformers.push(Box::new(|mut entry| { // Normalize timestamp format entry.timestamp = entry.timestamp.replace("T", " "); entry })); self }
fn add_severity_flag(mut self) -> Self { self.transformers.push(Box::new(|mut entry| { let is_critical = entry.level == "ERROR" && entry.message.to_lowercase().contains("critical"); if is_critical { entry.message = format!("[CRITICAL] {}", entry.message); } entry })); self }
fn analyze(&self, logs: Vec<LogEntry>) -> AnalysisResult { let filtered_logs: Vec<LogEntry> = logs .into_iter() .filter(|entry| self.filters.iter().all(|filter| filter(entry))) .map(|entry| self.transformers.iter().fold(entry, |acc, transformer| transformer(acc))) .collect();
let mut result = AnalysisResult::default();
// Analysis using closures result.total_entries = filtered_logs.len();
result.errors_by_module = filtered_logs .iter() .filter(|entry| entry.level == "ERROR") .fold(HashMap::new(), |mut acc, entry| { *acc.entry(entry.module.clone()).or_insert(0) += 1; acc });
result.average_response_time = filtered_logs .iter() .filter_map(|entry| entry.response_time) .fold((0u64, 0usize), |(sum, count), time| (sum + time, count + 1)) .into(); // Convert (sum, count) to average
result.critical_messages = filtered_logs .iter() .filter(|entry| entry.message.contains("[CRITICAL]")) .map(|entry| entry.message.clone()) .collect();
result }}
#[derive(Default)]struct AnalysisResult { total_entries: usize, errors_by_module: HashMap<String, usize>, average_response_time: Option<f64>, critical_messages: Vec<String>,}
impl From<(u64, usize)> for Option<f64> { fn from((sum, count): (u64, usize)) -> Self { if count > 0 { Some(sum as f64 / count as f64) } else { None } }}
// Usagefn analyze_application_logs(logs: Vec<LogEntry>) { let analyzer = LogAnalyzer::new() .filter_by_level("ERROR".to_string()) .filter_slow_requests(500) // > 500ms .transform_timestamps() .add_severity_flag();
let result = analyzer.analyze(logs);
println!("Found {} relevant entries", result.total_entries);
if let Some(avg_time) = result.average_response_time { println!("Average response time: {:.2}ms", avg_time); }
for (module, count) in &result.errors_by_module { println!("Module {} had {} errors", module, count); }
if !result.critical_messages.is_empty() { println!("Critical issues found:"); for msg in &result.critical_messages { println!(" - {}", msg); } }}Move Semantics with Closures
The move keyword forces closures to take ownership of captured variables:
fn create_counter(start: i32) -> impl Fn() -> i32 { let mut count = start;
move || { // move is necessary here count += 1; count }} // count would be dropped here without move
let counter = create_counter(10);println!("{}", counter()); // 11println!("{}", counter()); // 12Without move, the closure would try to borrow count after it’s been dropped.
The Pattern I Follow
I use closures when:
- Processing collections with iterator chains
- Setting up event handlers or callbacks
- Creating configurable behavior
- Implementing lazy evaluation
I avoid closures when:
- The logic is complex enough to warrant a named function
- I need the same logic in multiple places
- Performance is absolutely critical (though usually unnecessary)
Closures make Rust feel like a functional language while maintaining zero-cost abstractions. They’re one of my favorite features for writing expressive, efficient code.