skip to content
Mehdi Mehdikhani
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 50

Unlike 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:

  1. 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 transformations
fn 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,
}
}
  1. 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 environment
fn 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);
}
}
});
}
  1. 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 behavior
let 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));
  1. 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>() }
}
}
// Usage
let expensive_computation = LazyValue::new(|| {
println!("Computing expensive value...");
std::thread::sleep(Duration::from_millis(100));
42
});
println!("Value: {}", expensive_computation.get()); // Computes here
println!("Value: {}", expensive_computation.get()); // Uses cached value

Closure 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
}
}
}
// Usage
fn 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()); // 11
println!("{}", counter()); // 12

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