Skip to main content
  1. Rust/

Closures in Rust: Functions with their own Memory

1414 words·7 mins· loading · loading · · ·Rust-middle
about-rust - This article is part of a series.
Articles read 0/1
0%
🔵 Intermediate level (Rust-middle)
26 Closures in Rust: Functions with their own Memory (current)
Unread

Introduction
#

Closures (or lambda expressions) are anonymous functions that we can create on the fly and pass to other functions. But their main superpower is the ability to capture environment, meaning they remember variables from the scope where they were declared.

In programming languages with automatic garbage collection (GC), developers don’t even think about how closures store these variables. But in Rust, where memory is managed by the compiler, the rules for closures are governed by strict ownership and borrowing semantics.

In this article, we will break down how closures work in Rust, how they differ from ordinary functions, how they are divided into Fn, FnMut, and FnOnce traits, and how the compiler generates them “under the hood.”


1. Function Pointers (fn)
#

Before we talk about closures, let’s recall ordinary functions.

In Rust, a function’s name can act as a pointer to it. The type of such a pointer is written in lowercase: for example, fn(i32) -> i32.

  • How it is structured in memory:
    • The fn pointer is a “thin” pointer. It stores only one address — the address of the first instruction of the compiled code in memory.
    • Such functions have no state. They cannot look at outer variables or read anything that wasn’t passed to them in the arguments.

2. Closures: Functions with a backpack
#

A closure is an anonymous function that has an invisible “backpack” (context) behind its back, where it can put external variables.

let coefficient = 10;
let multiply = |x| x * coefficient; // The closure has captured coefficient!

Closure arguments are written inside two vertical bars |...|, followed by the body. Curly braces are only needed for multi-line code.

Type Inference
#

The Rust compiler automatically infers what data types the closure accepts and returns, analyzing the place of its first invocation. However, if you want, you can specify the types manually:

let multiply = |x: i32| -> i32 { x * coefficient };

Capturing Environment and the move keyword
#

  • By default (by reference): The closure tries to be as efficient as possible. It doesn’t copy variables; it borrows them. The compiler decides what kind of reference is needed: a shared reference for reading (&T) or a mutable reference for writing (&mut T).
  • Forced move (move): If the closure must outlive the surrounding code (for example, we return it from a function or pass it to another thread), we need to move ownership of the variables inside the closure. For this, write the keyword move before the vertical bars:
    let print_data = move || println!("Data: {}", data);

3. Three Closure Traits: Fn, FnMut, and FnOnce
#

Depending on what exactly the closure does with the variables in its “backpack,” it automatically implements one of three traits:

graph TD
    FnOnce --> FnMut
    FnMut --> Fn
    
    style FnOnce fill:#f9f,stroke:#333,stroke-width:2px,color:#000
    style FnMut fill:#ccf,stroke:#333,stroke-width:1px,color:#000
    style Fn fill:#cfc,stroke:#333,stroke-width:1px,color:#000
  1. FnOnce (The Consumer)
    • What it does: Takes captured variables by value and consumes them (destroys or passes them on).
    • Limitation: Since the data is consumed on the first call, you can call such a closure only once.
  2. FnMut (The Mutator)
    • What it does: Modifies captured variables (uses an exclusive reference &mut self).
    • Limitation: Can be called any number of times, but only sequentially (cannot run concurrently from different threads without synchronization).
  3. Fn (The Reader)
    • What it does: Only reads data (uses a shared reference &self).
    • Limitation: No limitations! Can be called repeatedly and concurrently from different threads.

Trait Hierarchy
#

They form a nested structure:

  • Any closure of type Fn automatically knows how to work as FnMut and FnOnce.
  • Regular function pointers (fn) have no state, so they always implement the top trait Fn.

4. How Closures Work in Code
#

Passing to Functions
#

Since the exact type of a closure is anonymous, we use generics or impl Trait:

fn execute(mut action: impl FnMut()) {
    action();
}

Returning from Functions
#

You cannot return a closure directly — the compiler doesn’t know its exact size. Therefore, we return it via impl Trait with the mandatory move keyword (to take the captured variables with us):

fn create_adder(x: i32) -> impl Fn(i32) -> i32 {
    move |y| x + y
}

Storing in Structs
#

If a struct needs to store a closure, we have two ways:

  1. Generics (fast): The struct is statically bound to the closure type at compile time. No overhead.
    struct Runner<F: Fn()> { action: F }
  2. Box (flexible): The closure is hidden in the heap under a dynamic pointer. Allows storing different closures in the same struct type.
    struct Runner { action: Box<dyn Fn()> }

5. What happens under the hood?
#

Closures in Rust are just syntactic sugar. Under the hood, the compiler turns each closure into a regular structure:

  1. An anonymous struct is created, whose fields are the captured variables.
  2. The implementation of the call (methods of traits call, call_mut, or call_once) is generated for this structure.
  3. At the site of the closure call, the compiler simply substitutes a regular method call of this structure.

6. Lifetimes on the fly (HRTBs)
#

Sometimes a complex situation arises: a closure accepts a reference to an object, but the lifetime of this reference is born and dies right inside the calling function. In such cases, the closure must be able to work with references of any lifetime.

For this, Higher-Rank Trait Bounds (or HRTBs) syntax is used via the for<'a> keyword:

fn run_with_ref<F>(f: F)
where F: for<'a> Fn(&'a str) 
{
    let local = String::from("data");
    f(&local); // The lifetime of the reference is limited only to this function!
}

The construction for<'a> Fn(&'a str) tells the compiler: “This closure knows how to accept a string reference with absolutely any lifetime 'a that we give it at the moment of call.”

Let’s see how closures of different types work in the interactive examples below:

Closures in Rust
Step 1/4
 1fn call_with_five(f: fn(i32) -> i32) {
 2    println!("Вызов fn pointer: {}", f(5));
 3}
 4
 5fn main() {
 6    // 1. Указатель на обычную функцию
 7    fn add_one(x: i32) -> i32 { x + 1 }
 8    call_with_five(add_one);
 9    
10    // 2. Замыкание с захватом внешнего окружения
11    let coefficient = 10;
12    let closure = |x| x * coefficient;
13    
14    // call_with_five(closure); // Ошибка! Замыкание с состоянием не приводится к fn pointer.
15    println!("Вызов замыкания: {}", closure(5));
16}
17

Указатели на функции и замыкания

  • Указатель на функцию fn является “тонким” указателем: он хранит только адрес машинного кода.
  • Замыкание (анонимная функция) может захватывать внешние переменные и нести с собой состояние.
  • Замыкания получают уникальный анонимный тип, генерируемый компилятором.

Check Your Knowledge!
#

Take a short interactive test to reinforce your understanding of closures in Rust.

Changelog

  • Created new article about closures in Rust, their types (Fn, FnMut, FnOnce), low-level implementation, and HRTBs with code examples and interactive tests.
Article read
Please rate how helpful and clear this article was to you
Цикл статей
about-rust - This article is part of a series.
Articles read 0/1
0%
🔵 Intermediate level (Rust-middle)
26 Closures in Rust: Functions with their own Memory (current)
Unread