ExamplesBy LevelBy TopicLearning Paths
518 Intermediate

Function Pointers vs Closures

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "Function Pointers vs Closures" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Two abstractions represent callable values in Rust: `fn` pointers (a plain machine address) and closures (address plus captured environment). Key difference from OCaml: 1. **Size visibility**: Rust exposes `size_of_val` to measure closure size at compile time; OCaml treats all function values as one

Tutorial

The Problem

Two abstractions represent callable values in Rust: fn pointers (a plain machine address) and closures (address plus captured environment). The tension between them matters in practice: fn pointers have a known, fixed size — useful for FFI, const contexts, and uniform dispatch tables. Closures are more powerful but carry hidden state and require generics or boxing. Choosing the wrong abstraction forces unnecessary heap allocation or limits caller flexibility. This example compares the two side-by-side including their memory layout.

🎯 Learning Outcomes

  • • The concrete memory difference between fn pointers, non-capturing closures, and capturing closures
  • • How apply_fn_ptr(f: fn(i32) -> i32) differs from apply_generic<F: Fn(i32) -> i32>(f: F)
  • • Why named functions can be used directly as fn pointer values
  • • When to prefer fn (FFI, tables, const) vs impl Fn (generic) vs Box<dyn Fn> (dynamic)
  • • How std::mem::size_of_val reveals the size of each callable kind
  • Code Example

    fn square(x: i32) -> i32 { x * x }
    
    // fn pointer: thin, no captured data
    fn apply_fn_ptr(f: fn(i32) -> i32, x: i32) -> i32 { f(x) }
    
    // Generic Fn: works with closures too
    fn apply_generic<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 { f(x) }

    Key Differences

  • Size visibility: Rust exposes size_of_val to measure closure size at compile time; OCaml treats all function values as one-word GC pointers, hiding the environment.
  • FFI boundary: Rust fn pointers cross C FFI boundaries natively; OCaml functions require ctypes wrappers or Callback.register for the same.
  • Generic dispatch: Rust monomorphizes F: Fn(T) -> U into separate code per closure type; OCaml uses boxing (value representation) — no separate copies but with indirection.
  • Dispatch tables: Rust Vec<fn(i32) -> i32> stores uniform-size pointers with no allocation overhead per entry; OCaml list of functions stores GC-managed boxed values.
  • OCaml Approach

    OCaml has a unified function type — there is no fn pointer vs closure distinction at the source level. All functions are closures; non-capturing ones compile to a record with a code pointer and an empty environment. The compiler optimizes away the environment allocation for known non-capturing functions in many cases, but the type does not distinguish them.

    let square x = x * x
    let ops = [("square", square); ("double", fun x -> x * 2)]
    let apply f x = f x
    

    Full Source

    #![allow(clippy::all)]
    //! Function Pointers vs Closures
    //!
    //! Comparing fn pointers and closures: size, capabilities, use cases.
    
    /// Named functions — can be used as fn pointers.
    pub fn square(x: i32) -> i32 {
        x * x
    }
    pub fn cube(x: i32) -> i32 {
        x * x * x
    }
    pub fn double(x: i32) -> i32 {
        x * 2
    }
    
    /// Accepts fn pointer — only non-capturing callables.
    pub fn apply_fn_ptr(f: fn(i32) -> i32, x: i32) -> i32 {
        f(x)
    }
    
    /// Accepts any Fn — works with both fn ptrs and closures.
    pub fn apply_generic<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
        f(x)
    }
    
    /// Table using fn pointers.
    pub fn math_ops() -> Vec<(&'static str, fn(i32) -> i32)> {
        vec![
            ("square", square),
            ("cube", cube),
            ("double", double),
            ("negate", |x| -x),
        ]
    }
    
    /// Size comparison between fn pointer and closure.
    pub fn size_comparison() -> (usize, usize, usize) {
        let fn_ptr_size = std::mem::size_of::<fn(i32) -> i32>();
        let non_capturing = std::mem::size_of_val(&|x: i32| x * 2);
        let y = 42i32;
        let capturing = std::mem::size_of_val(&move |x: i32| x + y);
        (fn_ptr_size, non_capturing, capturing)
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_fn_ptr_with_named() {
            assert_eq!(apply_fn_ptr(square, 5), 25);
            assert_eq!(apply_fn_ptr(cube, 3), 27);
            assert_eq!(apply_fn_ptr(double, 7), 14);
        }
    
        #[test]
        fn test_fn_ptr_with_closure() {
            assert_eq!(apply_fn_ptr(|x| x + 1, 5), 6);
            assert_eq!(apply_fn_ptr(|x| -x, 5), -5);
        }
    
        #[test]
        fn test_generic_with_both() {
            assert_eq!(apply_generic(square, 5), 25);
            assert_eq!(apply_generic(|x| x + 10, 5), 15);
    
            let offset = 100;
            assert_eq!(apply_generic(|x| x + offset, 5), 105);
        }
    
        #[test]
        fn test_math_ops_table() {
            let ops = math_ops();
            assert_eq!(ops[0].1(5), 25); // square
            assert_eq!(ops[1].1(3), 27); // cube
            assert_eq!(ops[2].1(7), 14); // double
            assert_eq!(ops[3].1(5), -5); // negate
        }
    
        #[test]
        fn test_size_comparison() {
            let (fn_size, non_cap, cap) = size_comparison();
            assert_eq!(fn_size, std::mem::size_of::<usize>());
            assert_eq!(non_cap, 0); // non-capturing is zero-sized
            assert!(cap > 0); // capturing holds data
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_fn_ptr_with_named() {
            assert_eq!(apply_fn_ptr(square, 5), 25);
            assert_eq!(apply_fn_ptr(cube, 3), 27);
            assert_eq!(apply_fn_ptr(double, 7), 14);
        }
    
        #[test]
        fn test_fn_ptr_with_closure() {
            assert_eq!(apply_fn_ptr(|x| x + 1, 5), 6);
            assert_eq!(apply_fn_ptr(|x| -x, 5), -5);
        }
    
        #[test]
        fn test_generic_with_both() {
            assert_eq!(apply_generic(square, 5), 25);
            assert_eq!(apply_generic(|x| x + 10, 5), 15);
    
            let offset = 100;
            assert_eq!(apply_generic(|x| x + offset, 5), 105);
        }
    
        #[test]
        fn test_math_ops_table() {
            let ops = math_ops();
            assert_eq!(ops[0].1(5), 25); // square
            assert_eq!(ops[1].1(3), 27); // cube
            assert_eq!(ops[2].1(7), 14); // double
            assert_eq!(ops[3].1(5), -5); // negate
        }
    
        #[test]
        fn test_size_comparison() {
            let (fn_size, non_cap, cap) = size_comparison();
            assert_eq!(fn_size, std::mem::size_of::<usize>());
            assert_eq!(non_cap, 0); // non-capturing is zero-sized
            assert!(cap > 0); // capturing holds data
        }
    }

    Deep Comparison

    OCaml vs Rust: Function Pointers

    OCaml

    (* No distinction — all functions are uniform *)
    let square x = x * x
    let apply f x = f x
    let _ = apply square 5
    

    Rust

    fn square(x: i32) -> i32 { x * x }
    
    // fn pointer: thin, no captured data
    fn apply_fn_ptr(f: fn(i32) -> i32, x: i32) -> i32 { f(x) }
    
    // Generic Fn: works with closures too
    fn apply_generic<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 { f(x) }
    

    Key Differences

  • OCaml: Uniform function representation
  • Rust: fn pointers are thin (1 pointer), closures may carry data
  • Rust: Non-capturing closures are zero-sized
  • Rust: fn pointers needed for C FFI
  • Both support higher-order functions
  • Exercises

  • Benchmark dispatch: Measure the performance difference between calling via fn(i32) -> i32, via impl Fn(i32) -> i32, and via Box<dyn Fn(i32) -> i32> in a tight loop using std::hint::black_box.
  • Const dispatch table: Define a const array OPS: [fn(i32) -> i32; 4] at the module level and verify it is accessible in const evaluation contexts.
  • Dynamic registry: Build a HashMap<String, Box<dyn Fn(i32) -> i32>> where named functions and capturing closures can both be registered, then write a run(name, arg) dispatcher.
  • Open Source Repos