Understanding Closures in Rust

Ajmal
5 min readApr 7, 2023

--

In this article, we will explore closures in Rust, a powerful language feature that allows developers to write concise, flexible, and readable code. We’ll dive into the motivation behind closures, discuss their benefits and drawbacks, and demonstrate their usage through practical examples. Finally, we’ll briefly touch upon the equivalent constructs in other popular programming languages, such as Java and Python.

Closures

Closures are anonymous, inline functions that can capture and store their surrounding environment (i.e., the variables and values within their scope). They are particularly useful for creating short, ad-hoc functions that can be passed to higher-order functions or stored in data structures. Closures are similar to lambdas or anonymous functions in other programming languages. In Rust, closures are defined using the || syntax followed by an optional list of input parameters, a -> return type (if needed), and a code block within curly braces {}.

Let’s look at an example that demonstrates closures in Rust. Suppose you want to create a higher-order function that takes a closure as an argument and applies it to two numbers:

Example of closure

In this example, we define a higher-order function apply that takes a closure F and two i32 values as input. The closure is defined with the Fn trait bound, which means it can be any function or closure that takes two i32 values and returns an i32.

In the main function, we define two closures, add and mul, that perform addition and multiplication, respectively. We then pass these closures to the apply function, which in turn applies the closure to the input numbers.

Motivation for Closures

Closures provide a convenient and concise way to define small, one-off functions that can be passed as arguments to higher-order functions (i.e., functions that take other functions as input) or stored in data structures. They are particularly useful when working with iterators, asynchronous programming, and any scenario where you need to pass around a small piece of behavior.

Benefits of Closures

Conciseness: Closures provide a concise syntax for defining short functions in-place. For instance, we use a closure |a, b| a.len().cmp(&b.len()) to express the sorting logic concisely, directly within the sort_by method call in the code example below.

fn main() {
let mut words = vec!["banana", "apple", "orange", "grape"];

words.sort_by(|a, b| a.len().cmp(&b.len()));

println!("Sorted words: {:?}", words); // Output: Sorted words: ["apple", "grape", "banana", "orange"]
}

Flexibility: Closures can capture variables from their environment, making them more adaptable to different use cases. For instance, the closure |&x| x > threshold captures the threshold variable, allowing the filtering logic to adapt to different threshold values.

fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let threshold = 3;

let filtered_numbers: Vec<_> = numbers.into_iter().filter(|&x| x > threshold).collect();

println!("Filtered numbers: {:?}", filtered_numbers); // Output: Filtered numbers: [4, 5]
}

Encapsulation: By capturing variables, closures can maintain state without requiring additional data structures. For example, the inner closure move || { count += 1; count } in the code example below captures the count variable by value (using the move keyword), encapsulating the state within the closure itself.

fn main() {
let mut counter = || {
let mut count = 0;
move || {
count += 1;
count
}
}();

println!("Counter: {}", counter()); // Output: Counter: 1
println!("Counter: {}", counter()); // Output: Counter: 2
}

Compatibility with higher-order functions: Closures can be passed as arguments to other functions or stored in data structures, enabling powerful abstractions and patterns. For instance, the apply_to_each function (in the code example below) takes a closure as an argument and applies it to each element in the items slice. The closure |x| *x *= 2 doubles each element in the numbers vector.

fn apply_to_each<T, F>(items: &mut [T], f: F)
where
F: Fn(&mut T),
{
for item in items {
f(item);
}
}

fn main() {
let mut numbers = vec![1, 2, 3, 4, 5];

apply_to_each(&mut numbers, |x| *x *= 2);

println!("Doubled numbers: {:?}", numbers); // Output: Doubled numbers: [2, 4, 6, 8, 10]
}

Drawbacks of Closures

Limited reusability: Closures are generally limited to the specific part of the code where they’re defined, so they can’t be reused elsewhere.

Performance: Closures can sometimes introduce runtime overhead due to capturing variables and dynamic dispatch. For instance, in the below example, the closure captures large_data by value, moving the entire vector into the closure's environment. This may incur a performance overhead, especially if the vector is large, as it requires copying the data when the closure is created. In cases where performance is critical, it might be better to use a function with an explicit argument instead of capturing the variable.

fn main() {
let large_data = vec![0; 1_000_000];
let closure = move || {
let _sum = large_data.iter().sum::<i32>();
};

// Use the closure
closure();
}

Lifetime complexity: When capturing references, closures can introduce lifetime complexities, making it harder to reason about the code. In below example, we are trying to return a closure that captures a reference to a string. However, the code won’t compile because the text variable goes out of scope, and the closure's captured reference becomes invalid. This introduces lifetime complexities, making it harder to reason about the code and potentially leading to errors.

fn create_closure<'a>(input: &'a str) -> impl Fn() -> &'a str {
move || input
}

fn main() {
let closure;
let output;

{
let text = String::from("Hello, world!");
closure = create_closure(&text);
output = closure();
}

println!("Output: {}", output);
}

Closures in Other Programming Languages

Closures are equivalent to the following constructs in other programming languages:

Java: In Java, closures are implemented using lambda expressions, which were introduced in Java 8. Lambda expressions have a similar purpose as closures in Rust, allowing you to create anonymous functions that can capture variables from the surrounding scope. The syntax for a lambda expression in Java is (parameters) -> expression_or_block.

Python: In Python, closures are created using lambda functions. Lambda functions are small, anonymous functions that can be defined using the lambda keyword followed by a list of parameters, a colon, and an expression. Like closures in Rust, lambda functions in Python can capture variables from the surrounding scope.

Summary

Closures in Rust are anonymous functions that can capture values from their environment. They provide an expressive, flexible way to define short functions for use with higher-order functions or data structures.

--

--

Ajmal

Ph.D. in Telecommunication Networks, a Researcher & Engineer with architecture and protocol design experience in NR and Web3 space..