Как стать автором
Обновить
0

Реактивное программирование пользовательского интерфейса в Jancy

Время на прочтение14 мин
Количество просмотров6.3K
rocketЧто такое реактивное программирование? Статья в Википедии учит, что это — парадигма программирования, ориентированная на потоки данных и распространение изменений. Это определение, хоть и технически корректно (ещё бы!), даёт крайне размытое представление о том, что же за всем этим скрывается на самом деле. Между тем, концепция реактивности проста и естественна, и объяснять её лучше всего на следующем примере.

Все мы когда-нибудь пользовались электронными таблицами типа Microsoft Excel. В ячейке таблицы пользователь может написать формулу, которая ссылается на другие ячейки. Если значение любой из них изменится — формула будет пересчитана, и наша ячейка автоматически обновится. При этом, если наша ячейка участвует в других формулах — то и они будут автоматически пересчитаны, и так далее, и так далее — процесс, напоминающий развитие цепной реакции. Так вот, это и есть главная идея реактивного программирования!

На хабре было уже немало статей на тему реактивного программирования (раз, два, три, четыре и другие) — в основном в них описывается реактивность в своей FRP-ипостаси в виде библиотек навроде bacon.js для JavaScript, JavaRx для Java и т.д. В данной статье пойдёт речь о реализации и применении реактивного программирования в языке Jancy. Материал будет интересен к прочтению даже если вы никогда не слышали о языке Jancy и не собираетесь на нём ничего писать — потому что далее мы продемонстрируем достаточно необычный подход к реактивности из императивного языка.

Какую бы из существующих реактивных библиотек мы не взяли, ключевым в реализации будет паттерн «Observable». Действительно, ведь для того, чтобы «распространять» изменения, нам как минимум надо получить о них уведомления. Observable-сущности обычно подразделяются на два примитива (называются они в разных библиотеках по-разному):
  • Поток событий (EventStream/Observable/Event/Stream);
  • Свойства (Property/Behavior/Attribute) — значение, меняющееся со временем.

В FRP всё это настаивается на элементах функционального программирования: для построения сложных конструкций из таких примитивов применяются функции высшего порядка типа map, reduce, filter, combine и т.д. которые порождают вторичные потоки и события (вместо того, чтобы «модифициорвать» исходные). Получившийся компот из observables и функциональщины не столь труден для понимания и освоения и при этом позволяет выражать зависимости между компонентами в декларативной форме. Это замечательно подходит для программирования запутанных пользовательских интерфейсов и распределённых асинхронных систем. Так есть ли что улучшать?

Проблемы


Первая проблема такова. Если реактивность реализуется на уровне библиотек, без поддержки observables в компиляторе, то автоматически-пересчитываемые формулы а-ля Excel остаются недостижимым идеалом. Вместо этого придётся вручную сделать несколько map и combine над нашими observables — тем больше, чем сложнее логика нашей формулы, а потом ещё onValue/assign для записи полученного значения в нужное место.

Пожалуй, ближе всего к Excel-подобным формулам подобрался Flapjax — опенсорсный компилятор в JavaScript (если существуют другие проекты подобного рода, пожалуйста, напишите про них в комментариях). Observables 2-го типа, которые в Flapjax называются Behavor, можно произвольным образом комбинировать в выражениях и получать на выходе новые Behavor.

magic-potНо имеется ещё одна фундаментальная проблема, которая присуща как реактивным библиотекам, так и Flapjax — это проблема «горшочек не вари». После того, как мы создали нашу инфраструктуру из потоков событий, свойств и взаимных подписок друг на друга, она начинает жить своей жизнью. Данные текут и преобразуются, как мы их попросили, необходимые действия выполняются во всеразличных onValue и onCompleted, всё здорово. Так, а как теперь это остановить? Пробежаться по всем корневым observables и останавить эмиссию событий вручную? Уже не очень красиво. А что если надо остановить не всё, а лишь часть нашего реактивного графа зависимостей? При том, что львиная доля наших observables существует в виде неявных результатов map/combine/filter?

Если переформулировать несколько по-другому, то одна из проблем с существующими реактивными библиотеками — это то, что (во многом в силу своей функциональной ориентированности) они порождают одноуровневую структуру observable объектов!

