The Hidden Rules Behind Rust Functions & Closures
Demystifying fn, Fn, FnMut, and FnOnce from everyday usage to compiler magic under the hood
Rust is famous for its steep learning curve for a reason. Besides the borrow checker, lifetime, and many other concepts, functions and closures are among the most confusing concepts to grasp for Rust newcomers. Take a look:
Example 1: Function and Closure Confusion
process_data expects a function that takes an i32 and returns an i32. Both closures match this signature exactly. The first closure |x| x + 1 works perfectly, but the second closure |x| x * multiplier fails. What's the difference?
Error: error[E0308]: mismatched types (expected fn pointer, found closure)
Example 2: Variable Capturing Confusion
Both closures use data, but one works multiple times while the other stops working after the first call. What's the difference?
Error: error[E0382]: use of moved value: 'closure2'
If you've encountered any of these scenarios, you're not alone. In this post, I'll:
Explain what functions and closures (with their traits) really are
Show the relationships between them
Dive into the internals that explain these confusing behaviors
By the end, you'll have mastered one of Rust's most difficult topics inside out. Let's go.
Function & Function Pointer
Let's start by examining how Rust handles regular functions. There are some surprising details about function types that most developers never encounter.
Function
A function in Rust is exactly what you'd expect:
Simple enough. But here's where it gets interesting: when you mention a function by name like add_one, you don't get a pointer to it. Instead, you get a special zero-sized value that represents that exact function, and calling it is a direct call. This zero-sized value is called a function item.
Function Item
Every function you define creates a unique, zero-sized type called a function item. This type is compiler-generated and cannot be named directly in your code:
f1 and f2 have different function item types even though they have identical function signatures. When you assign add_two to f1 at the end of the example, you get a type mismatch error because of that.
This unique typing enables powerful compiler optimizations. Since the compiler knows exactly which function will be called at compile time, it can:
Inline the function call - Replace the call with the function's body directly
Use static dispatch - No runtime lookup needed to find the function
Apply aggressive optimizations - Dead code elimination, constant folding, etc.
For example, f1(x) gets replaced by x + 1 directly, eliminating the function call overhead entirely.
Function Pointer
So how do we store different functions in the same variable? We convert them to function pointers:
Now, we explicitly tell the compiler that we want function pointers fn(i32) -> i32 for f1 and f2. Rust coerces add_one and add_two (function items) to function pointers and assigns them to f1 and f2. You can see at the end that I am able to assign both add_two and even f2 to f1 just fine, unlike the previous example.
The trade-off is that function pointer calls use dynamic dispatch (indirect calls through the pointer), which typically prevents inlining unlike direct calls from function items, but we gain flexibility.
Closure & Trait
Now let's understand closures and why they're different from functions.
Closure
A closure is an anonymous function that can capture variables from its surrounding environment:
The key difference: closures can access variables from their enclosing scope, while regular functions cannot.
The Three Closure Traits
Closures don't all capture variables in the same way. The manner of capture depends on what the closure does with those variables, and this directly determines which traits the closure implements.
The Rust compiler automatically assigns one of three traits to each closure based on how it uses captured variables:
FnOnce - Moves Captured Variables
Once data is dropped, calling the closure again would attempt to drop the same memory twice, leading to undefined behavior. Rust prevents this by making such closures callable only once.
FnMut - Mutates Captured Variables
Fn - Only Reads Captured Variables
Note about the move keyword: Sometimes you'll see closures with the move keyword, which forces the closure to take ownership of captured variables. However, move doesn't always mean FnOnce. If the captured variables aren't dropped or consumed, the closure can still be called multiple times and won't be FnOnce.
Trait Hierarchy
Let's look at a function that accepts an FnOnce closure:
This function promises to call the closure only once. That's the only restriction it places on the closure.
Now, if you have an Fn closure that can be called multiple times, can you pass it to execute_once? Absolutely! A closure that can be called multiple times can certainly be called just once. The same logic applies to FnMut closures - they can be called multiple times, so they can also be called once.
This creates a hierarchy: Fn can be used anywhere, FnMut works where FnMut or FnOnce is needed, and FnOnce only works where FnOnce is required.
Alright, that's a lot of rules to remember about functions and closures. Remembering these rules is good, but we'll quickly forget them. That's why it's much better to understand what happens behind the scenes. Let's dive into how Rust actually implements closures.
Closure Behind the Scene
How Rust Desugars Closures
When you write a closure, the Rust compiler internally transforms it into a compiler-generated anonymous struct with trait implementations:
The compiler conceptually transforms this closure into something like:
In this transformation, the original data variable is moved into the ClosureEnvironment struct. When the closure is called, it consumes itself (self) and drops the moved data. This is why the closure can only be called once. After the first call, both the closure struct and its captured data are consumed.
The same idea applies for Fn and FnMut. Rust transforms every closure into a compiler-generated struct with the appropriate trait implementation based on how it uses captured variables.
The compiler chooses the least intrusive capture mode it can. For each captured variable, it starts with the most permissive option (immutable borrow) and only escalates to more restrictive modes (mutable borrow, then move) when the closure's body requires it.
Why the Trait Hierarchy Works
Earlier we saw that you can pass an Fn closure where FnOnce is expected, but how is this possible when they're different traits? The answer lies in how the traits are defined.
The three closure traits form a hierarchy through trait inheritance:
Notice the : FnOnce<Args> and : FnMut<Args> syntax, this means:
Every type that implements FnMut must also implement FnOnce
Every type that implements Fn must also implement both FnMut and FnOnce
Relation Between Function Pointer & Closure Trait
As we saw earlier in Example 1, sometimes we can pass closures to functions expecting function pointers, but sometimes we can't. Let's understand why.
Non-Capturing Closures Become Function Pointers
When a closure doesn't capture any variables, it doesn't need the hidden struct we saw earlier. Since there's no captured state to store, Rust can coerce such closures directly to plain function pointers:
But as soon as the closure captures variables, this coercion is no longer possible:
Note: There are additional restrictions (like the closure being non-async), but we won't cover those here.
Function Pointers Implement All Closure Traits
Functions are essentially like non-capturing closures, they have no state and can be called repeatedly without side effects. Therefore, function pointers implement all three closure traits (Fn, FnMut, and FnOnce):
This bidirectional compatibility (non-capturing closures can become function pointers, and function pointers can be used as closures) creates a seamless integration between Rust's function and closure systems.
Key Takeaways
Let's summarize what we've learned about Rust's function and closure system:
Function Items vs Function Pointers: Every function creates a unique, zero-sized, compiler-generated type that enables powerful optimizations through static dispatch. Function pointers use dynamic dispatch but allow storing different functions in the same variable.
Closure Capture Modes: Closures are categorized by how they capture variables. FnOnce moves captured variables, FnMut mutates them, and Fn only reads them. The compiler chooses the least intrusive capture mode possible.
Trait Hierarchy: The closure traits form a hierarchy through supertraits where Fn extends FnMut, which extends FnOnce. This means an Fn closure can be used anywhere FnMut or FnOnce is expected.
Compiler-Generated Structs: Closures are transformed into anonymous structs that hold captured variables and implement the appropriate closure traits.
Seamless Integration: Non-capturing closures can be coerced to function pointers, while function pointers implement all closure traits. This creates bidirectional compatibility between functions and closures.
If you made it this far, you're probably as obsessed with understanding how things really work as I am. I'm Cuong, and I write about Rust and programming. If you share the same passion, I'd love to connect with you. Feel free to reach out on X, LinkedIn, or subscribe to my blog (substack, medium) to keep pushing the boundaries together!