Callbacks

How to use Rust closures as callbacks, store them in data structures, handle ownership and lifetimes, and integrate with foreign function interfaces.

A callback is a piece of code you hand off to some system, to be invoked later when a specific event occurs. In Rust the natural tool for this job is a closure, because a closure can capture the state it needs from the surrounding scope and carry that state into whatever context eventually calls it.

But storing closures that capture different kinds of state introduces a type problem: every closure has its own unique, anonymous type. This document shows how Rust solves that problem with trait objects and the Fn family of traits, how to build your own callback registry, and how to bridge the gap when you need to hand a closure to a C library.

Storing Closures as Callbacks in a Struct

The first challenge is creating a data structure that can hold multiple callbacks, each potentially a different closure. A Vec<F> requires that every element be the exact same type F, so a vector of concrete closure types won’t work unless all the callbacks are literally the same closure.

Rust’s answer is trait objects. A Box<dyn FnMut(i32)> can hold any closure that implements FnMut(i32), regardless of the closure’s actual type, because the trait object stores both a pointer to the closure’s data and a pointer to a vtable that knows how to call it. This is exactly how you build a callback list.

1

Step 1: Define the struct

The struct holds a Vec of boxed FnMut trait objects. The i32 argument is an arbitrary choice — you’ll adapt it to whatever data your callbacks need.

pub struct Callbacks {
    callbacks: Vec<Box<dyn FnMut(i32)>>,
}
2

Step 2: Constructor and registration

A new function creates an empty vector. The register method takes a callback that is already boxed, while register_generic accepts any FnMut(i32) + 'static closure and boxes it for you.

impl Callbacks {
    pub fn new() -> Self {
        Callbacks {
            callbacks: Vec::new(),
        }
    }
    pub fn register(&mut self, callback: Box<dyn FnMut(i32)>) {
        self.callbacks.push(callback);
    }
    pub fn register_generic<F: FnMut(i32) + 'static>(&mut self, callback: F) {
        self.callbacks.push(Box::new(callback));
    }
}

The 'static bound is essential. It means the closure cannot borrow data that might expire before the Callbacks struct is dropped. Omitting 'static would let you store a closure that holds a reference to a local variable, and when that variable goes out of scope the reference becomes dangling — the compiler would reject it.

3

Step 3: Calling all callbacks

When the event fires, you need to invoke every stored callback. Because FnMut requires a mutable reference to the closure’s environment, the iteration must be mutable.

pub fn call(&mut self, val: i32) {
    for callback in self.callbacks.iter_mut() {
        // `callback` is &mut Box<dyn FnMut(i32)>
        // Dereference the Box to get the FnMut, then call it.
        (*callback)(val);
    }
}

Rust’s auto-deref rules let you write callback(val) directly in many cases, but the explicit (*callback)(val) shows what’s happening: you’re calling the FnMut::call_mut method on the inner trait object.

Trait objects are fat pointers:

A Box<dyn FnMut(i32)> is not just a pointer to data; it’s a fat pointer containing two pieces: a pointer to the closure’s environment (the captured variables) and a pointer to a vtable that has the address of the call_mut function. The compiler uses that vtable to dispatch the correct code at runtime, which is why you can mix closures of different types in one Vec.

Generic Registration vs Trait Object Registration

You now have two ways to add a callback: register takes an already-boxed trait object, and register_generic takes a concrete closure type. The choice is not just syntactic.

register_generic<F> is a generic function. Rust monomorphizes it: for each distinct closure type you pass, the compiler generates a separate copy of register_generic that knows exactly how to box that particular type. This gives you static dispatch inside the function itself, but the stored callback is still a trait object called through dynamic dispatch later.

register accepts any Box<dyn FnMut(i32)> directly. If you already have a trait object — perhaps produced by some factory — you can use this method without monomorphization.

Which one to prefer? In most cases register_generic is more convenient because it lets callers pass closures directly without manually boxing them. The monomorphization cost is negligible unless you’re registering thousands of different closure types.

The 'static Bound and Why It Matters

When you write FnMut(i32) + 'static, you’re saying: “This closure does not borrow anything that could disappear before the end of the program.” Without it, a closure could capture a reference to a local variable, and if you stored the closure in a Callbacks struct that outlives that variable, you’d have a dangling reference. The compiler catches this at compile time, but only if you tell it what to look for.

let mut callbacks = Callbacks::new();
{
    let message = String::from("hello");
    // This won't compile: `message` does not live long enough.
    callbacks.register_generic(|val| println!("{}: {}", message, val));
} // message dropped here, but callbacks lives on

Use move to transfer ownership:

If your closure needs data that lives on the stack, use the move keyword to take ownership of that data instead of borrowing it. A move closure that owns all its data automatically satisfies 'static — as long as the owned types themselves are 'static.

let message = String::from("hello");
callbacks.register_generic(move |val| println!("{}: {}", message, val));

Reentrancy and Interior Mutability

