Spiiin's blog

Push/Pull/Event model, Reactive GUI

Push/Pull/Events модели

Регулярно повторяющаяся задача проектирования — выбор способа взаимодействия вызывающего и вызываемого кода.

Pull-модель — “вам надо, вы и мучаетесь”. Часто реализовывается проще. Вызывающий код в том месте, в котором удобно, запрашивает данные.

Примеры

считывание настроек в стиле `game::get_setting<string>("blabla")`
считывание 3D модели через render::load3dmodel("balbalb.xxx")
опрос сервера клиентом для получения новых сообщений или обновлений
игровой цикл, где игровой движок "запрашивает" состояние игрока или ввод данных в начале каждого кадра

Все загрузки, всё определение конфигурации и т.п. - размазано по коду и делается ad hoc.

Push-модель — “то же самое, но вывернутое наизнанку”. Вам приходит callback/event/change propagation “blabla setting changed old->new”. В системе жёстко вшито, когда именно происходит событие, и имеется возможность добавить свою реакцию на него.

Примеры

системы реального времени, где сенсоры отправляют данные на сервер или обработчик событий немедленно, как только они доступны
системы уведомлений, где сервер пушит уведомления на устройства клиентов без необходимости запроса со стороны клиента

Все загрузки, конфигурации, управление ресурсами - вынесено наружу, делается более-менее централизованно. Вы регистрируетесь где попало и реагируете на обновления. Зато система без вас знает, когда, как и что делать.

Место определения колбека отделено от момента его вызова, вызывающий код должен понимать контекст, в котором будет вызван колбек — необходимо ли проверять на существование необходимые колбеку ресурсы, доступны ли они или заняты, безопасно ли создавать или удалять какие-либо типы объектов. Один из самых примитивных примеров — инвалидация итератора stl-контейнера в цикле в c++.

Events-модель

В push-моделях можно встретить элементы pull-модели:

  • если объём данных для push большой и может быть не нужен клиентам полностью или сразу, система может только уведомить об изменившихся данных (послать сообщение) и предоставить pull-интерфейс для запроса этих данных, когда будет удобно клиенту
  • если удобно отложить обработку данных, коллбек может вместо выполнения работы положить сообщение в очередь. Дальше на клиента ложиться задача спуллить сообщения из очереди и обработать их. Удобно, если необходимо обработать все сообщения вместе.

Примеры

отложенная обработка сгенерированных команд для подсистемы рендера
отложенная обработка сообщений о столкновениях, чтобы проверить, что суммарные силы воздействии на физический объект не разрушат его

The C10K problem — взаимодействие большого количества сообщений в ОС.
Managing Coupling Part 2 — Polling, Callbacks and Events — о push/pull/events модели от дизайнеров движка Stingray

Паттерны/идиомы/узоры

В различных языках или библиотеках идеи и модели могут быть:
прозрачные для пользователя — доступны в виде примитивов языка
выражаемые формально — для использования можно переопределить функцию для своего типа/отнаследоваться от языка/написать синтаксический макрос/воспользоваться принятым в языке способом
выражаемые неформально — для использования необходимо каждый раз повторно реализовывать функционал

Когда-то паттернами считались процедуры (1957, Design Patterns in Dynamic Programming) и классы (1972, Design patterns of 1972). Прозрачные для пользователя практически не обсуждаются (или даже не называются!), формально выраженные также часто принимаются как правило хорошего программирования. Разве что когда кому-нибудь не нравится производительность или ограничения в интерфейсе. В этом случае даже прозрачные идиомы могут быть реифицированны и переделаны. Пример — Runtime-полиморфизм в C++, альтернативный полиморфизм.

Может показаться, что называть что-то очень простое и примитивное паттерном слишком сёрьзно, но это всего лишь ярлыки.

Ideas for a Programming Language Part 4: Reactive Programming — про поиск синтаксиса для того, чтобы ввернуть асинхронное программирование в язык прозрачно для пользователя

Итераторы, колстеки, корутины, файберы, диспетчеры, асинки — revisited

Две большие статьи выше посвящены, по большому счёту, паттернам работы с коллекциями в различных языках (где-то эти “паттерны” уже вшиты в примитивы языка, где-то их нет и в стандартной библиотеке и приходилось писать самому).

Всё описанное в статьях можно классифицировать по 4 группам:
операции с pull-итераторами в синхронных коллекциях
операции с push-итераторами в синхронных коллекциях
операции с pull-итераторами в асинхронных коллекциях
операции с push-итераторами в асинхронных коллекциях

pull-итераторы — c++ stl-итераторы, IEnumerator C#
push-итераторы — колбеки
синхронные коллекции — контейнеры
асинхронные коллекции — генераторы (могут описывать как space-distributed коллекции, так и time-distributed)

Как и в общем для pull-моделей, pull-итераторы проще для использования/комбинирования — результаты, возвращаемый pull-итератором — стандартные типы языка, с которым учатся работать с самого начала знакомства с языком.

