DEV Community

Ben Lovy
Ben Lovy

Posted on • Edited on

Higher-Order Functions in Rust

Rust is an imperative language but it provides many tools in the standard library which adhere to a more functional style, like the Iterator trait and its methods like map, for_each, and filter. This is a quick run-down of how to define your own higher-order functions in Rust which can both take closures as parameters and return closures in such a way that you can use the two together.

edit @theodesp rightly pointed out that these examples do not follow a purely functional style - I'm mutating the input in place. I wrote it this way because the pure solutions are well covered in the standard library and this use case is less clear, but I've updated the Rust Playground with some hand-rolled pure implementations as well for comparison.

To demonstrate these we're going to work with a 2D grid of numbers:

type Grid = Vec<Vec<i32>>;

fn prepare_grid(rows: i32, columns: i32) -> Grid {
    let mut ret = Vec::new();
    for _ in 0..rows {
        let mut row = Vec::new();
        for _ in 0..columns {
            row.push(0i32)
        }
        ret.push(row);
    }
    ret
}
Enter fullscreen mode Exit fullscreen mode

This function will initialize the grid to all 0s:

fn main() {
    let mut grid = prepare_grid(5, 5);
    println!("{:?}", grid);
}
// [[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]
Enter fullscreen mode Exit fullscreen mode

For whatever nefarious (?) purposes we might have in mind for our number grid it may be useful to act on a row at a time. We can accept a closure as a parameter using a generic data type implementing the Fn trait:

fn map_rows<F>(grid: &mut Grid, func: F)
where
    F: Fn(&mut Vec<i32>)
{
    for row in grid {
        func(row)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can, for example, increment the first element of each row:

map_rows(&mut grid, |row: &mut Vec<i32>| row[0] += 1);

println!("{:?}", grid);
// [[1, 0, 0, 0, 0], [1, 0, 0, 0, 0], [1, 0, 0, 0, 0], [1, 0, 0, 0, 0], [1, 0, 0, 0, 0]]
Enter fullscreen mode Exit fullscreen mode

Returning a function is a tad trickier because of how Rust manages lifetimes. What say you wanted to decide how much to increment that first value by. You need to return a Fn(&mut Vec<i32>). Thing is, Rust needs to calculate the lifetime of that return function at compile time. We have to explicitly tell the compiler that this function will live as long as the input parameter lives by using a reference and assigning it the lifetime 'a:

fn make_incrementer<'a>(amount:&'a i32) -> Box<Fn(&mut Vec<i32>) + 'a> {
    Box::new(move |row: &mut Vec<i32>| row[0] += amount)
}
Enter fullscreen mode Exit fullscreen mode

We're using the Box pointer type so that make_incrementer's return type's size is known at compile time, and using a move closure to ensure a new stack frame is allocated for the closure and we copy amount into it - thus escaping make_incrementer's stack frame.

Using it with map_rows requires some changes:

fn map_rows2<F>(grid: &mut Grid, func: Box<F>)
where
    F: for<'a> Fn(&'a mut Vec<i32>) + ?Sized
{
    for row in grid {
        (*func)(row)
    }
}
Enter fullscreen mode Exit fullscreen mode

We now have an explicit lifetime to deal with but generally it would need to be at least as long as the whole function. In this case the compiler will complain because it only comes into scope when the closure is called inside the function, way down in our for loop - clearly shorter than the whole function. The for<...> syntax is a feature called a Higher-Ranked Trait Bound, which tells the compiler to explicitly calculate the minimum lifetime to invoke our closure instead of defining it for the whole function with map_rows2<'a, F>(...), circumventing this problem.

We also need to pass the whole Box in as our argument because all local variables must be Sized (though apparently unsized locals are in the works). You don't need to take ownership - &Box<F> is fine. This, though, will cause Rust to freak out because now we don't have a Sized value for F like before. We needed to create one in order to get make_incrementer to compile but we've gone and undone it all by unwrapping it. Luckily there's an escape hatch - by adding ?Sized we can relax that requirement. The only other change is getting at the actual closure with (*func) inside our for loop.

Now we can go wild:

map_rows2(&mut grid, make_incrementer(&2));

println!("{:?}", grid);
// [[3, 0, 0, 0, 0], [3, 0, 0, 0, 0], [3, 0, 0, 0, 0], [3, 0, 0, 0, 0], [3, 0, 0, 0, 0]]
// we already added 1!
Enter fullscreen mode Exit fullscreen mode

Note: This example would work fine without the reference/lifetimes because our amount has type i32, which implements Copy - the move closure will just copy it for you. This means you can omit them in the map_rows2 too. The reference is only strictly necessary if your parameter to your factory function is not Copy - say, a String.

All in all higher-order functions are definitely more cumbersome to use in Rust than in a garbage-collected functional language, but absolutely not impossible. You just need to massage the borrow checker a little and make sure you know what exactly you're asking for!

Here's the full sample on the Rust Playground to tinker with.

Top comments (8)

Collapse
 
theodesp profile image
Theofanis Despoudis

I had the impression that map does not modify the original array but returns a new array with all the elements mapped to the function's domain. Also if you modify something in functional programming then it's unsafe and not really functional.

Can you show an example of this? (not using mut for example)?

Collapse
 
deciduously profile image
Ben Lovy

I've implemented pure versions of map_rows, map_rows2, and make_incrementer - here's an updated Playground link.

The signatures are almost the same - I've just replaced &mut Grid with &[Vec<i32>]. The only difference is the body of the functions themselves but you're correct - this set adheres more strictly to a functional paradigm whereas the originals do not.

Collapse
 
myrrlyn profile image
Alexander Payne

Rust isn’t functional; it’s just dressed like it. The ownership rules allow the compiler to prove that mut self -> Self functions and &mut self -> () methods are wholly equivalent, and therefore eliminate the need to construct a new object and destroy the old.

Collapse
 
deciduously profile image
Ben Lovy • Edited

Definitely, good catch - map was probably a bad choice of name. This example is not a pure functional style, but higher-order functions do not need to be pure. I thought that example was more interesting - if you do need a pure map I generally recommend using the standard library. I'd argue the above functions are still "functional programming", just not "pure functional programming". I don't know that I'd call it unsafe - fearless mutation like this is more idiomatic Rust.

I'll write up an example later this morning once I've had some coffee!

Collapse
 
jay profile image
Jay

I implemented a simplified version, of your pure functions. Used the standard map to map over rows, and some other goodies.

I suggest to use vec![init_value;size] instead of creating and pushing vec(s).

I used pass by value in Boxed case as passing by value for primitives is trivial, they implement Copy trait.

Rust Playground

Collapse
 
deciduously profile image
Ben Lovy • Edited

Way better! I was specifically trying to avoid map but you're definitely right about using vec! and passing by value for this function.

Using the trait object in the parameter list is better for the local closure version too.

Collapse
 
jeikabu profile image
jeikabu

Don't think I've come across for<> before. Interesting.
Haven't needed to dig very deep on some aspects of Rust, and this certainly touches on a few of them.

Collapse
 
deciduously profile image
Ben Lovy

I hadn't either. Useful, but not commonly necessary. I couldn't find a reference to it in the current edition of the book, here is the link to the relevant section of the first edition.