Matt Angelosanto
Posted on February 22, 2023
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))
}
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)());
}
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 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.
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
November 12, 2024