Коллбеки в Rust: от боли к элегантности через идиомы

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: почему коллбеки в Rust — это вызов (и возможность)

Если вы когда-нибудь писали на JavaScript, Python или C#, то коллбеки для вас — привычный инструмент. Вы передаёте функцию, которая будет вызвана позже, и живёте спокойно. Но когда вы приходите в Rust, этот простой паттерн внезапно превращается в минное поле: владение, заимствование, времена жизни, dyn vs impl...

На Stack Overflow вопрос "Idiomatic callbacks in Rust" собрал тысячи просмотров. Это значит, что даже опытные разработчики спотыкаются об эту тему. Но хорошая новость: Rust предлагает не один, а несколько элегантных способов реализовать коллбеки. И сегодня мы разберём их все — от простых замыканий до мощных трейт-объектов и enum-паттернов.

Эта статья — для вас, если вы:

  • Начали изучать Rust и хотите понять, как правильно организовывать асинхронные и событийные архитектуры;
  • Переходите с других языков и ищете идиоматичные аналоги привычным паттернам;
  • Хотите писать чистый, безопасный и производительный код на Rust.

Поехали!

Проблема: что не так с простыми коллбеками?

Представьте, что мы пишем простой планировщик задач. В Python это выглядело бы так:

def schedule(callback):
    # делаем что-то
    callback(result)

В Rust первая попытка может быть такой:

fn schedule(callback: fn(i32) -> i32) {
    let result = 42;
    callback(result);
}

И это работает! Но только для функций, а не для замыканий. Попробуйте передать замыкание, которое захватывает переменную:

let factor = 2;
let closure = |x| x * factor;
schedule(closure); // Ошибка! expected fn pointer, found closure

Компилятор справедливо ругается: fn — это указатель на функцию, а замыкание — это анонимный тип, который может захватывать окружение. Нам нужен более гибкий подход.

Подход 1: Замыкания с impl Fn (простота и гибкость)

Самый простой и часто используемый способ — использовать обобщённый тип с трейтом Fn, FnMut или FnOnce.

Когда использовать Fn, FnMut, FnOnce?

  • FnOnce — если коллбек вызывается ровно один раз (потребляет захваченные значения);
  • FnMut — если коллбек может изменять захваченное состояние (вызывается несколько раз);
  • Fn — если коллбек не изменяет состояние (чистая функция).

Пример с impl Fn:

fn schedule<F>(callback: F)
where
    F: Fn(i32) -> i32,
{
    let result = 42;
    println!("Результат: {}", callback(result));
}

fn main() {
    let factor = 2;
    let closure = |x| x * factor;
    schedule(closure); // Работает!
    
    // Можно передать и обычную функцию
    fn double(x: i32) -> i32 { x * 2 }
    schedule(double);
}

Плюсы: максимально гибко, поддерживает замыкания, безопасно, читаемо.
Минусы: мономорфизация — для каждого типа замыкания компилятор генерирует отдельную версию функции. Если коллбеков много, код может раздуться.

Подход 2: Трейт-объекты Box<dyn Fn> (динамическая диспетчеризация)

Если вы хотите хранить коллбеки в коллекциях (например, в Vec) или передавать их между потоками, обобщения не подойдут — нужен один тип. На помощь приходят трейт-объекты:

type Callback = Box<dyn Fn(i32) -> i32>;

struct Scheduler {
    callbacks: Vec<Callback>,
}

impl Scheduler {
    fn new() -> Self {
        Scheduler { callbacks: Vec::new() }
    }
    
    fn register(&mut self, callback: Callback) {
        self.callbacks.push(callback);
    }
    
    fn run_all(&self, input: i32) {
        for callback in &self.callbacks {
            println!("Результат: {}", callback(input));
        }
    }
}

fn main() {
    let mut scheduler = Scheduler::new();
    let factor = 10;
    scheduler.register(Box::new(move |x| x * factor));
    scheduler.register(Box::new(|x| x + 5));
    scheduler.run_all(42);
}

Обратите внимание на move — он забирает владение factor в замыкание. Иначе компилятор скажет, что factor может жить недостаточно долго.

Плюсы: единый тип, можно хранить в коллекциях, динамическая диспетчеризация.
Минусы: небольшой оверхед (виртуальный вызов), необходимость в Box (куча). Для высоконагруженных систем может быть критично.

Подход 3: Собственный трейт (максимальный контроль)

Иногда вам нужно больше, чем просто вызов функции. Например, коллбек может иметь сложное состояние или требовать несколько методов. В этом случае создайте свой трейт:

trait EventHandler {
    fn on_event(&mut self, event: &str);
    fn priority(&self) -> u32;
}

struct Logger {
    log_count: u32,
}

impl EventHandler for Logger {
    fn on_event(&mut self, event: &str) {
        self.log_count += 1;
        println!("[{}] Событие: {}", self.log_count, event);
    }
    
    fn priority(&self) -> u32 {
        10
    }
}

struct Dispatcher {
    handlers: Vec<Box<dyn EventHandler>>,
}

impl Dispatcher {
    fn dispatch(&mut self, event: &str) {
        // Сортируем по приоритету и вызываем
        self.handlers.sort_by_key(|h| h.priority());
        for handler in &mut self.handlers {
            handler.on_event(event);
        }
    }
}

Плюсы: полный контроль над интерфейсом, можно добавлять методы, состояния, конфигурацию.
Минусы: больше кода, нужно явно реализовывать трейт для каждого типа.

Подход 4: Enum-паттерн (для конечного набора вариантов)

Иногда коллбек — это не функция, а выбор из нескольких действий. Например, обработчик нажатия кнопки:

enum ButtonAction {
    Print(String),
    Beep { frequency: u32, duration: u32 },
    Quit,
}

struct Button {
    action: ButtonAction,
}

impl Button {
    fn press(&self) {
        match &                

Блоги

Рекомендаций по книгам