Skip to main content

Command Palette

Search for a command to run...

Rust Closures: Functions as First-Class Values

Updated
โ€ข8 min readโ€ขView as Markdown
Rust Closures: Functions as First-Class Values
A

Olamide is my name. I am a blockchain/Frontend developer with experience building smart contracts on both EVM (Solidity) and non-EVM compatible blockchains.

๐Ÿ˜„ Pronouns: He/Him. ๐Ÿ“ซ How to reach me on handles

I'm a full-stack blockchain developer crafting the next generation of blockchain experiences with ReactJS and Solidity. I'm not just a developer; I'm a trailblazer, obsessed with pushing the boundaries of what's possible in this transformative space.

I'm not just about code, I'm about impact. I've built smart contracts that shatter the status quo, empower users, and scale without compromise. My playground? EVM-compatible blockchains and L2 solutions, where I ensure trust, security, and groundbreaking performance into every line of code.

Introduction

Closures are one of Rust's most elegant features. They're functions that capture their environment, they remember values from the scope where they were defined.

But closures are also where ownership, borrowing, and move semantics collide. Understanding closures means understanding how Rust balances flexibility with safety.

In this guide, you'll learn:

  • What closures are and why they matter

  • How closures capture variables

  • The three closure traits: Fn, FnMut, FnOnce

  • Move semantics with closures

  • Returning closures and higher-order functions

  • Real-world patterns with iterators

By the end, closures will feel natural, not mysterious.

Concept Overview

Closures Explained Simply

A closure is a function that remembers values from its environment. It "closes over" variables in the surrounding scope.

let x = 5;

let add = |y| x + y;  // Closure that remembers x
println!("{}", add(3));  // 8 - uses captured x

Regular functions can't do this:

fn add_regular(y: i32) -> i32 {
    x + y  // โœ— ERROR - x is not in scope
}

Closures capture variables. Functions don't.

Why Closures Matter

Closures enable functional programming patterns:

// Map - transform each element
vec![1, 2, 3].iter().map(|x| x * 2).collect()

// Filter - keep matching elements
vec![1, 2, 3, 4].iter().filter(|&x| x > 2).collect()

// Callbacks - execute when event happens
button.on_click(|| println!("Clicked!"));

Technical Explanation

Closure Syntax

|parameters| body

// Examples:
|x| x + 1
|x, y| x + y
|x: i32| -> i32 { x * 2 }

Variable Capture

Closures capture variables in three ways:

1. By Reference (Borrowing):

let x = 5;
let add = |y| x + y;  // Captures &x

2. By Mutable Reference:

let mut x = 5;
let mut increment = || {
    x += 1;
    x
};

3. By Value (Move):

let x = String::from("hello");
let print = move || println!("{}", x);  // Takes ownership

The Three Closure Traits

Fn - Borrows immutably, can be called multiple times:

let x = 5;
let read = || x;  // Borrows &x
read();
read();  // Can call multiple times

FnMut - Borrows mutably, can modify captured variables:

let mut x = 5;
let mut modify = || x += 1;
modify();
modify();  // Can call multiple times, modifies x

FnOnce - Takes ownership, can only be called once:

let x = String::from("hello");
let use_once = move || println!("{}", x);
use_once();
// use_once();  // โœ— ERROR - x was moved

Code Examples

Example 1: Basic Closure

fn main() {
    let x = 5;
    let y = 10;
    
    let add = |a, b| a + b + x + y;
    
    println!("{}", add(1, 2));  // 1 + 2 + 5 + 10 = 18
}

Explanation: Closure captures x and y from environment.

Expected Behavior: Prints "18".

Best Practice: Type-annotate for clarity if needed.

Example 2: Closure with move Semantics

fn main() {
    let name = String::from("Alice");
    
    let greet = move || {
        println!("Hello, {}", name);
    };
    
    greet();
    // println!("{}", name);  // โœ— name was moved into closure
}

Explanation: move keyword transfers ownership to closure.

Expected Behavior: Prints "Hello, Alice".

Best Practice: Use move when passing closures to threads or async tasks.

Example 3: FnMut - Mutable Closure

fn main() {
    let mut counter = 0;
    
    let mut increment = || {
        counter += 1;
        counter
    };
    
    println!("{}", increment());  // 1
    println!("{}", increment());  // 2
    println!("{}", increment());  // 3
}

Explanation: Closure borrows counter mutably and modifies it.

Expected Behavior: Prints 1, 2, 3.

Best Practice: Mutable closures are useful for stateful operations.

Example 4: Passing Closures to Functions

fn apply_operation<F>(x: i32, y: i32, op: F) -> i32
where
    F: Fn(i32, i32) -> i32,
{
    op(x, y)
}

fn main() {
    let add = |a, b| a + b;
    let multiply = |a, b| a * b;
    
    println!("{}", apply_operation(5, 3, add));        // 8
    println!("{}", apply_operation(5, 3, multiply));   // 15
}

Explanation: Generic functions accept closures with trait bounds.

Expected Behavior: Prints "8" and "15".

