Crust of Rust: Functions, Closures, and Their Traits
In Rust, functions and function-like entities are highly versatile but come with distinct types, traits, and behaviors. This chapter demystifies the differences between function items, function pointers, and closures, explains how the compiler represents them under the hood, and explores advanced concepts such as dynamic dispatch (dyn Fn), conditional compilation (~const), and Higher-Rank Trait Bounds (HRTBs).
1. Function Items vs. Function Pointers
When you define a function in Rust using the fn keyword, you are not creating a function pointer. Instead, you are defining a Function Item.
Function Items
A function item is a zero-sized type (ZST) unique to that specific function definition. Because its size is bytes, it exists only as a compile-time identifier. This allows the compiler to perform aggressive optimizations, such as direct inlining, without any runtime pointer-dereference overhead.
If a function is generic, each unique instantiation of that function gets its own distinct zero-sized function item type.
use std::mem::size_of_val;
fn bar<T>() {
// Function body
}
fn main() {
// x is a Function Item uniquely identifying bar::<i32>
let x = bar::<i32>;
println!("Size of x: {} bytes", size_of_val(&x)); // Outputs: 0 bytes
}
Because function items uniquely identify a specific instantiation, you cannot assign a different instantiation to the same variable:
let mut x = bar::<i32>;
// x = bar::<u32>; // Compile Error: Mismatched types!
Function Pointers
A Function Pointer (written as lowercase fn(...) -> ...) is a primitive type that holds an actual address pointing to the executable code of a function. It has a size of 1 word ( bytes on a 64-bit architecture).
Function items can be implicitly coerced into function pointers if the target signature matches:
fn baz(f: fn()) {
println!("Size of f: {} bytes", std::mem::size_of_val(&f)); // Outputs: 8 bytes
f();
}
fn main() {
// Coerces the zero-sized function items into 8-byte function pointers
baz(bar::<i32>);
baz(bar::<u32>);
}
2. The Three Fn Traits and Their Hierarchy
Rust defines three traits in the standard library to represent callable entities: Fn, FnMut, and FnOnce. The fundamental distinction between them lies in how they receive their self parameter.
The Trait Hierarchy
These traits form a strict hierarchy based on capability:
Fnis a sub-trait ofFnMut: Any type that can be called via a shared reference (&self) can also be called via an exclusive reference (&mut self).FnMutis a sub-trait ofFnOnce: Any type that can be called via an exclusive reference (&mut self) can also be called by taking ownership ofself.
Because function pointers (fn()) do not capture any environment or state, they do not care about ownership or mutability. Therefore, all function pointers implement all three traits (Fn, FnMut, and FnOnce).
3. Closures and the Mechanics of Capturing
Closures are anonymous functions that can capture variables from their enclosing environment.
Non-Capturing Closures
If a closure does not reference any variables from its environment, it behaves exactly like a function item and can be coerced directly into a function pointer:
fn call_pointer(f: fn(u32) -> u32) {
f(42);
}
fn main() {
// Non-capturing closure coerced to fn(u32) -> u32
call_pointer(|x| x + 1);
}
Capturing Closures (The Compiler's Mental Model)
When a closure captures variables from its environment, it can no longer be represented as a simple function pointer. Instead, the compiler generates a unique, anonymous struct containing the captured data.
Mental Model: Compiler-Generated Closure Struct
Imagine you write the following closure:
RUSTlet z = String::from("Hello"); let my_closure = || println!("{}", z);Under the hood, the compiler desugars this into an anonymous struct and implements the appropriate
Fntrait:RUST// Conceptual representation of the compiler-generated struct struct EnvClosure<'a> { z: &'a String, // Captured by shared reference } impl<'a> FnOnce() for EnvClosure<'a> { type Output = (); extern "rust-call" fn call_once(self, args: ()) { self.call(args) } } impl<'a> FnMut() for EnvClosure<'a> { extern "rust-call" fn call_mut(&mut self, args: ()) { self.call(args) } } impl<'a> Fn() for EnvClosure<'a> { extern "rust-call" fn call(&self, _args: ()) { println!("{}", self.z); } }
4. How Capturing Dictates Trait Implementation
The compiler automatically determines which of the Fn traits a closure implements based on how it uses the captured variables inside its body.
1. Capturing by Shared Reference (Fn)
If the closure only reads captured variables via shared references, the generated struct holds shared references. It implements Fn, FnMut, and FnOnce.
let z = String::from("Hello");
let print_closure = || println!("{}", z); // Captures `z` via `&z`
// Implements Fn, FnMut, and FnOnce
2. Capturing by Exclusive Reference (FnMut)
If the closure mutates a captured variable, it requires exclusive access. The generated struct holds an exclusive reference (&mut). It implements FnMut and FnOnce, but not Fn.
let mut z = String::from("Hello");
let mut clear_closure = || z.clear(); // Captures `z` via `&mut z`
// Implements FnMut and FnOnce (Cannot implement Fn)
3. Capturing by Value / Moving (FnOnce)
If the closure consumes or moves a captured variable out of its environment, the generated struct must take ownership of the variable. It implements only FnOnce.
let z = String::from("Hello");
let drop_closure = || drop(z); // Moves `z` into the closure
// Implements FnOnce only (Cannot implement FnMut or Fn)
5. The move Keyword and Lifetime Management
By default, Rust's compiler uses heuristics to capture variables with the minimal permissions required (shared reference exclusive reference ownership). However, you can force the closure to take ownership of its environment using the move keyword.
Why Use move?
The most common use case for move is returning a closure from a function or passing it to another thread (e.g., std::thread::spawn). Without move, the closure would capture references to variables on the local stack frame, leading to lifetime errors once the stack frame is destroyed.
fn make_fn() -> impl Fn() {
let z = String::from("Hello");
// Error: closure may outlive the current function, but it borrows `z`
// || println!("{}", z)
// Correct: moves ownership of `z` into the closure struct
move || println!("{}", z)
}
The "All-or-Nothing" Nature of move
The move keyword is an all-or-nothing modifier; it forces the closure to capture every referenced variable by value. If you want to move some variables but keep others as references, you must create explicit local bindings before defining the closure:
fn main() {
let x = String::from("Move me");
let y = String::from("Borrow me");
let y_ref = &y; // Create a reference to borrow
let closure = move || {
println!("Owned: {}", x); // x is moved
println!("Borrowed: {}", y_ref); // y_ref (the reference) is moved, effectively borrowing y
};
}
6. Dynamic Dispatch with Function Traits (dyn Fn)
Just like other traits in Rust, the Fn traits can be used behind pointers for dynamic dispatch (dyn Fn). This is useful when you want to store closures of different concrete types in a collection or pass them around without monomorphizing your code.
fn call_dynamic(f: &dyn Fn()) {
f();
}
Memory Layout of dyn Fn
A dyn Fn is a wide pointer (16 bytes on 64-bit systems) consisting of:
- A pointer to the closure's state (the anonymous struct).
- A pointer to a vtable containing the function pointer to the appropriate
callmethod.
Historical Context: FnBox and Unsized R-Values
Historically, using Box<dyn FnOnce> was incredibly difficult. To call an FnOnce, you must take ownership of self (fn call_once(self)). However, dyn FnOnce is dynamically sized (unsized). In early versions of Rust, the compiler could not pass unsized values on the stack, meaning you could not move a value out of a Box if it was behind a trait object.
To work around this, the standard library temporarily featured an experimental trait called FnBox.
With the introduction of unsized locals (allowing the compiler to allocate unsized values dynamically on the stack frame), Box<dyn FnOnce> became fully supported on stable Rust, and FnBox was deprecated.
7. Advanced Concepts: Const Closures and ~const Bounds
Rust is actively expanding its compile-time evaluation capabilities (const fn). A major challenge is allowing generic functions to run at compile time while accepting closures or function arguments.
Conditional Constness (~const)
On nightly Rust, the experimental ~const trait bound syntax allows a function to be conditionally const.
// Experimental Nightly Rust
#![feature(const_trait_impl)]
#![feature(effects)]
const fn foo<F>(f: F)
where
F: ~const FnOnce()
{
f(); // Only allowed at compile-time if F's implementation of FnOnce is also const
}
Meaning of the ~const Sigil
- It does not mean "the trait must always be const."
- It does not mean "the trait might be const" (like
?Sized). - It means: "This function is
constif and only if the concrete type's trait implementation isconst." If called at runtime, a non-const implementation is perfectly acceptable.
8. Higher-Rank Trait Bounds (HRTBs) and the for<'a> Syntax
When passing closures that accept references, you often run into situations where the closure must accept a reference of any lifetime, rather than one specific lifetime tied to the outer function. This is where Higher-Rank Trait Bounds (HRTBs) are required.
The Problem
Consider a function that processes a string slice:
// We want F to accept a reference to a string slice with any lifetime
fn process<F>(f: F)
where
F: Fn(&str)
{
let local_string = String::from("local");
f(&local_string); // The lifetime of this reference is local to this function
}
The Desugaring (for<'a>)
Under the hood, the compiler desugars the bound F: Fn(&str) using HRTBs:
fn process<F>(f: F)
where
F: for<'a> Fn(&'a str)
{
let local_string = String::from("local");
f(&local_string);
}
The syntax for<'a> Fn(&'a str) translates to: "For any lifetime 'a that the caller or callee might choose, F must implement Fn(&'a str)."
While the compiler automatically infers HRTBs for standard Fn bounds, you may occasionally need to write the for<'a> syntax explicitly when working with custom traits or complex lifetime relationships.
9. Summary and Key Takeaways
- Function Items are zero-sized types unique to each function definition, allowing direct inlining and zero runtime overhead.
- Function Pointers (
fn) are 8-byte pointers to executable code and implement all threeFntraits (Fn,FnMut,FnOnce). - Closures are represented as anonymous compiler-generated structs containing their captured environment.
- Trait Selection is automatic:
- Capturing by shared reference
Fn - Capturing by exclusive reference
FnMut - Capturing by value/moving
FnOnce
- Capturing by shared reference
- The
movekeyword forces a closure to take ownership of all captured variables, which is essential when returning closures or spawning threads. dyn Fnuses dynamic dispatch via wide pointers (data pointer + vtable pointer).Box<dyn FnOnce>is fully supported on stable Rust thanks to unsized locals.for<'a>(HRTBs) allows closures to accept references with lifetimes determined at the call site rather than at the definition site.