Впрочем, критиковать всегда легче, чем предложить какую-то альтернативу. Итак, чем же в плане реактивности может похвастать Jancy?
  1. Excel-подобный автоматический пересчёт формул с observables — причём только там, где выберет программист;
  2. Возможность группировать кластеры зависимостей между observables — и потом запускать и останавливать все подписки в кластере разом.

Выглядит это вот так:
reactor TcpConnectionSession.m_uiReactor ()
{
	m_title = $"TCP $(m_addressCombo.m_editText)";
	m_isTransmitEnabled = m_state == State.Connected;
	m_adapterProp.m_isEnabled = m_useLocalAddressProp.m_value;
	m_localPortProp.m_isEnabled = m_useLocalAddressProp.m_value;
	m_actionTable [ActionId.Connect].m_text = m_state ? "Disconnect" : "Connect";
	m_actionTable [ActionId.Connect].m_icon = m_iconTable [m_state ? 
		IconId.Disconnect : 
		IconId.Connect];
	m_statusPaneTable [StatusPaneId.State].m_text = m_stateStringTable [m_state];
	m_statusPaneTable [StatusPaneId.RemoteAddress].m_text = m_state > State.Resolving ? 
		m_remoteAddress.getString () : 
		"<peer-address>";
	m_statusPaneTable [StatusPaneId.RemoteAddress].m_isVisible = m_state > State.Resolving;
}

Это выжимка из исходников сессии TCP Connection терминала IO Ninja. Как легко догадаться, данный код занимается обновлением UI при изменениях статуса, текста в комбобоксе и т.д.

А теперь про то, как это работает.

Общим планом


Прежде всего, дабы избежать путаницы, договоримся о терминологии.

Свойство (property) в Jancy имеет общепринятое (не реактивное) определение — это некая штуковина, которая выглядит, как переменная/поле, но при этом позволяет выполнять действия в функциях-аксессорах.

Мультикасты (multicast) и события (event) служат для накопления указателей на функции и вызова их всех разом (о различиях между мультикастами и событиями чуть попозже).

В Jancy только один вид observable на уровне компилятора — «связываемое свойство» (bindable property), т.е. свойство, способное оповещать о своём изменении через событие onChanged.

В отличие от аналогов, реактивность в Jancy не пытается быть «слишком умной» и лезть всюду, где используются observables — с побочными эффектами типа автоматической подписки, неявного порождения новых observables и т.д. Она стоит в уголке и есть не просит. Доступ к связываемым свойствам в императивном стиле не дороже доступа к обычной переменной.

Как это сочетается с заявленным выше реактивным пересчётом а-ля Excel? Бесконфликтное сосуществование реактивного и императивного начал в Jancy возможно потому, что предусмотрены специальные зоны реактивного кода — т.н. реакторы (reactors). Вместо последовательности инструкций, реакторы состоят из Excel-подобных формул — выражений, каждое из которых должно использовать связываемые свойства. Вот внутри реакторов связываемые свойства ведут себя «по-реактивному».

Итак, основными кирпичиками, из которых строится реактивное программирование в Jancy, являются события, связываемые свойства и реакторы. Рассмотрим эти кирпичики поближе.

Мультикасты и события


Мультикаст (multicast) в Jancy — это генерируемый компилятором специальный класс, позволяющий аккумулировать указатели на функции и затем вызывать их все сразу. Объявление мультикаста очень похоже на объявление функции, что неудивительно — ведь оно должно однозначно определять, указатели на функции какого типа будут храниться в данном мультикасте:
foo (int x);

bar (
    int x, 
    int y   
    );

baz ()
{
    multicast m (int); // normal (fat) multicast
    
    intptr fooCookie = m.add (foo); 
    m += bar ~(, 200); // capture the 2nd argument
    m (100);  // <-- foo (100); bar (100, 200);

    m -= fooCookie;
    m (300); // <-- bar (300, 200);
    m.clear ();
}

Подробнее про методы класса-мультикаста
Для примера, определим простой мультикаст:
multicast m (int);

