The concept of functional programming whereby functions take an argument’s type or return a function type forms a broader discussion of closures in Rust that is important for devs to have an understanding of today.
Closures are anonymous function-like constructs that can be passed as arguments, stored in a variable, or returned as a value from a named function.
In this article, we’ll explore and learn about closures and their related concepts in Rust — for example, using a closure with iterators in Rust or how the move
keyword takes ownership of values captured from the closure’s environment. We’ll examine the key aspects of environment capturing in Rust with closures and demonstrate using examples how you can use closures to optimize your code.
Jump ahead:
In Rust, closures and functions are two different types of code blocks that serve different purposes.
Here are the main differences you need to know between closures and functions in Rust:
Rust’s syntax for defining closures and functions is slightly different.
Closures use the syntax |parameters| -> return_type { body }
, where the parameters are passed between vertical pipes (||
) and the body is enclosed between curly brackets ({}
).
On the other hand, functions use the syntax fn name(parameters: type) -> return_type { body }
, where the parameters are passed between parentheses (()
) and the return type is specified after the arrow (->
).
The body of a closure can be a single statement or multiple statements.
The curly braces are optional if the closure consists of a single statement; however, if the closure consists of multiple statements, the body must be enclosed between curly brackets.
In contrast, the body of a function must always be enclosed between curly brackets, regardless of whether it consists of a single statement or multiple statements.
In Rust, the return type of closure is optional — this means that you don’t have to specify the type of value that the closure returns.
The return type of a function is mandatory, however — you must specify the value type that the function returns using the syntax -> return_type
.
You don’t have to specify the data types of the parameters in a closure in Rust. You must, however, specify the data types of the parameters in a function using the type parameter syntax.
Closures in Rust can capture variables from their environment, while functions cannot. This means that closures can reference variables that are defined outside.
Let’s quickly look at the syntax definition for a closure to get started:
let closure = |...| {...}
In the above syntax, you can define the different parameters for the closure inside the |…|
section of the definition, with the body of the closure defined inside the {…}
section.
Take a look at this code block to see what I mean:
fn main() { let closure = |x, y| { x + y }; println!("{}", closure(1, 2)) // 3 }
Just like functions, closures are executed using the name and two parentheses.
The parameters are defined in between the pipe syntax, ||
. Furthermore, you’ll notice with the closure that the parameters and the return type are inferred.
The above code defines a closure named “closure” that takes two arguments, x
and y
, and returns their sum. The closure body consists of a single statement, x + y
, which is not enclosed between curly brackets because it is a single statement.
In Rust, the type of closure is inferred based on the types of its arguments and the return value. In this case, the closure takes two arguments of type i32
and returns a value of type i32
, so the closure’s type is inferred as |x: i32, y: i32| -> i32
.
Sometimes, you may want to specify the closure type explicitly using a closure type annotation; this is useful when the Rust compiler cannot infer the closure type or when you want to specify a more specific type for the closure.
To specify a closure type annotation, you can use the syntax |parameters: types| -> return_type
. For example:
fn main() { let closure: |x: i32, y: i32| -> i32 = |x, y| { x + y }; println!("{}", closure(1, 2)) // 3 }
In this case, the closure type is explicitly specified as |x: i32, y: i32| -> i32
, which matches the inferred closure type.
Overall, closure type inference and annotation allow you to specify the type of a closure in Rust, which can be useful for ensuring type safety and clean code.
As mentioned earlier, one of the advantages of closures over functions is that they can capture and enclose variables in the environment where they were defined.
Let’s learn about this in a little more detail.
Closures, as we mentioned, can capture values from the environment in which they were defined — closures can either borrow or take ownership of these surrounding values.
Let’s build a code scenario where we can perform some environment capturing in Rust:
use std::collections::HashMap; #[derive(Debug)] struct Nft { tokens: Option<HashMap<String, u32>> } fn main() { let x = Nft { tokens: Some(HashMap::from([(String::from("string"), 32)])) }; let slice = vec![1, 3, 5]; let print_to_stdout = || { println!("Slice: {:?}", slice); if let Some(tokens) = &x.tokens { println!("Nft supply --> {:?}", tokens); } }; print_to_stdout(); println!("{:?}", x); print_to_stdout(); }
Here is the output you should receive:
Slice: [1, 3, 5] Nft supply --> {"string": 32} Nft { tokens: Some({"string": 32}) } Slice: [1, 3, 5] Nft supply --> {"string": 32}
In the above snippet, we defined x
as an instance of the Nft
struct. We also defined a slice
variable — a type of Vec<i32>
. Then, we defined a closure stored in the print_to_stdout
variable.
Without passing the two variables (x
and slice
) as parameters into the closure, we can still have immutable access to them in the print_to_stdout
closure.
The print_to_stdout
closure captured an immutable reference to the x
and slice
variables because they were defined in the same scope/environment as itself.
Additionally, because the print_to_stdout
closure has only an immutable reference to the variable — meaning it can’t alter the state of the variables — we can call the closure multiple times to print the values.
We can also redefine our closure to take a mutable reference to the variable by slightly adjusting the code snippet, as demonstrated here:
// --snip-- fn main() { // --snip-- let mut slice = vec![1, 3, 5]; let print_to_stdout = || { slice.push(11); // --snip-- println!("Slice: {:?}", slice); }; print_to_stdout(); println!("{:?}", slice); }
Here is the output:
Slice: [1, 3, 5, 11] [1, 3, 5, 11]
We can modify its state by capturing a mutable reference to the slice
variable.
Right after executing the print_to_stdout
closure, the borrowed reference is returned, making it possible for us to print the slice
value to stdout
.
In scenarios where we want to take ownership of the surrounding variable, we can use the move
keyword alongside the closure.
When we take ownership of a variable in a closure, we often intend to mutate the state of the variable.
Using our previous example, let’s take a look at how this works:
// --snip-- fn main() { // --snip-- //Redefined the closure using move keyword let print_to_stdout = move || { slice.push(11); // --snip-- println!("Slice: {:?}", slice); }; print_to_stdout(); }
Now, we’ve explicitly moved the variables into the closure, taking ownership of their values.
If you try calling println!("{:?}", slice);
like the previous code block, you’ll get an error explaining that the variable was moved due to its use in the closure (shown below).
Earlier, we explained how a closure can be passed as an argument to a function, or even returned as a value from a function.
Let’s explore how these behaviors can be achieved using the functions’ different definitions and trait bounds.
First, let’s look at the three Fn
traits, as a closure will automatically implement one, two, or all three due to the nature of the function signature definition or body content. All closures at the very least implement the FnOnce
trait.
Here’s an explanation of the three traits:
FnOnce
: Any closure that returns captured variables to its calling environment implements this traitFnMut
: This trait represents closures that can potentially mutate captured values and don’t move the captured values out of the closure’s body as returned valuesFn
: This trait neither mutates, returns captured values, nor captures variables from its defining scopeThese rules serve as our guiding lights when defining closures for different uses in your project.
Let’s demonstrate a sample use case by implementing one of the traits mentioned above:
#[derive(Debug)] enum State<T> { Received(T), Pending, } impl<T> State<T> { pub fn resolved<F>(self, f: F) -> T where F: FnOnce() -> T { match self { State::Received(v) => v, State::Pending => f(), } } } fn main() { let received_state = State::Received(String::from("LogRocket")); println!("{:?}", received_state.resolved(|| String::from("executed closure"))); let pending_state = State::Pending; println!("{:?}", pending_state.resolved(|| String::from("executed closure"))) }
Here is our output:
"LogRocket" "executed closure"
In the above snippet, we created a sample State
enum to hypothetically represent a network call with a fulfilled state of Received(T)
and a Pending
state. On the enum, we implemented a function to check the network call’s state and act accordingly.
Looking at the function signature, you will notice that the f
parameter of the function is a generic parameter: a FnOnce
closure.
Using trait bounding (F: FnOnce() -> T
), we defined the possible parametric values for f,
which means F
must be called only once at maximum, take no arguments of its own, and return a generic value of T
.
If we have a fulfilled state — the Received(T)
variant — we return the value contained in the fulfilled state, just as we did with the received_state
variable.
When the state happens to be Pending
, the closure argument would be called instead, just like pending_state
.
In this section, you’ll learn one of the most common use cases for closures; using closures with iterators to process a series of sequential data in a collection.
The iterator pattern performs progressive tasks on this sequence of items stored in a Rust collection.
N.B., for further reading on iterators, check out the Rust docs.
Let’s explain further how closure works with an iterator by first defining a vector variable:
#[derive(PartialEq, Debug)] struct MusicFile { size: u32, title: String, } fn main() { let files = vec![ MusicFile { size: 1024, title: String::from("Last last"), }, MusicFile { size: 2048, title: String::from("Influence"), }, MusicFile { size: 1024, title: String::from("Ye"), }, ]; let max_size = 1024; let accepted_file_sizes: Vec<MusicFile> = files.into_iter().filter( |s| s.size == max_size).collect(); println!("{:?}", accepted_file_sizes); }
Here is our output:
[MusicFile { size: 1024, title: "Last last" }, MusicFile { size: 1024, title: "Ye" }]
In the snippet above, we adapted the files
variable into an iterator using the into_iter
method, which can be called on the Vec<T>
type.
The into_iter
method creates a consuming adaptor type of the iterator type. This iterator moves every value out of the files
variable (from start to finish), meaning we can’t use the variable after calling it.
We defined it directly inside the filter
function to call a closure as an argument. Then, we used the last function call, collect()
, to consume and transform the iterator back into a Rust collection; in this case, the Vec<MusicFile>
type.
Closures are function-like constructs used alongside normal functions or iterators to process sequential items stored in a Rust collection.
You can implement a particular closure type depending on what context you want to use it for — this gives you the flexibility to take ownership of captured variables or borrow a reference to the variables, or neither!
Depending on your functional programming needs in Rust, closures for environment capturing can be a great benefit and make your life a lot easier. Let me know about your experiences with closures and environment capturing in Rust!
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowCompare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.