The Rust Lang Book: Closures in Rust
Closures in Rust are anonymous functions that can be saved in variables, passed as arguments to other functions, and defined in one place while being evaluated in a different context. Unlike standard functions, closures have the unique ability to capture and access variables from the parent scope in which they are defined.
1. The Problem: Expensive Calculations in Application Workflows
To understand why closures are valuable, consider a practical backend scenario for a fitness application. The application generates customized workout plans based on user metrics (e.g., age, BMI, workout preferences, and intensity level).
A key part of this algorithm involves running an expensive calculation that takes several seconds to execute. We can simulate this delay using std::thread::sleep.
The Simulated Expensive Calculation
use std::thread;
use std::time::Duration;
fn simulated_expensive_calculation(intensity: u32) -> u32 {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
intensity
}
The Naive Workout Generation Logic
Below is the initial implementation of the workout generator. It accepts an intensity level and a random number (used to introduce variety into the workout plan):
fn generate_workout(intensity: u32, random_number: u32) {
if intensity < 25 {
// Problem: The expensive calculation is called twice!
println!("Do {} push-ups!", simulated_expensive_calculation(intensity));
println!("Do {} sit-ups!", simulated_expensive_calculation(intensity));
} else {
if random_number == 3 {
// If the random number is 3, we don't need the calculation at all
println!("Take a break today! Remember to stay hydrated!");
} else {
// Problem: The expensive calculation is called here as well
println!("Run for {} minutes!", simulated_expensive_calculation(intensity));
}
}
}
fn main() {
let simulated_user_specified_intensity = 10;
let simulated_random_number = 7;
generate_workout(
simulated_user_specified_intensity,
simulated_random_number,
);
}
Architectural Flaws in the Naive Approach
- Redundant Execution: Inside the first
ifblock (intensity < 25), the program callssimulated_expensive_calculationtwice. This incurs an unnecessary 4-second delay. - Maintenance Overhead: If the signature of
simulated_expensive_calculationchanges (e.g., adding a new parameter), we must update every single call site across the codebase.
2. Naive Refactoring vs. The Lazy Evaluation Problem
To resolve the redundant execution issue, a developer might attempt to refactor the code by executing the calculation once at the beginning of the function and storing the result in a variable.
The Eager Evaluation Refactoring Attempt
fn generate_workout_refactored(intensity: u32, random_number: u32) {
// Eager evaluation: We run the calculation upfront
let expensive_result = simulated_expensive_calculation(intensity);
if intensity < 25 {
println!("Do {expensive_result} push-ups!");
println!("Do {expensive_result} sit-ups!");
} else {
if random_number == 3 {
// Problem: We ran the expensive calculation, but we don't use it here!
println!("Take a break today! Remember to stay hydrated!");
} else {
println!("Run for {expensive_result} minutes!");
}
}
}
Why This Refactoring Fails
While this solves the duplicate call issue inside the first if block, it introduces a new performance bottleneck: eager evaluation.
If intensity is 30 and random_number is 3, the program executes the 2-second calculation at the very top of the function, only to discard the result and print "Take a break today!".
Ideally, we want to define the calculation in one place but defer its execution until it is absolutely necessary (lazy evaluation).
3. The Mental Model: What is a Closure?
Closures provide a way to package a block of code so that it can be passed around and executed later.
Real-Life Analogy
Think of a standard function as a vending machine that is permanently bolted to a wall: it has a fixed location, a clear interface, and anyone can walk up to use it.
A closure is like a packaged meal kit: you prepare the ingredients (capture the environment) and pack them into a box (the closure variable). You can carry this box around, pass it to a friend, and choose to cook and consume it (execute the closure) only when you are actually hungry.
Visualizing the Execution Flow
Closure Syntax vs. Function Syntax
The visual difference between a function and a closure lies in how parameters and bodies are defined:
- Functions use parentheses
()for parameters and require explicit curly braces{}. - Closures use vertical pipes
||for parameters. If the closure body consists of a single expression, the curly braces{}are optional.
// Standard Function
fn add_one_v1(x: u32) -> u32 {
x + 1
}
// Fully annotated Closure
let add_one_v2 = |x: u32| -> u32 { x + 1 };
// Closure with inferred types and omitted braces (single expression)
let add_one_v3 = |x| x + 1;
4. Refactoring with Closures
By replacing the upfront function call with a closure, we can define the calculation once and invoke it lazily only within the specific execution paths that require it.
fn generate_workout_with_closure(intensity: u32, random_number: u32) {
// Defining the closure. Note: The calculation is NOT executed here.
let expensive_closure = |num| {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
};
if intensity < 25 {
// We execute the closure only when calling it with parentheses
println!("Do {} push-ups!", expensive_closure(intensity));
println!("Do {} sit-ups!", expensive_closure(intensity));
} else {
if random_number == 3 {
// The closure is never executed in this branch!
println!("Take a break today! Remember to stay hydrated!");
} else {
println!("Run for {} minutes!", expensive_closure(intensity));
}
}
}
The Remaining Bottleneck
Although we successfully deferred execution, we are back to our original problem inside the first if block: we call expensive_closure(intensity) twice, resulting in a 4-second delay when intensity < 25.
To solve this without reverting to eager evaluation, we need a mechanism to cache the result of the closure the first time it is called.
5. Type Inference and Concrete Type Locking
Unlike functions, closures do not require explicit type annotations for their parameters or return values.
Why Functions Require Type Annotations
Functions are part of an explicit, public interface exposed to users of a module or library. Agreeing on concrete types at the boundary is critical for API stability and compiler verification.
Why Closures Do Not Require Type Annotations
Closures are typically short, localized, and relevant only within a narrow context. The compiler can easily infer the types of the parameters and the return value by observing how the closure is called and used, similar to how it infers variable types.
Warning: Concrete Type Locking Although closures use type inference, they are not generic. Once the compiler infers a concrete type for a closure's parameters based on its first invocation, that closure is locked into those types. Subsequent calls with different types will trigger a compilation error.
Example: Type Locking Failure
fn main() {
// The compiler does not know the type of 'x' yet
let example_closure = |x| x;
// First call: The compiler infers that 'x' is of type String
let s = example_closure(String::from("hello"));
// Second call: Passing an integer causes a compilation error!
let n = example_closure(5);
}
Compiler Error Message:
6. The Memoization Pattern: Building a Custom Cacher
To solve the issue of calling our closure multiple times within a single execution path, we can implement the Memoization (Caching) Pattern. We will design a struct called Cacher that holds both the closure and an optional cached value.
Trait Bounds for Closures
To store a closure in a struct, we must use generics and trait bounds. The Rust standard library provides three traits that all closures implement:
FnFnMutFnOnce
For our Cacher struct, we will use the Fn trait bound, indicating that the closure can be called multiple times without mutating or consuming its captured environment.
Defining the Cacher Struct
struct Cacher<T>
where
T: Fn(u32) -> u32,
{
calculation: T,
value: Option<u32>,
}
Implementing the Cacher
impl<T> Cacher<T>
where
T: Fn(u32) -> u32,
{
// Constructor
fn new(calculation: T) -> Cacher<T> {
Cacher {
calculation,
value: None,
}
}
// Lazy evaluation and caching logic
fn value(&mut self, arg: u32) -> u32 {
match self.value {
Some(v) => v,
None => {
// Execute the closure and cache the result
let v = (self.calculation)(arg);
self.value = Some(v);
v
}
}
}
}
Using the Cacher in our Workout Generator
fn generate_workout_optimized(intensity: u32, random_number: u32) {
// Wrap the closure inside our Cacher struct
let mut cached_result = Cacher::new(|num| {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
});
if intensity < 25 {
// The first call executes the calculation
println!("Do {} push-ups!", cached_result.value(intensity));
// The second call returns the cached value instantly!
println!("Do {} sit-ups!", cached_result.value(intensity));
} else {
if random_number == 3 {
println!("Take a break today! Remember to stay hydrated!");
} else {
println!("Run for {} minutes!", cached_result.value(intensity));
}
}
}
7. Limitations of the Simple Cacher
While our Cacher implementation works perfectly for the workout generator, it has two major design limitations that prevent it from being a reusable utility in a production library.
Problem 1: The Single-Value Cache Bug
The current implementation caches exactly one value. If we call the .value() method with different arguments, it will always return the result of the first argument evaluated, ignoring subsequent inputs.
let mut c = Cacher::new(|a| a);
let v1 = c.value(1); // Returns 1, caches Some(1)
let v2 = c.value(2); // Sees Some(1), returns 1! (Expected 2)
The Solution: HashMap-Based Caching
To support multiple distinct arguments, we can replace the single Option<u32> with a HashMap. The keys of the map represent the input arguments, and the values represent the evaluated results.
use std::collections::HashMap;
use std::hash::Hash;
struct MultiCacher<T, K, V>
where
T: Fn(K) -> V,
K: Eq + Hash + Clone,
V: Clone,
{
calculation: T,
cache: HashMap<K, V>,
}
impl<T, K, V> MultiCacher<T, K, V>
where
T: Fn(K) -> V,
K: Eq + Hash + Clone,
V: Clone,
{
fn new(calculation: T) -> Self {
MultiCacher {
calculation,
cache: HashMap::new(),
}
}
fn value(&mut self, arg: K) -> V {
if let Some(cached_val) = self.cache.get(&arg) {
cached_val.clone()
} else {
let result = (self.calculation)(arg.clone());
self.cache.insert(arg, result.clone());
result
}
}
}
Problem 2: Hardcoded Types
Our initial Cacher was hardcoded to accept and return u32 values. By introducing generic parameters (like K and V in the MultiCacher example above), we can make the cacher work with any input and output types.
8. Capturing the Environment: Closures vs. Functions
The defining feature of closures is their ability to capture variables from their enclosing environment. Standard functions are strictly prohibited from doing this.
Example: Capturing a Local Variable
fn main() {
let x = 4;
// The closure captures 'x' from the local scope
let equal_to_x = |z| z == x;
let y = 4;
assert!(equal_to_x(y)); // Passes successfully
}
Why Functions Cannot Capture Environment
If we attempt to rewrite the closure above as a standard function, the compiler will reject it:
fn main() {
let x = 4;
// Compilation Error!
fn equal_to_x(z: i32) -> bool {
z == x
}
let y = 4;
assert!(equal_to_x(y));
}
Compiler Error Message:
The Performance Trade-off
- Functions: Do not capture their environment. They incur zero runtime memory overhead for context tracking.
- Closures: Must allocate memory to store the captured variables. This overhead varies depending on how the variables are captured.
9. The Three Fn Traits & The move Keyword
Closures capture values from their environment in three distinct ways, which map directly to how functions accept parameters:
- Borrowing Immutably (
&T) - Borrowing Mutably (
&mut T) - Taking Ownership (
T)
These behaviors are encoded into three standard library traits:
Rust automatically infers which of these traits to implement for a closure based on how the closure uses the captured variables in its body.
The move Keyword
You can force a closure to take ownership of its captured environment by using the move keyword before the parameter definition. This is highly useful when passing a closure to a new thread, ensuring that the thread owns the data it works with and preventing dangling reference errors.
Example: Forcing Ownership Transfer
fn main() {
let x = vec![1, 2, 3];
// The 'move' keyword forces the closure to take ownership of 'x'
let equal_to_x = move |z| z == x;
// Compilation Error! 'x' has been moved into the closure
println!("Can we use x here? {:?}", x);
let y = vec![1, 2, 3];
assert!(equal_to_x(y));
}
Compiler Error Message:
Summary and Key Takeaways
- Anonymous & Portable: Closures are anonymous functions that can be stored in variables, passed as arguments, and returned from functions.
- Lazy Evaluation: Closures allow you to define a block of code in one place but defer its execution until the exact moment its result is needed.
- Type Inference: Closures do not require explicit type annotations for parameters or return values, though they can be added for clarity.
- Type Locking: Once the compiler infers a concrete type for a closure's parameters during its first call, that closure cannot be called with any other types.
- The Memoization Pattern: You can use a wrapper struct (like a
Cacher) withFntrait bounds to store a closure and cache its output, preventing redundant, expensive executions. - Environment Capturing: Unlike functions, closures can capture variables from their defining scope, incurring a small runtime memory overhead to track this context.
- The Three Traits: Closures implement
Fn(immutable borrow),FnMut(mutable borrow), orFnOnce(ownership transfer) depending on how they interact with captured variables. - The
moveKeyword: Prependingmoveto a closure forces it to take full ownership of its captured environment, which is essential for safe multi-threaded programming.