Идиоматические обратные вызовы в Rust

100

В C / C ++ я обычно выполняю обратные вызовы с помощью простого указателя на функцию, возможно, также передав void* userdataпараметр. Что-то вроде этого:

typedef void (*Callback)();

class Processor
{
public:
    void setCallback(Callback c)
    {
        mCallback = c;
    }

    void processEvents()
    {
        for (...)
        {
            ...
            mCallback();
        }
    }
private:
    Callback mCallback;
};

Какой идиоматический способ сделать это в Rust? В частности, какие типы должна setCallback()принимать моя функция и какой должна mCallbackбыть? Следует ли это сделать Fn? Может быть FnMut? Сохранить Boxed? Пример был бы потрясающим.

Тимммм
источник

Ответы:

195

Краткий ответ: для максимальной гибкости вы можете сохранить обратный вызов как упакованный FnMutобъект с универсальным установщиком обратного вызова для типа обратного вызова. Код для этого показан в последнем примере ответа. Для более подробного объяснения читайте дальше.

"Указатели на функции": обратные вызовы как fn

Ближайшим эквивалентом кода C ++ в вопросе будет объявление обратного вызова как fnтипа. fnинкапсулирует функции, определенные fnключевым словом, подобно указателям функций C ++:

type Callback = fn();

struct Processor {
    callback: Callback,
}

impl Processor {
    fn set_callback(&mut self, c: Callback) {
        self.callback = c;
    }

    fn process_events(&self) {
        (self.callback)();
    }
}

fn simple_callback() {
    println!("hello world!");
}

fn main() {
    let p = Processor {
        callback: simple_callback,
    };
    p.process_events(); // hello world!
}

Этот код можно расширить, включив Option<Box<Any>>в него «пользовательские данные», связанные с функцией. Даже в этом случае это не был бы идиоматический Rust. В Rust способ связать данные с функцией заключается в их анонимном закрытии , как в современном C ++. Поскольку замыканий нет fn, set_callbackнеобходимо будет принимать другие типы объектов функций.

Обратные вызовы как универсальные функциональные объекты

И в Rust, и в C ++ замыкания с одной и той же сигнатурой вызова имеют разные размеры, чтобы соответствовать различным значениям, которые они могут захватывать. Кроме того, каждое определение замыкания генерирует уникальный анонимный тип для значения замыкания. Из-за этих ограничений структура не может назвать тип своего callbackполя или использовать псевдоним.

Один из способов встроить замыкание в поле структуры без ссылки на конкретный тип - сделать структуру универсальной . Структура автоматически адаптирует свой размер и тип обратного вызова для конкретной функции или замыкания, которые вы ей передаете:

struct Processor<CB>
where
    CB: FnMut(),
{
    callback: CB,
}

impl<CB> Processor<CB>
where
    CB: FnMut(),
{
    fn set_callback(&mut self, c: CB) {
        self.callback = c;
    }

    fn process_events(&mut self) {
        (self.callback)();
    }
}

fn main() {
    let s = "world!".to_string();
    let callback = || println!("hello {}", s);
    let mut p = Processor { callback: callback };
    p.process_events();
}

Как и раньше, новое определение обратного вызова сможет принимать функции верхнего уровня, определенные с помощью fn, но это также будет принимать замыкания as || println!("hello world!"), а также замыкания, которые фиксируют значения, такие как || println!("{}", somevar). Из-за этого процессору не нужно userdataсопровождать обратный вызов; закрытие, предоставленное вызывающим set_callback, автоматически захватит необходимые данные из своей среды и сделает их доступными при вызове.

Но в чем дело FnMut, почему не просто Fn? Поскольку замыкания содержат захваченные значения, при вызове замыкания должны применяться обычные правила мутации Rust. В зависимости от того, что замыкания делают со значениями, которые они содержат, они группируются в три семейства, каждое из которых отмечено свойством:

  • Fn- это замыкания, которые только читают данные и могут безопасно вызываться несколько раз, возможно, из нескольких потоков. Оба вышеуказанных закрытия есть Fn.
  • FnMut- это замыкания, которые изменяют данные, например, записывая в захваченную mutпеременную. Их также можно вызывать несколько раз, но не параллельно. (Вызов FnMutзакрытия из нескольких потоков приведет к гонке данных, поэтому это может быть выполнено только с защитой мьютекса.) Объект закрытия должен быть объявлен вызывающим изменяемым.
  • FnOnce- это замыкания, которые потребляют некоторые данные, которые они захватывают, например, перемещая захваченное значение в функцию, которая становится его владельцем. Как следует из названия, они могут быть вызваны только один раз, и вызывающий должен владеть ими.

Несколько нелогично, но при указании признака, привязанного к типу объекта, который принимает замыкание, FnOnceна самом деле это наиболее разрешающий вариант . Объявление того, что универсальный тип обратного вызова должен удовлетворять FnOnceпризнаку, означает, что он будет принимать буквально любое закрытие. Но за это приходится платить: это означает, что владельцу разрешено позвонить только один раз. Поскольку process_events()можно выбрать вызов обратного вызова несколько раз, а сам метод может вызываться более одного раза, следующая наиболее разрешительная граница - FnMut. Обратите внимание, что мы должны были пометить process_eventsкак мутирующие self.

Неуниверсальные обратные вызовы: объекты признаков функции

