Почему мы должны использовать Поведение в FRP

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

Библиотека UI, которую я использую, - это Gtk, но это не относится к объяснению.

Вот очень простая реализация, с которой я столкнулся:

import Graphics.UI.Gtk
import Reactive.Banana
import Reactive.Banana.Frameworks

makeNetworkDescription addEvent = do
    eClick <- fromAddHandler addEvent
    reactimate $ (putStrLn . show) <$> (accumE 0 ((+1) <$ eClick))

main :: IO ()
main = do
    (addHandler, fireEvent) <- newAddHandler
    initGUI
    network <- compile $ makeNetworkDescription addHandler
    actuate network
    window <- windowNew
    button <- buttonNew
    set window [ containerBorderWidth := 10, containerChild := button ]
    set button [ buttonLabel := "Add One" ]
    onClicked button $ fireEvent ()
    onDestroy window mainQuit
    widgetShowAll window
    mainGUI

Это просто выдает результат в оболочке. Я подошел к этому решению, прочитав статью статью Heinrich Apfelmus. Обратите внимание, что в моем примере я не использовал ни одного Behavior.

В статье приведен пример сети:

makeNetworkDescription addKeyEvent = do
    eKey <- fromAddHandler addKeyEvent
    let
        eOctaveChange = filterMapJust getOctaveChange eKey
        bOctave = accumB 3 (changeOctave <$> eOctaveChange)
        ePitch = filterMapJust (`lookup` charPitches) eKey
        bPitch = stepper PC ePitch
        bNote = Note <$> bOctave <*> bPitch
    eNoteChanged <- changes bNote
    reactimate' $ fmap (\n -> putStrLn ("Now playing " ++ show n))
               <$> eNoteChanged

В примере показан a stepper, который преобразует Event в Behavior и возвращает Event с помощью changes. В приведенном выше примере мы могли бы использовать только Event, и я предполагаю, что это не имело бы никакого значения (если я не что-то не понимаю).

Так может кто-то может пролить свет на то, когда использовать Behavior и почему? Должны ли мы преобразовать все Event как можно скорее?

В моем маленьком эксперименте я не вижу, где можно использовать Behavior.

Спасибо

Ответ 1

В любое время FRP-сеть "делает что-то" в Reactive Banana, потому что она реагирует на какое-то событие ввода. И единственный способ, которым он делает что-либо наблюдаемое вне системы, - это подключить внешнюю систему для реагирования на события, которые она создает (используя reactimate).

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

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

An Event имеет вхождения; конкретные моменты времени, когда он имеет значение. A Behaviour имеет значение во всех точках времени, без каких-либо мгновений времени, которые являются особыми (за исключением changes, что удобно, но вроде разложения модели).

Простым примером, знакомым со многими графическими интерфейсами, было бы, если бы я захотел отреагировать на щелчки мыши и щелкнуть мышкой, сделав что-то отличное от щелчка, когда клавиша смены не удерживается. Если Behaviour содержит значение, указывающее, удерживается ли клавиша сдвига, это тривиально. Если бы у меня только Event для нажатия клавиши shift/release и для щелчков мыши это было намного сложнее.

В дополнение к тому, чтобы быть более сложным, он намного более низкий. Почему мне нужно делать сложную работу, чтобы реализовать простую концепцию, такую ​​как shift-click? Выбор между Behaviour и Event является полезной абстракцией для реализации ваших программных концепций в терминах, которые более тесно связаны с тем, как вы думаете о них вне мира программирования.

Примером здесь может быть подвижный объект в игровом мире. Я мог бы отображать Event Position все время, в которое он движется. Или я мог бы просто показать Behaviour Position, где он всегда. Обычно я буду думать о том, что объект всегда имеет позицию, поэтому Behaviour является лучшим концептуальным подходом.

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

В качестве примера предположим, что ваша программа должна следить за датчиком температуры и не запускать работу при слишком высокой температуре. С помощью Event Temperature я буду решать, как часто опросить датчик температуры (или в ответ на что). И тогда есть все те же проблемы, что и в других моих примерах, связанных с необходимостью вручную сделать что-то, чтобы сделать последнее чтение температуры доступным для события, которое решает, начинать или нет задание. Или я мог бы использовать fromPoll для создания Behaviour Temperature. Теперь у меня есть значение, представляющее изменяющееся во времени значение температуры, и я полностью отвлекся от опроса датчика; Реактивный банан сам по себе заботится о том, чтобы опросить датчик так часто, как это могло бы потребоваться, без того, чтобы мне вообще не было необходимости в какой-либо логике!

