This is a premium alert message you can set from Layout! Get Now!

Optimizing your Rust code with closures: environment capturing

0

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:

Closures vs. functions in Rust

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:

Syntax

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 (->).

Body

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.

Return type

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.

Data types

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.

Environment capturing

Closures in Rust can capture variables from their environment, while functions cannot. This means that closures can reference variables that are defined outside.

Closure syntax

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.

Closure Type Inference and Annotation

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.

Environment capturing with closures in Rust

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

Closure Variable Moved Error Rust Environment Capturing

Closure as a function’s argument

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 trait
  • FnMut: 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 values
  • Fn: This trait neither mutates, returns captured values, nor captures variables from its defining scope

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

Using closures with iterators for processing collections

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.

Conclusion

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!

The post Optimizing your Rust code with closures: environment capturing appeared first on LogRocket Blog.



from LogRocket Blog https://ift.tt/4TcpDHK
Gain $200 in a week
via Read more

Post a Comment

0 Comments
* Please Don't Spam Here. All the Comments are Reviewed by Admin.
Post a Comment

Search This Blog

To Top