跳转到主内容closure 和回调 | 物联网民工

closure 和回调

C 里写回调,最常见的形状是函数指针加上下文指针:

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

void register_event(event_cb_t cb, void *ctx);

cb 是要调用的函数,ctx 是调用时带回来的环境。这个模式很成熟,但也有老问题:

  • ctx 的真实类型靠约定
  • ctx 活多久靠约定
  • 回调会不会修改环境靠约定
  • 回调会不会只调用一次靠约定
  • 多线程调用时是否安全靠约定

Rust 的 closure 解决的是同一个问题:把“要执行的代码”和“它需要的环境”放在一起。

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

callback 不只是函数。它还捕获了外部变量 prefix。

closure 是带环境的函数

普通函数不能捕获局部变量:

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

closure 可以:

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

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

print_event(1);

从 C 角度看,可以把它想成:

closure = 函数指针 + 编译器生成的 ctx

区别是,Rust 知道这个 ctx 里有什么,也知道它是只读借用、可变借用,还是被拿走所有权。

Fn:只读捕获,可以调用多次

如果 closure 只是读取捕获的变量,它通常实现 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) 表示这个回调可以用只读方式调用。它可以调用多次,不需要修改自身捕获的环境。

这类似 C 里回调只读 ctx:

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

Rust 把“只读使用环境”写进了 closure trait。

FnMut:回调会修改捕获环境

如果 closure 要修改捕获的变量,它通常实现 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}");
    });
}

callback 参数前要写 mut,因为调用 FnMut 可能会修改 closure 自己保存的环境。

C 里也常这样写:

struct Counter {
    int count;
};

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

Rust 的区别在于:可变捕获会受借用规则约束。只要这个 closure 还持有对 count 的可变借用,你就不能在外面同时乱改 count。

FnOnce:回调会消耗捕获的值

如果 closure 把捕获的值移动出去,它只能调用一次,通常实现 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 被移动进 closure,并在调用时被消耗。这个 closure 不能再调用第二次。

从 C 角度看,这类似“一次性回调接管 ctx,并负责释放”。不同的是,Rust 会在类型层面表达“只能调用一次”。

move closure 拿走捕获值

默认情况下,closure 会按需要借用或移动外部变量。你也可以显式写 move,让 closure 拿走捕获值:

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

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

move 常见于线程、异步任务或需要把回调保存起来的场景:

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

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

新线程可能比当前函数活得更久,所以不能借用当前栈上的局部变量。move 把 path 的所有权交给线程里的 closure。

这和 C 里把 ctx 指向栈变量再异步回调非常不同。Rust 会阻止这种悬垂引用。

函数指针和 closure 不完全一样

Rust 也有函数指针:

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

let f: fn(i32) = handle;

函数指针不捕获环境。它更接近 C 的纯函数指针。

不捕获环境的 closure 也可以转换成函数指针:

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

但捕获环境的 closure 不行:

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

因为普通函数指针没有地方存 prefix。需要保存环境时,用泛型参数 F: Fn...,或者用 trait object。

库 API 怎样接收回调

如果函数只是立即调用回调,可以用泛型:

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

调用方可以传函数:

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

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

也可以传 closure:

let mut sum = 0;

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

为什么这里用 FnMut?因为它覆盖面更实用:既能接收只读 closure,也能接收会修改捕获状态的 closure。

保存回调时通常需要 Box

如果结构体要长期保存回调,不能只写泛型函数参数。常见写法是 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)> 可以先理解成:

堆上的 closure 环境 + 一张调用表

'static 不是说回调永远不释放,而是说这个 closure 不能借用即将失效的局部变量。它要么拥有捕获的数据,要么只捕获真正能活足够久的引用。

FFI 回调仍然要回到 C 形状

Rust closure 很适合 Rust 内部 API。但跨 C ABI 时,不能直接把捕获环境的 closure 当成 C 函数指针传出去。

C 需要的仍然通常是:

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

Rust 边界要用 extern "C" 函数和明确的 context 指针。常见做法是把 closure 装进一个拥有类型,再在 FFI trampoline 里取出来调用。

这部分涉及裸指针、生命周期、线程和释放责任,后面的 FFI 回调专题再展开。现在先记住:Rust 内部用 closure,C 边界用明确 ABI 的函数指针和 ctx。

这一篇先记住什么

C 和 Rust 的第一层对应关系是:

函数指针                         -> fn(...) 或不捕获 closure
函数指针 + void *ctx              -> closure
只读使用 ctx                      -> Fn
修改 ctx                          -> FnMut
接管 ctx / 只能调用一次            -> FnOnce
异步保存或跨线程执行               -> 常见 move closure
长期保存回调                      -> Box<dyn FnMut(...)>
C ABI 回调                         -> extern "C" fn + ctx,不直接传捕获 closure

closure 的重点不是少写一个 ctx 参数,而是让“代码需要哪些环境、怎样使用这些环境、能调用几次”进入类型系统。

下一篇讲并发:Send、Sync、Arc 和 Mutex。C 里线程安全主要靠约定和锁纪律,Rust 会把很多跨线程移动和共享规则写进类型。