Returning Closures in Rust: The impl Keyword Explained

If you’ve worked with Rust closures, you’ve probably encountered a peculiar problem: how do you return one from a function? The answer lies in understanding one of Rust’s clever features: the impl keyword in return position.

The Hidden Complexity of Closures

Here’s something that might surprise you: every closure in Rust has its own unique, anonymous type. Even if two closures have identical signatures and behavior, the compiler treats them as distinct types. This creates an interesting challenge when you want to return different closures from the same function.

Consider a function that returns different mathematical operations based on user input. You might write something like this:

fn returns_a_closure(input: &str) -> impl Fn(i32) -> i32 {
    match input {
        "double" => |n| n * 2,
        "triple" => |n| n * 3,
        _ => |n| n,
    }
}

That impl Fn(i32) -> i32 is doing heavy lifting. It tells the compiler: “I’m returning some type that implements the Fn(i32) -> i32 trait, but I’m not specifying which exact type.” This is called an opaque return type, and it’s the modern, idiomatic way to return closures.

Your Three Options

When returning closures, you have three main approaches, each with distinct trade-offs:

Function pointers (fn(i32) -> i32) are the simplest option, but only work for closures that don’t capture any variables from their environment. They’re fast and straightforward, but limited.

The impl keyword gives you zero-cost abstraction with static dispatch. The compiler knows the exact type at compile time, optimizes aggressively, and there’s no runtime overhead. This is your default choice for most scenarios.

Boxed trait objects (Box<dyn Fn(i32) -> i32>) offer maximum flexibility through dynamic dispatch. They’re essential when you need to store different closure types in collections or when the concrete type can’t be determined at compile time. The trade-off? Heap allocation and a small runtime cost.

The Bottom Line

Rust’s closure system reflects the language’s core philosophy: zero-cost abstractions wherever possible. The impl keyword lets you write elegant, flexible code while maintaining the performance characteristics of hand-written assembly. For most use cases, impl Fn strikes the perfect balance between expressiveness and efficiency, making it the go-to choice for returning closures in modern Rust.

Let’s break down what’s happening with Box<dyn Fn(i32) -> i32>:

Dynamic Dispatch vs Static Dispatch

With impl Fn, the compiler knows the exact type at compile time. It’s like the compiler saying “I know this is specifically the double closure” and can optimize accordingly. This is static dispatch – the decision about which code to run happens at compile time.

With Box<dyn Fn>, the compiler only knows “this is something that implements Fn” – it figures out which specific closure to call at runtime. This is dynamic dispatch – the decision happens while your program is running.

Why You Need It

Here’s a concrete example where impl won’t work:

// This WON'T compile with impl Fn!
fn get_operations() -> Vec<impl Fn(i32) -> i32> {
    vec![
        |x| x * 2,
        |x| x * 3,
        |x| x + 1,
    ]
}

Each closure is a different type, so you can’t put them in the same Vec. But with Box<dyn Fn>:

fn get_operations() -> Vec<Box<dyn Fn(i32) -> i32>> {
    vec![
        Box::new(|x| x * 2),
        Box::new(|x| x * 3),
        Box::new(|x| x + 1),
    ]
}

Now it works! All closures are “erased” to the same type: Box<dyn Fn(i32) -> i32>.

The Trade-offs

Heap allocation: Box puts the closure on the heap instead of the stack. This means a memory allocation (slower) and following a pointer to call it (tiny overhead).

Runtime cost: When you call the closure, the program has to look up which function to actually run using a “vtable” (virtual table). This is nanoseconds of overhead, but it exists.

When the type can’t be determined at compile time: Imagine loading plugins at runtime, or choosing operations based on user input stored in a config file. The compiler literally can’t know which closure you’ll use until the program runs.

Think of it this way: impl Fn is like a perfectly optimized, compile-time decision. Box<dyn Fn> is like having a menu of options you choose from at runtime – more flexible, slightly slower.

Memorise this

Closure = struct + code
Stack = known size, fast
Heap = unknown size, flexible
dyn = runtime dispatch
Box = heap allocation