Closure-to-Function-Pointer Coercion
Tutorial Video
Text description (accessibility)
This video demonstrates the "Closure-to-Function-Pointer Coercion" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. C and systems programming have long relied on function pointers for callbacks — they are a fixed machine-word size, require no heap allocation, and map directly to a call instruction. Key difference from OCaml: 1. **Uniform representation**: Rust `fn` pointers are a single pointer word with no closure environment; OCaml always has a (pointer, environment) pair even for non
Tutorial
The Problem
C and systems programming have long relied on function pointers for callbacks — they are a fixed machine-word size, require no heap allocation, and map directly to a call instruction. Rust preserves this capability: non-capturing closures and named functions both coerce to fn pointer types, enabling zero-overhead callbacks in FFI-compatible APIs. The constraint is intentional — a capturing closure has extra data that a raw pointer cannot represent. Understanding when coercion works and when it fails helps you choose between fn, impl Fn, and Box<dyn Fn>.
🎯 Learning Outcomes
fn pointers but capturing ones do notfn pointer valuesfn pointers (uniform size, no fat pointer)fn vs impl Fn vs Box<dyn Fn> for different API shapesfn pointer types for ABI compatibilityCode Example
// Non-capturing closure coerces to fn pointer
let f: fn(i32) -> i32 = |x| x * 2; // OK
// Capturing closure CANNOT coerce to fn pointer
let n = 3;
// let f: fn(i32) -> i32 = |x| x + n; // ERROR!
// Must use Box<dyn Fn> for capturing closures
let f: Box<dyn Fn(i32) -> i32> = Box::new(move |x| x + n);Key Differences
fn pointers are a single pointer word with no closure environment; OCaml always has a (pointer, environment) pair even for non-capturing functions.fn pointers map directly to C function pointers — usable in extern "C" callbacks without wrappers; OCaml requires ctypes machinery or Callback.register.fn pointers have a known, fixed size enabling [fn(T) -> U; N] arrays; OCaml function values are opaque pointers of uniform size too, but via GC indirection.fn(i32) -> i32 from fn(i64) -> i32 at the type level with no implicit coercion; OCaml's type system similarly rejects mismatched function types at compile time.OCaml Approach
OCaml has no function pointer / closure distinction at the value level — all functions are closures, and closed-over environments are heap-allocated. There is no direct coercion concept. For C FFI, OCaml uses ctypes or Callback.register, which wrap OCaml functions behind C-callable thunks. Performance-sensitive dispatch uses arrays of functions just as in Rust, but every entry is a closure regardless.
let ops = [| (fun x -> x * 2); (fun x -> x * 3) |]
let apply i x = ops.(i) x
Full Source
#![allow(clippy::all)]
//! Closure-to-fn-pointer Coercion
//!
//! Non-capturing closures coerce to fn pointers; capturing ones cannot.
/// Accept a function pointer explicitly.
pub fn apply_fn_ptr(f: fn(i32) -> i32, x: i32) -> i32 {
f(x)
}
/// Named functions are fn pointers.
pub fn double(x: i32) -> i32 {
x * 2
}
pub fn triple(x: i32) -> i32 {
x * 3
}
pub fn negate(x: i32) -> i32 {
-x
}
/// Array of function pointers (all same size — no fat pointers).
pub fn make_transform_table() -> [fn(i32) -> i32; 4] {
[
double,
triple,
negate,
|x| x + 10, // non-capturing closure coerces to fn ptr
]
}
/// Function that stores fn pointers in a Vec.
pub fn build_pipeline(ops: Vec<fn(i32) -> i32>) -> impl Fn(i32) -> i32 {
move |x| ops.iter().fold(x, |acc, f| f(acc))
}
/// Demonstrate coercion rules.
pub fn coercion_demo() {
// Non-capturing closure → fn pointer: OK
let _: fn(i32) -> i32 = |x| x * 2;
// Named function → fn pointer: OK
let _: fn(i32) -> i32 = double;
// Capturing closure → fn pointer: NOT OK (won't compile)
// let y = 5;
// let _: fn(i32) -> i32 = |x| x + y; // ERROR!
}
/// C FFI often requires fn pointers.
pub type Callback = fn(i32) -> i32;
pub fn register_callback(cb: Callback) -> i32 {
cb(100)
}
/// When you need to store capturing closures, use Box<dyn Fn>.
pub fn store_capturing_closures() -> Vec<Box<dyn Fn(i32) -> i32>> {
let a = 5;
let b = 10;
vec![
Box::new(|x| x + 1), // non-capturing
Box::new(move |x| x + a), // capturing
Box::new(move |x| x * b), // capturing
]
}
/// Size comparison: fn pointers vs closures.
pub fn size_demo() -> (usize, usize, usize) {
let fn_ptr_size = std::mem::size_of::<fn(i32) -> i32>();
let closure_size = std::mem::size_of_val(&|x: i32| x * 2);
let capturing_size = {
let y = 42i32;
std::mem::size_of_val(&move |x: i32| x + y)
};
(fn_ptr_size, closure_size, capturing_size)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_fn_ptr_with_function() {
assert_eq!(apply_fn_ptr(double, 5), 10);
assert_eq!(apply_fn_ptr(triple, 5), 15);
assert_eq!(apply_fn_ptr(negate, 5), -5);
}
#[test]
fn test_apply_fn_ptr_with_closure() {
// Non-capturing closure coerces to fn pointer
assert_eq!(apply_fn_ptr(|x| x + 1, 5), 6);
assert_eq!(apply_fn_ptr(|x| x * x, 5), 25);
}
#[test]
fn test_transform_table() {
let table = make_transform_table();
assert_eq!(table[0](5), 10); // double
assert_eq!(table[1](5), 15); // triple
assert_eq!(table[2](5), -5); // negate
assert_eq!(table[3](5), 15); // +10
}
#[test]
fn test_build_pipeline() {
let pipeline = build_pipeline(vec![double, |x| x + 1, triple]);
// (5 * 2 + 1) * 3 = 33
assert_eq!(pipeline(5), 33);
}
#[test]
fn test_register_callback() {
assert_eq!(register_callback(double), 200);
assert_eq!(register_callback(|x| x / 2), 50);
}
#[test]
fn test_store_capturing_closures() {
let closures = store_capturing_closures();
assert_eq!(closures[0](10), 11); // +1
assert_eq!(closures[1](10), 15); // +5
assert_eq!(closures[2](10), 100); // *10
}
#[test]
fn test_fn_pointer_size() {
let (fn_size, non_cap, _cap) = size_demo();
// fn pointer is one pointer size
assert_eq!(fn_size, std::mem::size_of::<usize>());
// non-capturing closure is zero-sized
assert_eq!(non_cap, 0);
}
#[test]
fn test_explicit_coercion() {
let f: fn(i32) -> i32 = |x| x * 2;
assert_eq!(f(21), 42);
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_fn_ptr_with_function() {
assert_eq!(apply_fn_ptr(double, 5), 10);
assert_eq!(apply_fn_ptr(triple, 5), 15);
assert_eq!(apply_fn_ptr(negate, 5), -5);
}
#[test]
fn test_apply_fn_ptr_with_closure() {
// Non-capturing closure coerces to fn pointer
assert_eq!(apply_fn_ptr(|x| x + 1, 5), 6);
assert_eq!(apply_fn_ptr(|x| x * x, 5), 25);
}
#[test]
fn test_transform_table() {
let table = make_transform_table();
assert_eq!(table[0](5), 10); // double
assert_eq!(table[1](5), 15); // triple
assert_eq!(table[2](5), -5); // negate
assert_eq!(table[3](5), 15); // +10
}
#[test]
fn test_build_pipeline() {
let pipeline = build_pipeline(vec![double, |x| x + 1, triple]);
// (5 * 2 + 1) * 3 = 33
assert_eq!(pipeline(5), 33);
}
#[test]
fn test_register_callback() {
assert_eq!(register_callback(double), 200);
assert_eq!(register_callback(|x| x / 2), 50);
}
#[test]
fn test_store_capturing_closures() {
let closures = store_capturing_closures();
assert_eq!(closures[0](10), 11); // +1
assert_eq!(closures[1](10), 15); // +5
assert_eq!(closures[2](10), 100); // *10
}
#[test]
fn test_fn_pointer_size() {
let (fn_size, non_cap, _cap) = size_demo();
// fn pointer is one pointer size
assert_eq!(fn_size, std::mem::size_of::<usize>());
// non-capturing closure is zero-sized
assert_eq!(non_cap, 0);
}
#[test]
fn test_explicit_coercion() {
let f: fn(i32) -> i32 = |x| x * 2;
assert_eq!(f(21), 42);
}
}
Deep Comparison
OCaml vs Rust: Closure/Function Pointer Coercion
OCaml
(* All functions have the same representation *)
let double x = x * 2
let add_n n x = x + n
(* No distinction between fn pointers and closures *)
let apply f x = f x
let _ = apply double 5
let _ = apply (add_n 3) 5
Rust
// Non-capturing closure coerces to fn pointer
let f: fn(i32) -> i32 = |x| x * 2; // OK
// Capturing closure CANNOT coerce to fn pointer
let n = 3;
// let f: fn(i32) -> i32 = |x| x + n; // ERROR!
// Must use Box<dyn Fn> for capturing closures
let f: Box<dyn Fn(i32) -> i32> = Box::new(move |x| x + n);
Key Differences
Exercises
[fn(i32) -> i32; N] dispatch table and write a function that takes an index and applies the corresponding operation, returning an error variant for out-of-bounds indices.build_named_pipeline(ops: &[&str]) that looks up named operations ("double", "negate", etc.) in a HashMap<&str, fn(i32) -> i32> and returns a composed fn pipeline.Box<dyn Fn(i32) -> i32> can hold both fn pointers and capturing closures, and write a dispatcher that tries a fn pointer table first and falls back to a Box<dyn Fn> registry.