By now, you must be good at writing functions in Rust. If you have prior programming experience, you would know that functions sometimes accept pointers as parameters. If you know functional programming, you must have heard about higher-order functions and closures. Let’s take them step by step.
Closures: syntax, capturing, and usage
Closures are anonymous functions that can capture variables from their surrounding scope. They are flexible and powerful tools for encapsulating behavior concisely.
Syntax
Here’s a simple example of a closure that adds two numbers:
pub fn do_closure() {
let add_closure = |x, y| x + y;
let result = add_closure(10, 5);
println!("Result: {}", result);
}
fn main() {
// Use do_closure.
do_closure();
}
Now, let’s move on to Capturing.
Capturing
Closures can capture variables from their enclosing scope. They can capture variables by reference or by value. Consider this example:
pub fn do_capture() {
let base = 10;
let add_to_base = |x| x + base;
let result = add_to_base(5);
println!("Result: {}", result);
}
fn main() {
// Use do_capture.
do_capture();
}
The examples show how closures work in Rust: one or more variables are captured into an anonymous function denoted by pipes, the operation is carried out right after the capture, and the entire process is assigned to a variable. The variable can then be used as a function all along.
Let’s see how this is done in a real system. We will use closures to customize the formatting of log messages based on their severity levels:
pub struct Logger {
pub log_level: LogLevel,
}
pub enum LogLevel {
Info,
Warning,
Error,
}
impl Logger {
pub fn new(log_level: LogLevel) -> Self {
Logger { log_level }
}
pub fn log(&self, message: &str, formatter: impl Fn(&str) -> String) {
if self.should_log() {
let formatted_message = formatter(message);
println!("{}", formatted_message);
}
}
pub fn should_log(&self) -> bool {
match self.log_level {
LogLevel::Info => true,
LogLevel::Warning => true,
LogLevel::Error => true,
}
}
}
fn main() {
let logger = Logger::new(LogLevel::Warning);
let info_formatter = | message: &str | format!("INFO: {}", message);
let warning_formatter = | message: &str | format!("WARNING: {}", message);
let error_formatter = | message: &str | format!("ERROR: {}", message);
logger.log("This is an information message", info_formatter);
logger.log("This is a warning message", warning_formatter);
logger.log("This is an error message", error_formatter);
}
In this example, we've defined a Logger
struct with a log
method that takes a message and a closure to format the message based on its severity level. The Logger
struct also has a should_log
method to determine whether a message should be logged based on the log level set during initialization.
We then define three formatting closures (info_formatter
, warning_formatter
, and error_formatter
) that prepend the appropriate log level to the message.
In the main
function, we create a Logger
instance with a log level of Warning
. We then log messages with different severity levels using the corresponding formatting closures. Since the log level is set to Warning
, only the warning and error messages are logged.
This example showcases how closures can encapsulate behavior and customize functionality. In a backend scenario, you could extend this logging system to include additional features such as writing logs to files, sending logs to remote servers, or integrating with third-party logging libraries.
Function pointers and higher-order functions
Rust supports functional programming. Thus, you can write functions to receive anything as value, including other functions, and to return anything as a result, including other functions. This opens the door to using function pointers and higher-order functions.
Function pointers
A function pointer refers to the memory location of a function. They're useful for scenarios where you want to pass functions as arguments or store them in data structures. Here's a basic example:
pub fn add(x: i32, y: i32) -> i32 {
x + y
}
pub fn subtract(x: i32, y: i32) -> i32 {
x - y
}
pub fn calculate(operation: fn(i32, i32) -> i32, x: i32, y: i32) -> i32 {
operation(x, y)
}
The identity of the function pointer is not known at compile time.
Higher-Order functions
These are functions that take other functions as arguments or return functions. They allow you to create more abstract and reusable code. Consider the following example that implements a higher-order function for applying a function to each element of a vector:
pub fn apply_operation(operation: fn(i32) -> i32, number: i32) -> i32 {
operation(number)
}
pub fn square(x: i32) -> i32 {
x * x
}
pub fn double(x: i32) -> i32 {
x * 2
}
fn main() {
let num = 5;
let squared_result = apply_operation(square, num);
println!("Square of {} is {}.", num, squared_result);
let doubled_result = apply_operation(double, num);
println!("Double of {} is {}.", num, doubled_result);
}
Using a real-world example, let’s see how higher-order functions that have closures are written. Below, we simulate a data processing pipeline for user data:
pub struct User {
pub id: u32,
pub name: String,
pub age: u32,
}
fn main() {
// Higher-order functions with closures.
let hof_users = vec![
advanced::hof::User { id: 1, name: "Alice".to_string(), age: 18 },
advanced::hof::User { id: 2, name: "Bob".to_string(), age: 32 },
advanced::hof::User { id: 3, name: "Charlie".to_string(), age: 24 },
];
// Higher-order function: map
let names: Vec<String> = hof_users.iter().map(|user| user.name.clone()).collect();
println!("User names: {:?}", names);
// Higher-order function: filter
let adults: Vec<&User> = hof_users.iter().filter(|user| user.age >= 18).collect();
println!("Adult users: {:?}", adults);
// Higher-order function: reduce (fold)
let total_age: u32 = hof_users.iter().map(|user| user.age).fold(0, |acc, age| acc + age);
println!("Total age of all users: {}", total_age);
}
In this example, we have a User
struct representing user data. Our goal is to use higher-order functions to process this data differently.
Map
We start by using the map
higher-order function to transform the user data. We create a new vector containing only the names of the users using the map
function and a closure that extracts the name
field from each user. This demonstrates how higher-order functions can transform data into a different format.
Filter
Next, we use the filter
higher-order function to select specific users based on a condition. We create a new vector containing only the adult users (those aged 18 or older) using the filter
function and a closure that checks the age
field. This showcases how higher-order functions can be used for data filtering.
Reduce (Fold)
Finally, we utilize the fold
higher-order function to perform a reduction operation on the user data. We calculate the total age of all users using the fold
function and a closure that accumulates the ages. This demonstrates how higher-order functions can be used for data aggregation.
Exercise
Higher-Order Functions: Write a higher-order function that takes a vector of integers and returns a new vector containing only the even numbers.
Closures: Create a program that simulates an online auction. Implement a bidding mechanism using closures to define the behavior of placing bids and determining the winner.