Класс мультикаста, сгенерированный в примере выше, будет иметь следующие методы:
void clear ();
intptr set (function* (int)); // returns cookie
intptr add (function* (int)); // returns cookie
function* remove (intptr cookie) (int);
function* getSnapshot () (int);
void call (int);

Методы set и add возвращают некий целочисленный cookie, который может быть использован в методе remove для эффективного удаления указателя из мультикаста.

Некоторые из методов имеют также псевдонимы в виде операторов:
multicast m ();
m = foo;     // same as m.set (foo);
m += bar;    // same as m.add (bar);
m -= cookie; // same as m.remove (cookie);
m = null;    // same as m.clear ();
m (10);      // same as m.call (10);

Мультикаст можно привести к указателю на функцию, которая вызовет все накопленные в мультикасте указатели. Но тут имеется неоднозначность, а именно: должно ли подобное приведение быть «живым» (live) или же снимком (snapshot)? Другими словами, если после создания указателя на функцию мы модифицируем исходный мультикаст, должен ли этот указатель видеть изменения?

Для разрешения неоднозначности мультикасты предоставляют метод getSnapshot, возвращающий снимок. В то же время оператор приведения даёт «живой» указатель:
foo ();
bar ();

baz ()
{
	multicast m ();
	m += foo;

    function* f1 () = m.getSnapshot ();
    function* f2 () = m; 

    m += bar;

    f1 (45); // <-- foo ();
    f2 (55); // <-- foo (); bar ();

    return 0;
}


События (event) в Jancy представляют собой специальные указатели на мультикасты с ограничением доступа: можно делать только add и remove:
foo ()
{
    multicast m (int);

    event* p (int) = m;
    p += bar;    // OK
    p (100);     // <-- error, 'call' is inaccessible
}

Объявление переменной или поля типа «событие» создаёт дуальный тип: для «своих» этот тип ведёт себя так, как если бы был использован модификатор multicast, а для «чужих» — это event с запрещением вызова всех методов, кроме add и remove.
Подробнее про дуальные типы в Jancy
Основным отличием модели доступа в Jancy от большинства других объектно-ориентированных языков является сокращение количества спецификаторов доступа до двух — public и protected.

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

Итак, в Jancy для каждого отдельно взятого пространства имён A весь остальной мир распадается на две категории: «свои» и «чужие». Помимо самого пространства имён A, к «своим» относятся:
  • Пространства имён классов или структур, унаследованных от A;
  • Пространства имён, объявленные как дружественные (friend);
  • Дочерние по отношению к A пространства имён;
  • Расширения (extension namespaces) A.

Все остальные являются «чужими». «Свои» имеют доступ и к публичным (public), и к защищённым (protected) членам пространства имён, в то время как «чужие» — только к публичным членам. Помимо этого, принадлежность к группе «своих» или «чужих» меняет смысл дуальных модификаторов Jancy.

Дуальный модификатор readonly может быть использован для элегантной организации доступа только на чтение. Вместо написания тривиальных геттеров, единственным назначением которых был бы контроль доступа, разработчик на Jancy может объявлять поля с модификатором readonly. Для «своих» модификатор readonly как бы невидим, для «чужих» readonly трактуется как const:
class C1
{
    int readonly m_progress;

    foo ()
    {
        m_progress += 25; // OK
        // ...
    }
}

bar (C1* c)
{
    c.m_progress = 100; // <-- error, cannot write to 'const' location
}

Главное преимущество данного подхода — это то, что он делает код короче и естественнее; как побочный положительный эффект можно назвать упрощение, а значит и ускорение работы оптимизатора, которому не нужно анализировать и выкидывать геттеры-пустышки.

Второй дуальный модификатор в Jancy — это event. Владелец события должен иметь над ним полный контроль, включая возможность вызвать всех подписчиков или очистить их список. Клиент события должен иметь возможность только добавить или удалить подписчика. Для «своих» поле с модификатором event работает так же, как и мультикаст с соответствующей сигнатурой аргументов. Для «чужих» такое поле ограничивает доступ к методам мультикаста: разрешены только вызовы add и removе; запрещены call, set, clear, getSnapshot и приведение к указателю-на-функцию:
class C1
{
    event m_onCompleted (); // dual type

