July 12, 20255 min

Using closures versus regular functions ?

m
mayo

Performance Overhead

Closures in Rust have zero runtime overhead in most cases due to static dispatch and compiler optimizations. However, specific scenarios can introduce costs:

Call overhead: impl Fn is inlined; Box<dyn Fn> pays a vtable lookup impl Fn (static) ~0 ns (inlined) Box<dyn Fn> (dynamic) ~2-3x slower (vtable + cache miss) Large captures (e.g. a 1KB buffer) also grow closure size, regardless of dispatch
Aspect Closures Regular Functions
Dispatch Static (via monomorphization) Always static (direct call)
Memory May store captured data (size varies) No captured data (fixed size)
Heap Allocation Only if boxed (Box) Never
Optimization Inlined aggressively Inlined aggressively

When Closures May Be Less Efficient

Heap-Allocated Trait Objects (Box)

Using dynamic dispatch (e.g., Box) adds overhead:

  • Vtable Lookups: Indirect calls via function pointers.
  • Cache Misses: Fat pointers (data + vtable) reduce locality.
let closures: Vec<Box<dyn Fn(i32) -> i32>> = vec![
    Box::new(|x| x + 1),
    Box::new(|x| x * 2),
]; // Heap-allocated, slower to call

Large Captured Environments

Closures storing large structs (e.g., 1KB buffer) increase memory usage and may inhibit inlining:

let data = [0u8; 1024]; // 1KB array
let closure = move || data.len(); // Closure size = 1KB + overhead

A closure is just an anonymous struct holding its captures, so "how expensive is it" is really a question about that struct's layout:

A closure is a struct of its captures — the layout is the cost small Copy capture move || x * 2 4 bytes on the stack just the captured i32 inlines away entirely — identical ASM to a plain fn large capture move || data.len() 1024 bytes on the stack the whole [0u8; 1024] copied byte for byte still no heap, but too big to inline Box<dyn Fn> Box::new(|x| x + 1) 16 bytes: fat pointer data ptr + vtable ptr captured env separate heap allocation vtable one indirect jump per call Only the right-hand column touches the heap; only it can't be inlined. The middle one is free at call time but expensive to move around.

Excessive Monomorphization

Generic closures with many instantiations (e.g., in a hot loop) can bloat binary size:

(0..1_000).for_each(|i| { /* Unique closure per iteration */ });

Zero-Cost Abstractions in Practice

Static Dispatch (impl Fn)

Closures are as fast as regular functions when:

  • Captured data is small (e.g., primitives).
  • Monomorphization doesn't cause code bloat.
let add = |x, y| x + y; // Same ASM as `fn add(x: i32, y: i32) -> i32`

Example: Inlining

fn main() {
    let x = 5;
    let closure = || x * 2; // Inlined → no function call
    println!("{}", closure()); // ASM: `mov eax, 10`
}

Key Takeaways

✅ Use impl Fn for zero-cost static dispatch. 🚫 Avoid Box in performance-critical code. ⚠️ Optimize large captures: Prefer borrowing or minimizing captured data.

Real-World Impact

  • rayon uses closures with static dispatch for parallel iterators (no overhead).
  • GUI frameworks like iced leverage closures for event handlers efficiently.

Try This: Compare the assembly output of a closure and a function with cargo rustc -- --emit asm!