Avatar ·

Идиоматичные колбэки (callbacks) в Rust: как передать функцию и избежать borrow checker?

Всем привет! Я пишу на Rust около месяца, перешел с JavaScript, и у меня возникла проблема с реализацией колбэков. В JS я привык просто передавать функцию как аргумент: `doSomething(callback)`. В Rust я пытаюсь сделать то же самое, но получаю ошибки от borrow checker. Вот мой код: ```rust struct Processor { callback: fn(i32) -> i32, } impl Processor { fn new(cb: fn(i32) -> i32) -> Self { Processor { callback: cb } } fn process(&self, x: i32) -> i32 { (self.callback)(x) } } fn double(x: i32) -> i32 { x * 2 } fn main() { let proc = Processor::new(double); println!("{}", proc.process(5)); // 10 } ``` Это работает, но только для обычных функций. Если я хочу передать замыкание (closure), которое захватывает переменные из окружения, компилятор ругается: ```rust let multiplier = 3; let proc = Processor::new(|x| x * multiplier); // error[E0277]: expected a `fn` pointer, found closure ``` Как правильно организовать идиоматичные колбэки в Rust? Мне нужно: 1. Передавать как обычные функции, так и замыкания. 2. Замыкания могут захватывать переменные (по ссылке или по значению). 3. Желательно без Box и heap-аллокаций, если возможно. Я слышал про трейты `Fn`, `FnMut`, `FnOnce`, но не понимаю, как их применить на практике. Помогите, пожалуйста, с наглядным примером! <<>>

Проблема: 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.

Р