Push-итератор не возвращает ничего, и является отложенным вычислением (замыкания/указатели на функции/функциональные объекты). Отложенные вычисленя тоже можно комбинировать, но вместо привычных прикладному программисту на императивных языках способов требуются привычные математикам.

“Modern” C++ Lamentations — про разницу в синтаксисе и времени компиляции между C++ и C# при работе с лямбдами.
“Transducers” by Rich Hickey — типичные проблемы комбинирования колбеков, как оторвать комбинирование вычислений от коллекций.

Даже просто запись цепочки выполняющихся друг за другом после окончания предыдущего колбеков превращается в анти-паттерн callback hell (синтаксический сахар async/await - количество скобок уменьшается)

//.NET 4
Task<string> htmlTask = webClient.DownloadStringTaskAsync(url);
//тот можно выполнить код, которому не требуется результат hmtlTask
//...
string html = htmlTask.Result; //тут блокировка до ожидания результата
//или прикрепить к задаче продолжение
htmlTask.ContinueWith(task=> {
string html = task.Result;
})

//.NET 5
Task<string> htmlTask = webClient.DownloadStringTaskAsync(url);
string html = await htmlTask; //то же что и в прошлом примере с ContinueWith
doSomething(html) //строки ниже await также "переписываются" внутрь ContinueWith

Паттерн Task (где-то встречается название Future) является обёрткой для асинхронной работы над примитивом. В статьях не хватает разбора способов асинхронной работы с коллекциями (синхронными и асинхронными).

Обобщения паттерна Task в C