    bool work ()
    {
        // ...
        m_onCompleted (); // OK, friends have multicast-access to m_onComplete
        return true;
    }
}

foo ()
{
    C1 c;
    c.m_onCompleted += completionFunc; // ok, aliens have event-access to m_onComplete
    c.m_onCompleted ();                // <-- error, 'call' is inaccessible
    c.m_onCompleted.clear ();          // <-- error, 'clear' is inaccessible
}


Свойства


В контексте языков программирования свойство (property) — это нечто, выглядящее и ведущее себя как данные, но при этом позволяющее выполнять некие дополнительные действия при считывании и записи. Без ложной скромности, Jancy предоставляет самую полную на сегодняшний день реализацию свойств любых форм, цветов и размеров.
Подробнее и с примерами

Определения


Функции, выполняющие действия при чтении и записи, называются аксессорами (accessors): аксессор чтения свойства называется геттером (getter), записи – сеттером (setter).

Каждое свойство в Jancy обладает одним геттером и опционально — одним или несколькими (перегруженными) сеттерами (т.е. write-only свойств в Jancy нет). Если сеттер перегружен, то выбор конкретного сеттера будет произведён во время присвоения значения свойству по тем же правилам, по которым производится выбор перегруженной функции.

Если свойство не имеет сеттера, то оно называется константным (const-property). В других языках программирования свойства без сеттеров обычно называются «только-для-чтения» (read-only), но так как в Jancy понятия const и readonly сосуществуют (readonly — дуальный модификатор), то переопределить устоявшиеся определения пришлось бы так или иначе. Итак, в Jancy свойство без сеттера — это const-свойство.

Простые свойства


Для простых свойств без перегруженных сеттеров (к которым сводится большинство практических задач) предлагается наиболее естественная форма объявления:
int property g_simpleProp;
int const property g_simpleConstProp;

Данная форма идеально подходит для объявления интерфейсов, или же если разработчик предпочитает принятый в C++ стиль разнесения объявления и реализации методов:
int g_simpleProp.get ()
{
    // ...
}

g_simpleProp.set (int x)
{
    // ...
}

int g_simpleConstProp.get ()
{
    // ...
}

Полная форма объявления


Для свойств произвольной сложности (т.е. свойств с перегруженными сеттерами, полями данных, вспомогательными методами и т.д.) имеется полная форма объявления:
property g_prop
{
    int m_x = 5; // member field with in-place initializer

    int get ()
    {
        return m_x;
    }

    set (int x) 
    {
        m_x = x;
        update ();
    }   
        
    set (double x); // overloaded setter
    update ();      // helper method
}

Индексируемые свойства


Jancy также поддерживает индексируемые свойства, т.е. свойства с семантикой массивов. Аксессоры таких свойств принимают дополнительные индексные аргументы. Однако в отличие от настоящих массивов, индексные аргументы свойств не обязаны быть целочисленными, и, строго говоря, не обязаны вообще иметь смысл «индекса» — их использование полностью определяется разработчиком:
int indexed property g_simpleProp (size_t i);

property g_prop
{
    int get (
        size_t i,
        size_t j
        );

    set (
        size_t i,
        size_t j,
        int x
        );

    set (
        size_t i,
        size_t j,
        double x
        );
}

foo ()
{
    int x = g_simpleProp [10];
    g_prop [x] [20] = 100;
}

Autoget-свойства


В подавляющем большинстве случаев геттер просто должен возвращать значение некоей переменной или поля, где хранится текущее значение свойства, а собственно логика поведения свойства воплощается в сеттере. Очевидно, что создание таких тривиальных геттеров можно переложить на компилятор — что и сделано в Jancy. Для autoget-свойств компилятор автоматически создаёт геттер и поле для хранения данных. Более того, компилятор генерирует прямой доступ к полю в обход геттера везде, где это возможно:
int autoget property g_simpleProp;

g_simpleProp.set (int x)
{
    m_value = x; // the name of a compiler-generated field is 'm_value'
}

property g_prop
{
    int autoget m_x; // declaring an autoget field makes the whole property autoget

    set (int x);
    set (double x);
}

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

