Идиоматичные колбэки (callbacks) в Rust: как передать функцию и избежать borrow checker?
Проблема: fn pointer vs closure
Вы столкнулись с классической для Rust проблемой: тип fn(i32) -> i32 — это указатель на функцию (function pointer). Он может хранить только обычные функции или замыкания, которые не захватывают переменные из окружения (non-capturing closures). Как только замыкание захватывает хотя бы одну переменную, его тип перестаёт быть fn.
Решение — использовать трейты Fn, FnMut или FnOnce через обобщённые типы (generics) или через Box. Рассмотрим оба подхода.
Подход 1: Обобщённый тип (generics) — без heap-аллокаций
Самый идиоматичный и производительный способ — сделать структуру обобщённой по типу колбэка. Ограничение F: Fn(i32) -> i32 гарантирует, что мы можем вызывать колбэк многократно (требуется Fn, а не FnOnce).
struct Processor<F>
where
F: Fn(i32) -> i32,
{
callback: F,
}
impl<F> Processor<F>
where
F: Fn(i32) -> i32,
{
fn new(cb: F) -> Self {
Processor { callback: cb }
}
fn process(&self, x: i32) -> i32 {
(self.callback)(x)
}
}
// Работает и с обычной функцией, и с замыканием
fn double(x: i32) -> i32 { x * 2 }
fn main() {
// 1. Обычная функция
let proc1 = Processor::new(double);
println!("{}", proc1.process(5)); // 10
// 2. Замыкание, захватывающее переменную
let multiplier = 3;
let proc2 = Processor::new(|x| x * multiplier);
println!("{}", proc2.process(5)); // 15
}
Плюсы:
- Нет heap-аллокаций — всё на стеке.
- Мономорфизация даёт максимальную производительность.
- Работает с любыми замыканиями (по ссылке, по значению, с мутацией).
Минусы:
- Тип структуры зависит от конкретного замыкания —
Processor<fn(i32) -> i32>иProcessor<[closure]>— это разные типы. Нельзя хранить в одном векторе разные колбэки. - Если нужно хранить колбэк в долгоживущей структуре (например, в поле), компиляция может быть медленнее из-за мономорфизации.
Подход 2: Box<dyn Fn> — единый тип, но с heap
Если вам нужен единый тип для всех колбэков (например, для хранения в коллекции), используйте Box<dyn Fn(i32) -> i32>. Это trait object — динамическая диспетчеризация.
struct Processor {
callback: Box<dyn Fn(i32) -> i32>,
}
impl Processor {
fn new<F>(cb: F) -> Self
where
F: Fn(i32) -> i32 + 'static,
{
Processor {
callback: Box::new(cb),
}
}
fn process(&self, x: i32) -> i32 {
(self.callback)(x)
}
}
fn main() {
let multiplier = 3;
let proc = Processor::new(|x| x * multiplier);
println!("{}", proc.process(5)); // 15
// Можно хранить в векторе
let callbacks: Vec<Box<dyn Fn(i32) -> i32>> = vec![
Box::new(|x| x + 1),
Box::new(|x| x * 2),
];
}
Плюсы:
- Единый тип
Processorдля любых колбэков. - Можно хранить в коллекциях (Vec, HashMap).
Минусы:
- Heap-аллокация при создании
Box. - Небольшой overhead от динамической диспетчеризации (виртуальный вызов).
- Требуется
'static— замыкание не должно содержать ссылок на локальные переменные (если нужно — используйтеRcилиArc).
Когда что использовать?
- Generics — если колбэк известен на этапе компиляции и не нужно хранить разные типы колбэков. Это предпочтительный способ.
- Box<dyn Fn> — если нужна гибкость (например, callback registry, event system) или если колбэк живёт дольше, чем его создатель.
Важное замечание про FnMut и FnOnce
Если ваш колбэк должен изменять захваченные переменные (например, счётчик), используйте FnMut — он принимает &mut self:
struct Counter<F>
where
F: FnMut() -> i32,
{
callback: F,
}
let mut count = 0;
let mut counter = Counter {
callback: || { count += 1; count },
};
(counter.callback)(); // 1
А если колбэк можно вызвать только один раз (например, для освобождения ресурса) — FnOnce:
fn run_once<F>(f: F)
where
F: FnOnce(),
{
f(); // можно вызвать только один раз
}
<<Альтернатива: трейт-объекты без Box (через ссылки)
Если вы не хотите использовать heap, но нужна гибкость trait object'ов, можно передавать колбэк по ссылке — &dyn Fn. Это полезно, когда колбэк не нужно хранить, а только вызвать.
struct Processor;
impl Processor {
// Принимает любую ссылку на колбэк
fn process(&self, x: i32, cb: &dyn Fn(i32) -> i32) -> i32 {
cb(x)
}
}
fn main() {
let multiplier = 3;
let closure = |x| x * multiplier;
let proc = Processor;
let result = proc.process(5, &closure);
println!("{}", result); // 15
}
Этот подход:
- Не требует
Box— всё на стеке. - Позволяет передавать разные замыкания в один и тот же метод.
- Но колбэк не сохраняется — только вызывается.
Если нужно сохранить колбэк без heap, можно использовать enum с двумя вариантами:
enum Callback<'a> {
Func(fn(i32) -> i32),
Closure(&'a dyn Fn(i32) -> i32),
}
struct Processor<'a> {
cb: Callback<'a>,
}
Но это уже экзотика — на практике проще использовать generics или Box.
Р