Введение: почему коллбеки в 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 &