How to define higher-order functions in Rust

mangelosanto

Matt Angelosanto

Posted on February 22, 2023

How to define higher-order functions in Rust

Written by Matteo Di Pirro✏️

Higher-order functions (HOFs) are functions whose parameters and/or return values are functions themselves. In other words, if a language supports higher-order functions, then we say that these functions are first-class citizens, that is they are values.

In this article, we’ll investigate how Rust supports higher-order functions and how we can define them.

Jump ahead:

Functions in Rust

We can define functions in Rust via the fn keyword. As usual, to define a function we have to specify its name, parameters, and the type of the returned value:

fn plus_one(n: i32) -> i32 {
    n + 1
}

The return keyword is optional. If we don’t specify it, the last statement of the function is considered as the return statement.

As we said earlier, functions in Rust are first-class citizens. Hence, we can store them in a variable. Once it is stored in a variable, we can invoke it as usual:

fn main() {
    let add_one = plus_one;

    println!("{}", add_one(1));
}

Functions as parameters

In the above section, we demonstrated how to define a function and store it in a variable. Now, let’s see how to pass a function as a parameter to another function.

First, we have to create our higher-order function definition:

fn binary_operator(n: i32, m: i32, op: F) -> i32 
                     where F: Fn(i32, i32) -> i32 {
    op(n, m)
}

binary_operator inputs two numbers, n and m, and a function, op. It applies op to n and m, returning the result.

Note the type of the op parameter. It is a generic type F, refined in the where clause of binary_operator. In particular, we defined it as a function (Fn) with two numeric parameters ((i32, i32)), returning a parameter (i32). Fn here represents a function pointer, that is, the memory address where the function is stored.

Named functions

The simplest way to pass a function as a parameter is by using its name (named function):

 add(n: i32, m: i32) -> i32 {
    n + m
}

fn binary_operator(n: i32, m: i32, op: F) -> i32 
                     where F: Fn(i32, i32) -> i32 {
    op(n, m)
}

fn main() {
    println!("{}", binary_operator(5, 6, add));
}

In the example above, we first define a function (add) to add two numbers and use it as a parameter to binary_operator. main prints 11, as expected.

Anonymous functions

Sometimes it’s not necessary to name functions. For example, we might want to define a function on the fly, to be used only in a single place. This is where anonymous functions come into play:

fn main() {
    println!("{}", binary_operator(5, 6, |a: i32, b: i32| a - b));
}

In the example above, we define an anonymous function directly in the call to binary_operator. We define the parameters list between pipes (||), followed by the body of the function itself.

Anonymous functions are a very powerful tool, more so because, in Rust, they can “capture” the enclosing environment. In this case, the functions are also called closures.

The snippet above compiles and prints -1, as expected.

Functions as returned values

As we mentioned earlier, in Rust a function can also return another function. This is a bit more complicated due to the consequence of memory management in Rust, as we’ll see shortly.

In the following example, we’ll modify binary_operator, defined above, not to apply the operator but, instead, to return a function representing an unapplied operator:

fn unapplied_binary_operator<'a, F>(n:& 'a i32, m:& 'a i32, op:& 'a F) 
                                   -> Box<dyn Fn() -> i32 + 'a>
                                   where F: Fn(i32, i32) -> i32 {
    Box::new(move || op(*n, *m))
}
Enter fullscreen mode Exit fullscreen mode

The definition of unapplied_binary_operator now looks much more complex.

The main problem with returning a function is defining the length of that function’s lifetime. A lifetime in Rust is a construct the borrow checker uses to ensure all borrows are valid.

The borrow checker and, more in general, how borrowing works in Rust is out of the scope of this article. If you’re unfamiliar with the Rust borrow checker, or want to know more, check out this article.

In the above example, we define a lifetime ('a) together with the usual F type (representing the binary operator). Then, we associate 'a to the three parameters as well as to the returned value of the function.

Basically, we’re telling the borrow checker to consider the lifetime of the function returned by unapplied_binary_operator as long as the three parameters (n, m, and op) exist.

Furthermore, lifetimes in Rust may only exist with references. Hence, we have to turn our parameters and returned value into references with & and Box, respectively.

Generally speaking, Box<dyn Fn()> represents a boxed-up value implementing the Fn trait.

References make the function body more complex than before, as we now have to dereference the references to n and m, and explicitly create the resulting function using Box::new().

Another interesting thing in the above implementation is the use of move. This keyword signals that all captures (i.e., all references to the enclosing environment) occur by value. Otherwise, any captures by reference will be dropped as soon as the returned anonymous function exists, leaving the closure with invalid references. In other words, with move, the closure takes ownership of the variables it uses.

unapplied_binary_operator returns a function, with no arguments, returning the result of the application of op to n and m. Provided that we’re now using references, we have to use borrowing to invoke it:

fn main() {
    let n = 5;
    let m = 6;
    println!("{}", unapplied_binary_operator(&n, &m, &add)());
}
Enter fullscreen mode Exit fullscreen mode

Note how we borrow n, m, and add (defined above) using &. Lastly, since unapplied_binary_operator returns a function with no parameters, we can invoke it using empty parentheses. The above snippet will print 11, as expected.

Conclusion

In this article, we took a quick dive into higher-order functions in Rust. We demonstrated how to define a simple function. Then, we explored the ways to pass a function as a parameter. Lastly, we investigated how much more complex it is to return a function, briefly mentioning some key Rust features, such as borrowing and lifetimes.

Higher-order functions are a key feature in Rust and in many other programming languages; HOFs are a foundational concept of functional programming. We can use higher-order functions to write code that is more concise and easier to maintain in the long run.


LogRocket: Full visibility into production Rust apps

Debugging Rust applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.

LogRocket Dashboard Free Trial Banner

LogRocket is like a DVR for web apps, recording literally everything that happens on your Rust app. 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.

💖 💪 🙅 🚩
mangelosanto
Matt Angelosanto

Posted on February 22, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related