Callbacks in Rust: From Pain to Elegance Through Idioms

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Introduction: Why Callbacks in Rust Are a Challenge (and an Opportunity)

\n

If you've ever written code in JavaScript, Python, or C#, callbacks are a familiar tool for you. You pass a function that will be called later, and you live peacefully. But when you come to Rust, this simple pattern suddenly turns into a minefield: ownership, borrowing, lifetimes, dyn vs impl...

\n

On Stack Overflow, the question "Idiomatic callbacks in Rust" has gathered thousands of views. This means even experienced developers stumble on this topic. But the good news is: Rust offers not one, but several elegant ways to implement callbacks. And today we'll break them all down — from simple closures to powerful trait objects and enum patterns.

\n

This article is for you if you:

\n
    \n
  • Have started learning Rust and want to understand how to properly organize asynchronous and event-driven architectures;
  • \n
  • Are transitioning from other languages and looking for idiomatic equivalents to familiar patterns;
  • \n
  • Want to write clean, safe, and performant code in Rust.
  • \n
\n

Let's go!

\n\n

The Problem: What's Wrong with Simple Callbacks?

\n

Imagine we are writing a simple task scheduler. In Python, it would look like this:

\n
def schedule(callback):\n    # do something\n    callback(result)
\n

In Rust, the first attempt might be:

\n
fn schedule(callback: fn(i32) -> i32) {\n    let result = 42;\n    callback(result);\n}
\n

And this works! But only for functions, not closures. Try passing a closure that captures a variable:

\n
let factor = 2;\nlet closure = |x| x * factor;\nschedule(closure); // Error! expected fn pointer, found closure
\n

The compiler rightly complains: fn is a function pointer, but a closure is an anonymous type that can capture its environment. We need a more flexible approach.

\n\n

Approach 1: Closures with impl Fn (Simplicity and Flexibility)

\n

The simplest and most commonly used way is to use a generic type with the Fn, FnMut, or FnOnce trait.

\n

When to use Fn, FnMut, FnOnce?

\n
    \n
  • FnOnce — if the callback is called exactly once (consumes captured values);
  • \n
  • FnMut — if the callback can modify captured state (called multiple times);
  • \n
  • Fn — if the callback does not modify state (pure function).
  • \n
\n

Example with impl Fn:

\n
fn schedule<F>(callback: F)\nwhere\n    F: Fn(i32) -> i32,\n{\n    let result = 42;\n    println!("Result: {}", callback(result));\n}\n\nfn main() {\n    let factor = 2;\n    let closure = |x| x * factor;\n    schedule(closure); // Works!\n    \n    // You can also pass a regular function\n    fn double(x: i32) -> i32 { x * 2 }\n    schedule(double);\n}
\n

Pros: maximally flexible, supports closures, safe, readable.
\nCons: monomorphization — for each closure type, the compiler generates a separate version of the function. If there are many callbacks, code can bloat.

\n\n

Approach 2: Trait Objects Box<dyn Fn> (Dynamic Dispatch)

\n

If you want to store callbacks in collections (e.g., in a Vec) or pass them between threads, generics won't work — you need a single type. Trait objects come to the rescue:

\n
type Callback = Box<dyn Fn(i32) -> i32>;\n\nstruct Scheduler {\n    callbacks: Vec<Callback>,\n}\n\nimpl Scheduler {\n    fn new() -> Self {\n        Scheduler { callbacks: Vec::new() }\n    }\n    \n    fn register(&mut self, callback                

Blogs

Book Recommendations