Best Practice: Use trait bounds (F: Fn(...)) for generic closure parameters.

Example 5: Iterators with Closures

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    
    let result: Vec<i32> = numbers
        .iter()
        .filter(|&&x| x > 2)
        .map(|&x| x * 2)
        .collect();
    
    println!("{:?}", result);  // [6, 8, 10]
}

Explanation: Closures power iterator combinators.

Expected Behavior: Prints "[6, 8, 10]".

Best Practice: Use iterator methods with closures for functional style.

Common Errors

Error 1: Moved Value in Closure

let s = String::from("hello");
let closure = || println!("{}", s);
closure();
println!("{}", s);  // โœ— ERROR - closure moved s

How to Fix:

Use reference if possible:

let s = String::from("hello");
let closure = || println!("{}", s);
closure();
println!("{}", s);  // โœ“ s not moved

Or use move intentionally:

let s = String::from("hello");
let closure = move || println!("{}", s);
closure();
// Don't use s after this

Error 2: FnMut vs Fn

let mut x = 5;
let increment = || x += 1;

let callbacks: Vec<Box<dyn Fn()>> = vec![
    Box::new(increment),  // โœ— increment is FnMut, not Fn
];

How to Fix: Use FnMut trait:

let mut x = 5;
let increment = || x += 1;

let callbacks: Vec<Box<dyn FnMut()>> = vec![
    Box::new(increment),  // โœ“ Works
];

Error 3: Capturing by Reference Too Long

fn get_closure() -> Box<dyn Fn() -> String> {
    let s = String::from("hello");
    Box::new(|| s.clone())  // โœ— ERROR - s is dropped after function
}

How to Fix: Use move to take ownership:

fn get_closure() -> Box<dyn Fn() -> String> {
    let s = String::from("hello");
    Box::new(move || s.clone())  // โœ“ Works
}

Performance & Memory Insights

Closure Size

let x = 5;
let small = || x;  // Zero-sized closure (just a reference)

let s = String::from("hello");
let large = move || s.len();  // Closure size = String size

Closures capturing by reference: Zero overhead (just store references). Closures with move: Store captured values, larger size.

Inline vs Trait Objects

// Inlined - fast, monomorphized
fn process<F: Fn(i32) -> i32>(f: F) {
    f(42);
}

// Dynamic - slower, runtime dispatch
fn process(f: &dyn Fn(i32) -> i32) {
    f(42);
}

Real-World Use Cases

Use Case 1: Iterator Chains

let data = vec![1, 2, 3, 4, 5];
let result = data
    .iter()
    .filter(|&x| x % 2 == 0)
    .map(|&x| x * x)
    .sum::<i32>();

Use Case 2: Event Handlers

button.on_click(|| {
    println!("Button clicked");
    update_ui();
});

Use Case 3: Async Callbacks

tokio::spawn(async {
    let result = fetch_data().await;
    callback(result);
});

Best Practices

1. Prefer Closures Over Named Functions for Callbacks

// โœ“ Closure - captures environment
vec![1, 2, 3].iter().map(|x| x * multiplier).collect()

// โœ— Function - can't capture
fn double(x: i32) -> i32 { x * 2 }

2. Use Type Annotations When Unclear

// โœ“ Clear
let add: Box<dyn Fn(i32, i32) -> i32> = Box::new(|x, y| x + y);

// โœ— Confusing
let add = |x, y| x + y;

3. Use move for Thread/Async Operations

// โœ“ Correct
std::thread::spawn(move || {
    println!("{}", captured_value);
});

// โœ— Compile error - can't borrow across thread boundary
std::thread::spawn(|| {
    println!("{}", captured_value);
});

Beginner Mistakes

Mistake 1: Forgetting move for Lifetimes

// โœ— References don't cross boundaries
std::thread::spawn(|| use_reference());

// โœ“ Move ownership
std::thread::spawn(move || use_owned_value());

Mistake 2: Overcomplicating Closure Syntax

// โœ— Too verbose
let add = |x: i32, y: i32| -> i32 { x + y };

// โœ“ Simpler
let add = |x, y| x + y;

Mistake 3: Confusing Closure Traits

// Fn = read-only
// FnMut = read-write
// FnOnce = consume

Advanced Insights

Higher-Ranked Trait Bounds

fn apply<F>(f: F)
where
    F: for<'a> Fn(&'a str) -> &'a str,
{
    f("hello");
}

Returning Closures

fn make_multiplier(factor: i32) -> impl Fn(i32) -> i32 {
    move |x| x * factor
}

Conclusion

Closures are powerful tools for functional programming. They capture environment, enable callbacks, and make iterator chains elegant.

Key Takeaways:

  • Closures capture variables from their environment

  • Three traits: Fn (borrow), FnMut (borrow mut), FnOnce (move)

  • Use move for thread/async operations

  • Closures enable iterator chains and callbacks

Next Steps:

  • Practice with iterator combinators

  • Build callbacks and event handlers

  • Learn about higher-order functions

  • Explore functional programming patterns

Closures unlock Rust's functional programming capabilities. Master them, and your code becomes more expressive.