Хотя общая реализация обратного вызова чрезвычайно эффективна, у нее есть серьезные ограничения интерфейса. Он требует, чтобы каждый Processorэкземпляр был параметризован конкретным типом обратного вызова, что означает, что один Processorможет иметь дело только с одним типом обратного вызова. Учитывая, что каждое замыкание имеет отдельный тип, универсальный Processorне может обрабатывать, proc.set_callback(|| println!("hello"))за которым следует proc.set_callback(|| println!("world")). Расширение структуры для поддержки двух полей обратных вызовов потребует, чтобы вся структура была параметризована для двух типов, что быстро станет громоздким по мере роста числа обратных вызовов. Добавление дополнительных параметров типа не сработает, если количество обратных вызовов должно быть динамическим, например, для реализации add_callbackфункции, которая поддерживает вектор различных обратных вызовов.

Чтобы удалить параметр типа, мы можем воспользоваться объектами признаков , функцией Rust, которая позволяет автоматически создавать динамические интерфейсы на основе признаков. Иногда это называется стиранием типа и является популярным методом в C ++ [1] [2] , не путать с несколько различным использованием этого термина в языках Java и FP. Читатели, знакомые с C ++, поймут, что различие между реализуемым закрытием Fnи Fnобъектом признака эквивалентно различию между общими объектами функций и std::functionзначениями в C ++.

Объект признака создается путем заимствования объекта с &оператором и преобразования или принуждения его к ссылке на конкретный признак. В этом случае, поскольку нам Processorнеобходимо владеть объектом обратного вызова, мы не можем использовать заимствование, но должны хранить обратный вызов в выделенной куче Box<dyn Trait>(эквивалент Rust std::unique_ptr), что функционально эквивалентно объекту черты.

Если Processorхранит Box<dyn FnMut()>, он больше не должен быть универсальным, но теперь set_callback метод принимает универсальный cчерез impl Traitаргумент . Таким образом, он может принимать любые вызываемые объекты, включая замыкания с состоянием, и правильно упаковывать их перед сохранением в Processor. Общий аргумент set_callbackне ограничивает тип обратного вызова, который принимает процессор, поскольку тип принятого обратного вызова отделен от типа, хранящегося в Processorструктуре.

struct Processor {
    callback: Box<dyn FnMut()>,
}

impl Processor {
    fn set_callback(&mut self, c: impl FnMut() + 'static) {
        self.callback = Box::new(c);
    }

    fn process_events(&mut self) {
        (self.callback)();
    }
}

fn simple_callback() {
    println!("hello");
}

fn main() {
    let mut p = Processor {
        callback: Box::new(simple_callback),
    };
    p.process_events();
    let s = "world!".to_string();
    let callback2 = move || println!("hello {}", s);
    p.set_callback(callback2);
    p.process_events();
}

Срок службы ссылок внутри закрытых коробок

'staticСрок службы связан с типом cаргумента , принятого set_callbackпростой способ убедить компилятор , что ссылки , содержащиеся в c, что может быть замыкание , которое относится к окружающей среде, относятся только к глобальным ценностям и , следовательно , будет оставаться в силе в течение использования из перезвонить. Но статическая граница также является очень жесткой: хотя она принимает замыкания, которые владеют объектами в полном порядке (что мы обеспечили выше, сделав замыкание move), она отклоняет замыкания, которые относятся к локальной среде, даже если они относятся только к значениям, которые пережить процессор и на самом деле будет в безопасности.

Поскольку нам нужны только обратные вызовы, пока жив процессор, мы должны попытаться связать их время жизни со временем жизни процессора, что является менее строгим ограничением, чем 'static. Но если мы просто удалим 'staticограничение времени жизни set_callback, он больше не будет компилироваться. Это связано с тем, что set_callbackсоздается новый блок и назначается его callbackполю, определенному как Box<dyn FnMut()>. Поскольку в определении не указано время жизни для упакованного в штучный объект признака, 'staticподразумевается, и присвоение эффективно расширит время жизни (от неназванного произвольного времени жизни обратного вызова до 'static), что запрещено. Исправление состоит в том, чтобы указать явное время жизни процессора и связать это время жизни как со ссылками в поле, так и со ссылками в обратном вызове, полученными set_callback:

struct Processor<'a> {
    callback: Box<dyn FnMut() + 'a>,
}

impl<'a> Processor<'a> {
    fn set_callback(&mut self, c: impl FnMut() + 'a) {
        self.callback = Box::new(c);
    }
    // ...
}

Поскольку эти значения времени жизни указаны явно, в использовании больше нет необходимости 'static. Замыкание теперь может относиться к локальному sобъекту, т. Е. Больше не должно быть move, при условии, что определение объекта sпомещается перед определением, pчтобы гарантировать, что строка переживет процессор.

пользователь4815162342
источник
15
Вау, я думаю, это лучший ответ, который я когда-либо получал на ТАК вопрос! Спасибо! Прекрасно объяснено. Но я не понимаю одной мелочи - почему это CBдолжно быть 'staticв последнем примере?
Timmmm
9
Box<FnMut()>Используется в средстве структура поля Box<FnMut() + 'static>. Примерно «Упакованный объект-признак не содержит ссылок / каких-либо ссылок, которые он содержит, переживает (или равно) 'static». Это не позволяет обратному вызову захватывать локальных жителей по ссылке.
bluss
Ах, понятно, думаю!
Timmmm
1
@Timmmm Подробнее о 'staticпереплете в отдельном сообщении в блоге .
user4815162342
3
Это фантастический ответ, спасибо за его @ user4815162342.
Dash83 06