Function Pointers vs Closures
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
fn pointers, non-capturing closures, and capturing closuresapply_fn_ptr(f: fn(i32) -> i32) differs from apply_generic<F: Fn(i32) -> i32>(f: F)fn pointer valuesfn (FFI, tables, const) vs impl Fn (generic) vs Box<dyn Fn> (dynamic)std::mem::size_of_val reveals the size of each callable kindCode 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_of_val to measure closure size at compile time; OCaml treats all function values as one-word GC pointers, hiding the environment.fn pointers cross C FFI boundaries natively; OCaml functions require ctypes wrappers or Callback.register for the same.F: Fn(T) -> U into separate code per closure type; OCaml uses boxing (value representation) — no separate copies but with indirection.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
}
}#[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
Exercises
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 array OPS: [fn(i32) -> i32; 4] at the module level and verify it is accessible in const evaluation contexts.HashMap<String, Box<dyn Fn(i32) -> i32>> where named functions and capturing closures can both be registered, then write a run(name, arg) dispatcher.