В применении к реактивному программирования наибольший интерес представляют связываемые свойства, т.е. свойства, которые могут оповещать подписчиков о своих изменениях. Как можно догадаться, для реализации связываемых свойств в Jancy используется механизм мультикастов/событий:
int autoget bindable property g_simpleProp;

g_simpleProp.set (int x)
{   
    if (x == m_value)
        return;

    m_value = x;
    m_onChanged (); // the name of a compiler-generated bindable event is 'm_onChanged'
}

property g_prop
{
    autoget int m_x;
    bindable event m_e (); // declaring a bindable event makes the whole property bindable

    set (int x);
    set (double x);
}

Для доступа к событиям, оповещающим об изменениях связываемого свойств, применяется оператор bindingof:
onSimplePropChanged ()
{
    // ...
}

foo ()
{
    bindingof (g_simpleProp) += onSimplePropChanged;
    g_simpleProp = 100; // bindable event is going to get fired
}

Jancy также поддерживает связываемые свойства с полностью сгенерированными компилятором аксессорами — и геттером, и сеттером. Эти в некотором роде дегенеративные свойства называются связываемые данные (bindable data). Они служат единственной цели — отлавливать момент изменения — и могут выступать в роли простых observable-переменных/полей:
int bindable g_data;

onDataChanged ()
{
    // ...
}

foo ()
{
    bindingof (g_data) += onDataChanged;
    g_data = 100; // onDataChanged will get called
    g_data = 100; // onDataChanged will NOT get called
}

Реакторы


Реактор в Jancy — это зона реактивного кода. Все реактивные зависимости и неявные подписки локализованы внутри реакторов.

Внешне реактор выглядит как обычная функция, разве что в объявлении указан модификатор reactor. В отличие от функций каждый реактор создаёт переменную или поле особого реакторного класса с двумя публичными методами: start и stop, позволяющими запустить и остановить реактор. Вместо инструкций (statements), из которых состоит тело обычной функции, тело реактора состоит из последовательности выражений, каждое из которые должно использовать в своей правой части связываемые свойства:

State bindable m_state;

reactor m_uiReactor ()
{
    m_isTransmitEnabled = m_state == State.Connected;
    m_actionTable [ActionId.Disconnect].m_isEnabled = m_state != State.Closed;
    // ...
}

При старте реактор строит граф зависимостей и подписывается на все связываемые события всех «управляющих» свойств. При изменении любого из них все зависимые выражения пересчитываются (что, естественно, может вызвать лавинообразное изменение других свойств).
А что делать с циклическими зависимостями?
В настоящий момент циклические обновления зависимостей просто игнорируются — то есть, если какое-либо из управляющих свойств P изменилось и вызвало пересчёт выражений в реакторе, которые в свою очередь поменяли значение этого свойства P, то это повторное изменение не вызовет рекурсивного запуска вычислений в реакторе.

В дальнешем, скорее всего, у реакторов появятся настройки допустимой глубины рекурсии и стратегии восстановления, если глубина оказалась-таки превышена (игнорировать/останавливать реактор с ошибкой/вызывать некий callback/и т.д.)

Помимо реактивных выражений, в реакторах можно в интуитивно-понятном синтаксисе увязывать произвольные события и код их обработки. Для этого предусмотрена конструкция onevent. Данный подход позволяет использовать традиционный событийный подход к UI и в то же время избавляет от необходимости подписываться на события вручную:
reactor m_uiReactor ()
{
    onevent m_startButton.m_onClicked ()
    {
        // handle start button click...
    }

    onevent (bindingof (m_userEdit.m_text), bindingof (m_passwordEdit.m_text)) ()
    {
        // handle login change...
    }

    // ...
}

При останове реактор отписывается от всех событий, на которые подписан (если реактор является членом класса, то останов автоматически происходит в момент разрушения родительского объекта). Таким образом, разработчик имеет возможность детально определять и где использовать реактивный подход (зоны реакторов), и когда (старт/стоп). Все неявные подписки собраны воедино и сделать сакраментальное «горшочек не вари» очень легко:
m_uiReactor.stop ();