Ответ 2

Behavior имеют значение все время, тогда как Event имеет значение только в один момент.

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

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

stepper предназначен для изменения вещей, которые происходят в значениях в ячейках, а change - для просмотра ячеек и запуска действий. Ваш пример, когда вывод текста в командной строке не зависит от отсутствия постоянных данных, потому что выход приходит в пакетах в любом случае.

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

Не нужно иметь Behaviors, и аналогичным образом вы можете запрограммировать электронную таблицу Excel полностью в VBA без каких-либо формул. Это еще лучше с постоянством данных и экваториальной спецификацией. Когда вы привыкнете к новой парадигме, вы не захотите вернуться к ручным искажениям зависимостей и обновлению.

Ответ 3

Если у вас есть только 1 Event или несколько событий, которые происходят одновременно, или несколько событий одного типа, легко просто union или иным образом объединить их в результирующее событие, затем перейти к reactimate и сразу же вывести его. Но что, если у вас есть 2 события из 2 разных типов, происходящих в разное время? Тогда объединение их в результирующее Событие, которое вы можете передать на reactimate, становится ненужным осложнением.

Я рекомендую вам на самом деле попробовать и реализовать синтезатор из FRP-объяснения с использованием реактивного банана только с событиями и без поведения, вы быстро посмотрите, что Behaviors упрощают ненужные манипуляции с событиями.

Скажем, у нас есть 2 события, выводящие Octave (синоним типа Int) и Pitch (синоним типа Char). Пользователь нажимает клавиши от a до g для установки текущего тонального сигнала или нажимает + или - для увеличения или уменьшения текущей октавы. Программа должна выводить текущую ось и текущую октаву, например a0, b2 или f7. Пусть говорят, что пользователь нажал эти клавиши в разных комбинациях в разное время, поэтому мы закончили с двумя потоками событий (События):

   +     -     +   -- octave stream (time goes from left to right)
     b     c       -- pitch stream

Каждый раз, когда пользователь нажимает клавишу, мы выводим текущую октаву и высоту тона. Но каково должно быть событие результата? Предположим, что шаг по умолчанию a, а октава по умолчанию - 0. Мы должны создать поток событий, который выглядит следующим образом:

  a1 b1 b0 c0 c1   -- a1 corresponds to + event, b1 to b, b0 to -, etc

Простой ввод/вывод символов

Попробуйте реализовать синтезатор с нуля и посмотрим, можем ли мы сделать это без Behaviors. Пусть сначала напишите программу, где вы поместите символ, нажмите Enter, программа выведет его и снова запросит символ:

import System.IO
import Control.Monad (forever)

main :: IO ()
main = do
  -- Terminal config to make output cleaner
  hSetEcho stdin False
  hSetBuffering stdin NoBuffering
  -- Event loop
  forever (getChar >>= putChar)

Простая сеть событий

Проделайте это, но с помощью сети событий, чтобы проиллюстрировать их.

import Control.Monad (forever)
import System.IO (BufferMode(..), hSetEcho, hSetBuffering, stdin)

import Control.Event.Handler (newAddHandler)
import Reactive.Banana
import Reactive.Banana.Frameworks

makeNetworkDescription :: Frameworks t => AddHandler Char -> Moment t ()
makeNetworkDescription myAddHandler = do
  event <- fromAddHandler myAddHandler
  reactimate $ putChar <$> event

main :: IO ()
main = do
  -- Terminal config to make output cleaner
  hSetEcho stdin False
  hSetBuffering stdin NoBuffering
  -- Event loop
  (myAddHandler, myHandler) <- newAddHandler
  network <- compile (makeNetworkDescription myAddHandler)
  actuate network
  forever (getChar >>= myHandler)