pull-based работа синхронными коллекциями — если просто заменить Task<int> на Task<IEnumerable<int>>, то можно использовать await для коллекции, но при этом все элементы коллекции будут собираться синхронно за один вызов, в чём собственно нет особого смысла
pull-based работа с асинхронными коллекциямиIAsyncEnumerable<T> и await foreach позволяет получать элементы коллекции асинхронно
push-based работа с асинхронными коллекциями — push-вариант работы с асинхронными коллекциями — паттерн Observer (IObserver<T> в C#). Если Task представляет собой одно асинхронное событие, то Observable — это асинхронная коллекция (источник) событий, на которые могут подписываться Observer-ы.

Observable/Observer

Observer — давно известный паттерн, популяризованный в GoF, и активно используемый в smalltalk-версии паттерна MVC.
How did MVC get so F’ed up? — деградация MVC в языках с не-observable примитивами в качестве модели, мешает композиции.

Observable в C# зовёт 3 метода Observer (продолжение/окончание/ошибка):

OnCompleted() - последовательность закончилась
OnError(exception) - при генерации данных произошла ошибка
OnNext(value) - было сгенерированно следующее значение

Observable можно представить как обобщение примитивного обхода коллекции (синхронной или асинхронной). При этом, как и для примитивного foreach, так и любых более сложных реализаций, должно соблюдаться простое правило: OnCompleted/OnError — это последний колбек, после которого Observable не будет присылать других.

Паттерн сам по себе не защищен от возможностей кривой композиции, так что периодически появляются теоретические попытки сделать что-то более чистое (но не всегда более простое).
Deprecating the Observer Pattern with Scala.React от Мартина Одерски, умершая либа. Вместо неё сейчас и сам Одерски предлагает JavaRx, надстроенную над observer-ами.

Reactive-подход
Observer имеет те же особенности композиции, что и push-итераторы — близкие математикам или функциональным программистам примитивы, в C# местами завёрнутые в linq-синтаксис.

ReactiveX — библиотека реактивного программирования, набор примитивов для композиции и конвертеров между различными типами итераторов.
Introduction to ReactiveX in C++ (rxcpp) — презентация по С++ версии библиотеки, heavy templates-based.
Introduction to RX.Net — книга по C# версии.

//kotlin example
getDataFromNetwork()
.skip(10)
.take(5)
.map({ s -> return s + " transformed" })
.subscribe({ println "onNext => " + it })

GUI

Можно разделить GUI-библиотеки на imperative/declarative и retained/immediate/reactive.

Imperative/Declarative

Imperative — установка состояние контролов описывается в коде
Declarative — установка состояния вынесена куда-нибудь в JSON/XML/HTML или в динамический язык (lua/squirrel — бестиповые таблицы + лямбды удобны для описания представления и кода) и загружается с помощью control->loadFromFile. Из преимуществ — gui можно менять без перекомпиляции, программиста и с помощью тулзы, которая позволяет менять состояние мышкой.

WPF: контролы лишенные внешнего вида и неразрешимая задача выбора конфигурации темплейта — более развернуто про то, что даёт декларативный подход

Разработчики хотят, чтобы библиотека «угадала» их представление о том, как должен выглядеть и как должен работать тот или иной элемент визуального интерфейса. Соответственно, разработчики, как пользователи библиотеки, оказываются не готовы, что вместо того, чтобы искать готовые компоненты, надо научиться создавать их самому в парадигме того инструментария и тех концепций, которые реализованы в библиотеке.

Retained

Retained — клиентский код не занимается рендером сам, а заполняет и обновляет модель. Push-модель, очень распространённая в GUI-библиотеках.

Пример, как может выглядеть код:

struct {
int value;
} state;

auto window = GUI::createRootWindow();

auto label = GUI::createLabel("Click counter %d", state.value);

auto button = GUI::createButton();
button->setText("Increase counter");
button->setClickCallback([](){
state.value++;
//нужно не забыть руками обновить внутреннее состояние контрола, отображающего значение
label->updateText("Click counter %d", state.value);
});
window->addChild(button);

//somewhere
while (!exit) {
GUI::updateRootWindow();
}

Минусы — код обновления состояния модели (всего, что вне GUI), нужно синхронизировать с состоянием GUI.

Примеры либ - cegui

Immediate

Immediate — Pull-подход (“вам надо, вы и рисуйте”), родившийся в головах гейм-девелоперов. Если “вывернуть” наизнанку логику, и отдать обязанность рисования контролов клиенту, то логика местами станет проще (вместо угадывания потребностей клиента сложность переезжает в клиентский код, и отпадает необходимость синхронизации состояния). Функции рисования не имеют своего состояния, а принимают указатели на данные, которые они могут изменить.

struct {
int value;
} state;

Gui::Begin("My window");
Gui::Text("Counter %d", state.value);
if (Gui::Button("Increate counter")) //without explicit callback function
state.value++;
Gui::End();

Примеры либ — Unity Imgui, Dear Imgui, Nuklear.

Reactive

Если под рукой есть язык или библиотека, который может описать зависимости между данными, то можно было бы устранить недостаток retained gui без перехода к immediate.

struct {
GUI::MagicObservable<int> value; //при изменении уведомлять всех наблюдателей
} state;

auto window = GUI::createRootWindow();
auto label = GUI::createLabel("Click counter %d", state.value);

auto button = GUI::createButton();
button->setText("Increase counter");
button->setClickCallback([](){
//обновляем значение, observer уведомит об изменении все контролы. Прямого обновления label в коде больше нет
GUI::updateObservable(state.value);
});
window->addChild(button);

Примеры либ — knockout.js, rivets для js, ReactiveUI для C#, rxqt для C++.
MIX11 Knockout JS Helping you build dynamic JavaScript UIs with MVVM and ASP NET — knockout.js demo
How dependency tracking works — knockout.js how it works

Knockout.js использует тривиальный динамический метод ослеживания зависимостей.

  • Когда объявляется observable, вычисляется его initial-значение.
  • В ходе вычисления, устанавливается подписка на любые другие observables (включая computed observables), значения которых читаются (язык должен поддерживать хук на чтение значений). Подписка означает, что будет вызвано вычисление этого observable (шаг 1), при этом любые старые подписки удаляются.
  • После завершения вычисления, вызываются все подписки о том, что новое значение этого observable доступно.
    Так что зависимости не вычисляются при первом запуске и их не нужно объявлять, они просто перерасчитываются каждый раз при обновлении, и могут изменяться динамически. Если эвалуатор observable не читает ни одного другого observable, от которого он зависит, то повторное вычисление никогда не произойдёт, и сам эвалуатор может быть заменён на вычисленное значение.
  • Декларативные биндинги данных к состоянию контролов — это просто computed observables.

Введение в ReactiveUI: прокачиваем свойства во ViewModel — В C# -> WPF + XAML бекэнд + RX.Net для описания зависимостей + ReactiveUI.Fody для генерации

Reactive Gui на C++ и геймдеве

Систему реактивного GUI можно разделить на 4 части:

  • язык реализации (и, возможно, расширения) системы (C++)
  • язык для декларативного описания gui и байндингов контролов к данным
  • язык для описания скриптового поведения контролов (если хотим декларативно описывать поведение — в C++ сложно с рефлексией и интерпретацией кода)
  • тулза для визуального изменения декларативного описания

Model-View-ViewModel
The Elm Architecture

Примеры Reactive GUI для геймдева встречаются не очень часто, но встречаются.

imvue
imvue — минималистичный проект-пример

  • sdl/glew/imgui в качестве бекэнда для рендера gui, плюс кодоген ооп-обёртки вокруг imgui
  • lua в качестве скриптового языка и reactive-системы
  • libcss — html/css декларативное описание, в стиле шаблонов vue.js
  • без визуального редактора, так как игрушечная либа

xui
XUI — еще один демо-пример

  • irrlicht в качестве бекэнда
  • C++ clang-based парсер для рефлексии, rxcpp и cpplinq для реактивности
  • xaml для декларативного описания, порт OmniXaml на C++

noesisengine
noesisengine — коммерческий проект

  • свой рендер-бекэнд, рефлексия, редактор (+ экспорт из microsoft blend)
  • xaml в качестве декларативного описания

daRg
Dagor Reactive GUI — гайдзиновский reactive gui

  • своей рендер-бекэнд
  • quirrel (свой порт squirrel) в качестве декларативного описания, рефлексии и скриптового языка
  • свой визуальный редактор Dargbox