При этом, разумеется, может быть несколько реакторов, использующих одни и те же связываемые свойства и другие события — если это требуется для логической группировки зависимостей в некие кластеры.

Сводим всё воедино


Итак, у нас есть все кубики для того, чтобы собирать из них красивые фреймворки пользовательского интерфейса и использовать их в реактивном стиле:
Несколько UI-классов
class Widget
{
    bitflag enum SizePolicyFlag
    {
        Grow,
        Expand,
        Shrink,
        Ignore,
    }

    enum SizePolicy
    {
        Fixed = 0,
        Minimum = SizePolicyFlag.Grow,
        Maximum = SizePolicyFlag.Shrink,
        Preferred = SizePolicyFlag.Grow | SizePolicyFlag.Shrink,
        MinimumExpanding = SizePolicyFlag.Grow | SizePolicyFlag.Expand,
        Expanding = SizePolicyFlag.Grow| SizePolicyFlag.Shrink | SizePolicyFlag.Expand,
        Ignored = SizePolicyFlag.Shrink | SizePolicyFlag.Grow | SizePolicyFlag.Ignore
    }

    protected intptr m_handle;
    
    SizePolicy readonly m_hsizePolicy;
    SizePolicy readonly m_vsizePolicy;

    setSizePolicy (
        SizePolicy hpolicy,
        SizePolicy vpolicy
        );
    
    bool autoget property m_isVisible;
    bool autoget property m_isEnabled;
}

opaque class Label: Widget
{
    bitflag enum Alignment
    {
        Left,
        Right,
        HCenter,
        Justify,
        Absolute,
        Top,
        Bottom,
        VCenter,
    }

    char const* autoget property m_text;
    int autoget property m_color;
    int autoget property m_backColor;
    Alignment autoget property m_alignment;

    Label* operator new (char const* text);
}

opaque class Button: Widget
{
    char const* autoget property m_text;
    event m_onClicked ();

    Button* operator new (char const* text);
}

opaque class CheckBox: Widget
{
    char const* autoget property m_text;
    bool bindable property m_isChecked;

    CheckBox* operator new (char const* text);
}

opaque class TextEdit: Widget
{
    char const* property m_text;

    TextEdit* operator new ();
}

opaque class Slider: Widget
{
    int autoget property m_minimum;
    int autoget property m_maximum;
    int bindable property m_value;

    Slider* operator new (
        int minimum = 0,
        int maximum = 100
        );
}

Их использование из реактора
Slider* g_redSlider;
Slider* g_greenSlider;
Slider* g_blueSlider;

int bindable g_color;

Label* g_colorLabel;

CheckBox* g_enablePrintCheckBox;
TextEdit* g_textEdit;
Button* g_printButton;

int calcColorVolume (int color)
{
    return 
        (color & 0xff) + 
        ((color >> 8) & 0xff) + 
        ((color >> 16) & 0xff);
}

reactor g_uiReactor ()
{
    g_color = 
        (g_redSlider.m_value << 16) |
        (g_greenSlider.m_value << 8) |
        (g_blueSlider.m_value);

    g_colorLabel.m_text = $"#$(g_color;06x)";       
    g_colorLabel.m_backColor = g_color;
    g_colorLabel.m_color = calcColorVolume (g_color) > 0x180 ? 0x000000 : 0xffffff;

    g_textEdit.m_isEnabled = g_enablePrintCheckBox.m_isChecked;
    g_printButton.m_isEnabled = g_enablePrintCheckBox.m_isChecked;

    onevent g_printButton.m_onClicked ()
    {
        printf ($"> $(g_textEdit.m_text)\n");
    }
}

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

Желающие таки скачать и cобрать/просто покопаться в исходниках Jancy, могут сделать это со странички скачивания. Кстати, в папке samples/02_dialog лежит приведённый чуть выше пример навешивания реактивности на QT-виджеты.

А на реальное применение Jancy и его реактивных возможностей можно посмотреть в нашем программируемом терминале/сниффере IO Ninja.
Теги:
Хабы:
+4
Комментарии2

Публикации

Информация

Сайт
tibbo.com
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия

Истории