Сеть - это то, где все ваши события и поведение живут и взаимодействуют друг с другом. Они могут делать это только внутри Moment монадического контекста. В учебном пособии Функциональное реактивное программирование руководства для начинающих аналогия для event-network - это мозг человека. Человеческий мозг - это то, где все потоки событий и поведение чередуются друг с другом, но единственный способ получить доступ к мозгу - через рецепторы, которые действуют как источник события (вход).

Теперь, прежде чем мы продолжим, внимательно изучите типы наиболее важных функций приведенного выше фрагмента:

type Handler a = a -> IO ()
newtype AddHandler a = AddHandler { register :: Handler a -> IO (IO ()) }
newAddHandler :: IO (AddHandler a, Handler a)
fromAddHandler :: Frameworks t => AddHandler a -> Moment t (Event t a)
reactimate :: Frameworks t => Event t (IO ()) -> Moment t ()
compile :: (forall t. Frameworks t => Moment t ()) -> IO EventNetwork
actuate :: EventNetwork -> IO ()

Поскольку мы используем самый простой пользовательский интерфейс - ввод/вывод символов, мы собираемся использовать модуль Control.Event.Handler, предоставленный Reactive-banana, Обычно библиотека GUI выполняет эту грязную работу для нас.

Функция типа Handler - это просто действие IO, подобное другим IO-действиям, таким как getChar или putStrLn (например, последний имеет тип String -> IO ()). Функция типа Handler принимает значение и выполняет с ним некоторое вычисление ввода-вывода. Таким образом, его можно использовать только в контексте IO (например, в main).

Из типов это очевидно (если вы понимаете основы монадов), что fromAddHandler и reactimate может использоваться только в контексте Moment (например, makeDescriptionNetwork), а newAddHandler, compile и actuate можно использовать только в контексте IO (например, main).

Вы создаете пару значений типов AddHandler и Handler с помощью newAddHandler в main, вы передаете эту новую функцию AddHandler в свою сеть событий, где вы можете создать поток событий его использования с помощью fromAddHandler. Вы управляете этим потоком событий столько, сколько хотите, а затем переносите его события в действие IO и передаете результирующий поток событий в reactimate.

Фильтрация событий

Теперь давайте только выводить что-либо, если пользователь нажимает + или -. Пусть выдается 1, когда пользователь нажимает +, -1, когда пользователь нажимает -. (Остальная часть кода остается той же).

action :: Char -> Int
action '+' = 1
action '-' = (-1)
action  _  = 0

makeNetworkDescription :: Frameworks t => AddHandler Char -> Moment t ()
makeNetworkDescription myAddHandler = do
  event <- fromAddHandler myAddHandler
  let event' = action <$> filterE (\e -> e=='+' || e=='-') event
  reactimate $ putStrLn . show <$> event'

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

action :: Char -> Maybe Int
action '+' = Just 1
action '-' = Just (-1)
action  _  = Nothing

makeNetworkDescription :: Frameworks t => AddHandler Char -> Moment t ()
makeNetworkDescription myAddHandler = do
  event <- fromAddHandler myAddHandler
  let event' = filterJust . fmap action $ event
  reactimate $ putStrLn . show <$> event'

Важные функции для манипуляций с событиями (подробнее см. Reactive.Banana.Combinators):

fmap :: Functor f => (a -> b) -> f a -> f b
union :: Event t a -> Event t a -> Event t a
filterE :: (a -> Bool) -> Event t a -> Event t a
accumE :: a -> Event t (a -> a) -> Event t a
filterJust :: Event t (Maybe a) -> Event t a

Накопление приращений и декрементов

Но мы не хотим просто выводить 1 и -1, мы хотим увеличивать и уменьшать значение и запоминать его между нажатиями клавиш! Поэтому нам нужно accumE, accumE принимает значение и поток функций типа (a -> a). Каждый раз, когда появляется новая функция из этого потока, она применяется к значению, и результат запоминается. В следующий раз, когда появится новая функция, она применяется к новому значению и так далее. Это позволяет нам помнить, какое число мы в настоящее время должны уменьшать или увеличивать.

makeNetworkDescription :: Frameworks t => AddHandler Char -> Moment t ()
makeNetworkDescription myAddHandler = do
  event <- fromAddHandler myAddHandler
  let event' = filterJust . fmap action $ event
      functionStream = (+) <$> event' -- is of type Event t (Int -> Int)
  reactimate $ putStrLn . show <$> accumE 0 functionStream

