Skip to main contentClosures and Callbacks | IoT Worker

Closures and Callbacks

C callbacks usually look like a function pointer plus a context pointer:

typedef void (*event_cb_t)(void *ctx, int event);

void register_event(event_cb_t cb, void *ctx);

cb is the function to call, and ctx is the environment passed back during the call. This pattern is mature, but the old problems remain:

  • the real type of ctx is a convention
  • the lifetime of ctx is a convention
  • whether the callback mutates the environment is a convention
  • whether the callback is called once or many times is a convention
  • thread-safety requirements are a convention

Rust closures solve the same problem: keep the code to run together with the environment it needs.

let prefix = String::from("event");
let callback = |event: i32| {
    println!("{prefix}: {event}");
};

callback is not just a function. It captures the outer variable prefix.

A Closure Is a Function with Environment

A normal function cannot capture local variables:

fn print_event(event: i32) {
    println!("{event}");
}

A closure can:

let name = String::from("sensor");

let print_event = |event: i32| {
    println!("{name}: {event}");
};

print_event(1);

From a C perspective, the first model is:

closure = function pointer + compiler-generated ctx

The difference is that Rust knows what is inside that ctx, and whether it is shared-borrowed, mutably borrowed, or moved into the closure.

Fn: Read-Only Capture, Callable Many Times

If a closure only reads captured variables, it usually implements Fn:

fn call_twice<F>(callback: F)
where
    F: Fn(i32),
{
    callback(1);
    callback(2);
}

fn main() {
    let name = String::from("sensor");

    call_twice(|event| {
        println!("{name}: {event}");
    });
}

Fn(i32) means the callback can be called through shared access. It can be called many times and does not need to mutate its captured environment.

This is similar to a C callback that treats ctx as read-only:

void on_event(void *ctx, int event)
{
    const struct Config *config = ctx;
    printf("%s %d\n", config->name, event);
}

Rust puts “read-only use of the environment” into the closure trait.

FnMut: The Callback Mutates Captured State

If a closure mutates captured variables, it usually implements FnMut:

fn call_events<F>(mut callback: F)
where
    F: FnMut(i32),
{
    callback(1);
    callback(2);
}

fn main() {
    let mut count = 0;

    call_events(|event| {
        count += 1;
        println!("event {event}, count {count}");
    });
}

The callback parameter is mut because calling FnMut may mutate the closure’s own captured environment.

C often writes the same idea like this:

struct Counter {
    int count;
};

void on_event(void *ctx, int event)
{
    struct Counter *counter = ctx;
    counter->count++;
}

The Rust difference is that mutable capture follows borrowing rules. While the closure holds a mutable borrow of count, outside code cannot freely mutate count at the same time.

FnOnce: The Callback Consumes Captured Values

If a closure moves captured values out, it can only be called once and usually implements FnOnce:

fn call_once<F>(callback: F)
where
    F: FnOnce(),
{
    callback();
}

fn main() {
    let message = String::from("done");

    call_once(|| {
        println!("{message}");
        drop(message);
    });
}

message is moved into the closure and consumed during the call. This closure cannot be called a second time.

From a C point of view, this is like a one-shot callback that takes ownership of ctx and releases it. Rust expresses “call only once” in the type system.

move Closures Take Captured Values

By default, a closure borrows or moves outer variables as needed. You can write move to force captured values into the closure:

let name = String::from("worker");

let callback = move || {
    println!("{name}");
};

move is common for threads, async tasks, or callbacks that must be stored:

let path = String::from("data.txt");

std::thread::spawn(move || {
    println!("process {path}");
});

The new thread may outlive the current function, so it cannot borrow local stack variables. move transfers ownership of path into the closure running on the thread.

This is very different from passing a C ctx pointer to a stack variable into an asynchronous callback. Rust prevents that dangling reference.

Function Pointers and Closures Are Not the Same

Rust also has function pointers:

fn handle(event: i32) {
    println!("{event}");
}

let f: fn(i32) = handle;

A function pointer captures no environment. It is closer to a plain C function pointer.

A closure that captures no environment can also coerce to a function pointer:

let f: fn(i32) = |event| println!("{event}");

But a closure that captures environment cannot:

let prefix = String::from("event");
// let f: fn(i32) = |event| println!("{prefix}: {event}"); // not allowed

A plain function pointer has nowhere to store prefix. When environment must be stored, use a generic F: Fn... parameter or a trait object.

How Library APIs Receive Callbacks

If a function only calls the callback immediately, use a generic parameter:

fn for_each_event<F>(events: &[i32], mut callback: F)
where
    F: FnMut(i32),
{
    for event in events {
        callback(*event);
    }
}

The caller can pass a function:

fn print_event(event: i32) {
    println!("{event}");
}

for_each_event(&[1, 2, 3], print_event);

Or a closure:

let mut sum = 0;

for_each_event(&[1, 2, 3], |event| {
    sum += event;
});

Why FnMut here? It is practical: it accepts read-only closures and closures that mutate captured state.

Stored Callbacks Usually Need Box

If a struct stores a callback for later, a generic function parameter is not enough. A common shape is Box<dyn FnMut(...)>:

struct Dispatcher {
    callback: Box<dyn FnMut(i32)>,
}

impl Dispatcher {
    fn new<F>(callback: F) -> Self
    where
        F: FnMut(i32) + 'static,
    {
        Self {
            callback: Box::new(callback),
        }
    }

    fn dispatch(&mut self, event: i32) {
        (self.callback)(event);
    }
}

Box<dyn FnMut(i32)> can be understood as:

closure environment on the heap + a call table

'static does not mean the callback is never freed. It means the closure must not borrow local variables that will disappear too early. It either owns captured data or captures references that really live long enough.

FFI Callbacks Still Need C Shapes

Rust closures are good for Rust-internal APIs. Across a C ABI, you cannot pass an environment-capturing closure directly as a C function pointer.

C usually still needs:

typedef void (*event_cb_t)(void *ctx, int event);

The Rust boundary needs an extern "C" function and an explicit context pointer. A common design stores the closure inside an owning Rust value, then calls it through an FFI trampoline.

That involves raw pointers, lifetimes, threads, and release responsibility, so it belongs in the later FFI callback topic. For now, keep this rule: use closures inside Rust; use explicit ABI function pointers and ctx at the C boundary.

What to Keep

The first C-to-Rust mapping is:

function pointer                 -> fn(...) or non-capturing closure
function pointer + void *ctx      -> closure
read-only ctx                     -> Fn
mutable ctx                       -> FnMut
owned ctx / one-shot callback      -> FnOnce
async storage or cross-thread run  -> often move closure
stored callback                   -> Box<dyn FnMut(...)>
C ABI callback                    -> extern "C" fn + ctx, not a captured closure directly

The point of closures is not merely removing a ctx parameter. It is making the environment, how it is used, and how many times it can be called visible to the type system.

The next topic is concurrency: Send, Sync, Arc, and Mutex. C thread-safety relies heavily on convention and locking discipline; Rust moves many cross-thread ownership and sharing rules into types.