A callback might need to modify shared state, or even register new callbacks. If a callback tries to call call again on the same Callbacks instance, that’s reentrancy. With a plain &mut self on call, this is impossible because the borrow checker sees the mutable borrow is already held. But if you use interior mutability — like RefCell — the compiler can’t stop you at compile time; instead the program will panic at runtime when the RefCell detects a second mutable borrow.

use std::cell::RefCell;
use std::rc::Rc;
let callbacks = Rc::new(RefCell::new(Callbacks::new()));
let callbacks_clone = Rc::clone(&callbacks);
callbacks.borrow_mut().register_generic(move |val| {
    println!("Callback called with {}", val);
    // This will panic because `call` tries to borrow_mut again.
    callbacks_clone.borrow_mut().call(val);
});

Reentrant call panics at runtime:

A RefCell that is already mutably borrowed cannot be borrowed again, even from the same thread. If a callback triggers a nested call to call, the program panics with already borrowed: BorrowMutError. There is no compile-time check — the guard is at runtime only. If you need reentrant callbacks, consider a different design, such as collecting pending events and processing them after the current call completes.

Closures as C Callbacks (FFI)

C APIs often accept function pointers of the form extern "C" fn(...), with no slot for a context pointer. A Rust closure, which bundles both code and captured environment, cannot be passed as a bare function pointer because the C side has nowhere to store the captured data.

When the C API does allow a user‑data pointer, you can bridge the gap with a trampoline: an extern "C" function that receives the user‑data pointer, casts it back to a closure reference, and invokes it. The closure itself must be kept alive for as long as the callback could be called, so you usually allocate it on the heap.

use std::ffi::c_void;
extern "C" fn trampoline(x: i32, user_data: *mut c_void) -> bool {
    let closure: &mut &mut dyn FnMut(i32) -> bool = unsafe { &mut *(user_data as *mut _) };
    closure(x)
}
pub fn set_callback<F>(callback: F)
where
    F: FnMut(i32) -> bool + 'static,
{
    // Double indirection: Box<Box<dyn FnMut>> is needed to get a thin *mut c_void.
    let boxed: Box<Box<dyn FnMut(i32) -> bool>> = Box::new(Box::new(callback));
    let raw = Box::into_raw(boxed) as *mut c_void;
    unsafe { ffi_register_callback(trampoline, raw); }
}

The double boxing (Box<Box<dyn FnMut>>) is not an accident. A Box<dyn FnMut> is a fat pointer (two words: data pointer and vtable pointer), but *mut c_void is a thin pointer (one word). Wrapping it in another Box gives you a thin pointer to a heap allocation that contains the fat pointer.

The closure-ffi crate handles the heavy lifting:

If you need to pass closures to C functions that provide no context pointer at all, the closure-ffi crate generates thin trampolines at runtime by copying a monomorphized thunk into executable memory. It handles the platform‑specific calling conventions and manages the lifetime of the closure. This is a safe (if complex) alternative to writing unsafe trampoline code by hand.

Common Mistakes

Forgetting move when the closure outlives borrowed data.
If you store a closure that captures a reference, the compiler will complain with a lifetime error. The fix is usually move, but remember that after a move closure takes ownership, the original variable is no longer usable unless it implements Copy.

Omitting the 'static bound on a generic registration method.
The compiler will infer that F must outlive the callbacks container, which often leads to confusing “does not live long enough” errors. Always add + 'static when you store a closure indefinitely.

Trying to store a closure that itself holds a reference to the Callbacks struct.
This creates a cycle. If you use Rc<RefCell<Callbacks>> to break ownership cycles, reentrancy panics will bite you. Rethink the design instead.

Using FnOnce when you need to call the callback multiple times.
FnOnce closures consume themselves when called. Once stored in a Vec<Box<dyn FnOnce(i32)>>, you can call each one at most once, and calling any of them requires moving it out of the vector. FnMut is the correct choice for repeatedly‑callable stored callbacks.

Confusing fn pointers with closures.
A fn(i32) is a function pointer — it can point to a named function or a non‑capturing closure (one that doesn’t capture any variables). It cannot hold a capturing closure. Use a trait object when you need to capture state.

Avoid using fn pointers for capturing closures:

If you see the error “expected fn pointer, found closure” when passing a capturing closure to a function that expects a fn type, you need either a trait object or a generic bound like F: FnMut(i32). Function pointers carry no environment.

Summary

Callbacks in Rust are closures placed where they can be called later — in a registry, an event loop, or across an FFI boundary. Trait objects (Box<dyn FnMut>) solve the heterogeneous type problem, the 'static bound keeps memory safe, and move transfers ownership when a closure needs to outlive the scope that created it. For C interop, trampolines bridge the gap between Rust’s rich closures and C’s bare function pointers, while crates like closure-ffi automate the hardest parts.

When you choose between a generic callback parameter and a trait object, the decision is between monomorphization and dynamic dispatch — but the stored callbacks themselves are always trait objects, so the runtime cost of the virtual call is the price of flexibility.