functionStream - это в основном поток функций (+1), (-1), (+1), в зависимости от того, какой ключ нажал пользователь.

Объединение двух потоков событий

Теперь мы готовы реализовать как октавы, так и шаг из исходной статьи.

type Octave = Int
type Pitch = Char

actionChangeOctave :: Char -> Maybe Int
actionChangeOctave '+' = Just 1
actionChangeOctave '-' = Just (-1)
actionChangeOctave  _  = Nothing

actionPitch :: Char -> Maybe Char
actionPitch c
  | c >= 'a' && c <= 'g' = Just c
  | otherwise = Nothing

makeNetworkDescription :: Frameworks t => AddHandler Char -> Moment t ()
makeNetworkDescription addKeyEvent = do
  event <- fromAddHandler addKeyEvent
  let eChangeOctave = filterJust . fmap actionChangeOctave $ event
      eOctave = accumE 0 ((+) <$> eChangeOctave)
      ePitch = filterJust . fmap actionPitch $ event
      eResult = (show <$> ePitch) `union` (show <$> eOctave)
  reactimate $ putStrLn <$> eResult

В нашей программе будет отображаться либо текущая высота тона, либо текущая октава, в зависимости от того, что нажал пользователь. Он также сохранит значение текущей октавы. Но ждать! Это не то, что мы хотим! Что делать, если мы хотим вывести как текущую, так и текущую октаву, каждый раз, когда пользователь нажимает либо букву, либо + или -?

И здесь он становится супер-тяжелым. Мы не можем объединить 2 потока событий разных типов, поэтому мы можем преобразовать их оба в Event t (Pitch, Octave). Но если событие шага и октавное событие происходят в разное время (т.е. Они не являются одновременными, что практически очевидно в нашем примере), то наш временный поток событий скорее будет иметь тип Event t (Maybe Pitch, Maybe Octave), причем Nothing везде, где вы находитесь Соответствующее событие. Поэтому, если пользователь нажимает в последовательности + b - c +, и мы предполагаем, что октава по умолчанию равна 0, а шаг по умолчанию - a, тогда мы получаем последовательность пар [(Nothing, Just 1), (Just 'b', Nothing), (Nothing, Just 0), (Just 'c', Nothing), (Nothing, Just 1)], завернутую в Event.

Затем мы должны выяснить, как заменить Nothing тем, каков будет текущий шаг или октава, поэтому результирующая последовательность должна быть чем-то вроде [('a', 1), ('b', 1), ('b', 0), ('c', 0), ('c', 1)].

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

Поведение упрощает манипуляции с событиями

Несколько простых модификаций, и мы достигли того же результата.

makeNetworkDescription :: Frameworks t => AddHandler Char -> Moment t ()
makeNetworkDescription addKeyEvent = do
  event <- fromAddHandler addKeyEvent
  let eChangeOctave = filterJust . fmap actionChangeOctave $ event
      bOctave = accumB 0 ((+) <$> eChangeOctave)
      ePitch = filterJust . fmap actionPitch $ event
      bPitch = stepper 'a' ePitch
      bResult = (++) <$> (show <$> bPitch) <*> (show <$> bOctave)
  eResult <- changes bResult
  reactimate' $ (fmap putStrLn) <$> eResult

Поверните шаг Событие в Поведение с stepper и замените accumE на accumB, чтобы получить октавное поведение вместо октавного события. Чтобы получить результат, используйте прикладной стиль.

Затем, чтобы получить событие, вы должны перейти к reactimate, передать полученное поведение changes. Однако changes возвращает сложное монадическое значение Moment t (Event t (Future a)), поэтому вы должны использовать reactimate' вместо reactimate. Это также является причиной того, почему вы должны поднять putStrLn в приведенном выше примере дважды на eResult, потому что вы поднимаете его на Future функтор внутри функтора Event.

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

stepper :: a -> Event t a -> Behavior t a
accumB :: a -> Event t (a -> a) -> Behavior t a
changes :: Frameworks t => Behavior t a -> Moment t (Event t (Future a))
reactimate' :: Frameworks t => Event t (Future (IO ())) -> Moment t ()