当您只有 1 个Event,或同时发生的多个事件,或同一类型的多个事件时,只需 union 或将它们组合成一个结果事件,然后传递给 reactimate 和立即输出。但是,如果您有 2 种不同类型的 2 种事件在不同时间发生怎么办?然后将它们组合成一个可以传递给reactimate 的结果事件就变得不必要了。
我建议您实际尝试从 FRP explanation using reactive-banana 实现合成器,仅使用事件而没有行为,您会很快看到行为简化了不必要的事件操作。
假设我们有 2 个事件,输出 Octave(类型为 Int 的同义词)和 Pitch(类型为 Char 的同义词)。用户按 a 到 g 键设置当前音高,或按 + 或 - 增加或减少当前音高八度。程序应输出当前音高和当前八度,如a0、b2 或f7。假设用户在不同的时间以不同的组合按下这些键,所以我们最终得到了 2 个这样的事件流(Events):
+ - + -- 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 的情况下实现它。我们先写一个程序,放一个字符,按回车,程序输出,又要求输入一个字符:
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 monadic 上下文中做到这一点。在教程Functional Reactive Programming kick-starter guide 中,事件网络的类比是人脑。人脑是所有事件流和行为相互交错的地方,但访问大脑的唯一方法是通过充当事件源(输入)的受体。
现在,在我们继续之前,仔细检查一下上面sn-p最重要的函数的类型:
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 ()
因为我们使用了最简单的用户界面——字符输入/输出,所以我们将使用由 Reactive-banana 提供的模块Control.Event.Handler。通常,GUI 库会为我们完成这项肮脏的工作。
Handler 类型的函数只是一个 IO 操作,类似于其他 IO 操作,例如 getChar 或 putStrLn(例如,后者的类型为 String -> IO ())。 Handler 类型的函数接受一个值并使用它执行一些 IO 计算。因此它只能在 IO 上下文中使用(例如在main 中)。
从类型很明显(如果您了解 monad 的基础知识)fromAddHandler 和 reactimate 只能在 Moment 上下文中使用(例如 makeDescriptionNetwork),而 newAddHandler、compile 和 @987654332 @ 只能在IO 上下文中使用(例如main)。
您在main 中使用newAddHandler 创建一对AddHandler 和Handler 类型的值,您将这个新的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 以获得八度行为而不是八度事件。要获得结果行为,请使用applicative style。
然后,要获取必须传递给reactimate 的事件,将生成的行为传递给changes。但是,changes 返回一个复杂的一元值Moment t (Event t (Future a)),因此您应该使用reactimate' 而不是reactimate。这也是为什么您必须将上述示例中的putStrLn 两次提升为eResult 的原因,因为您将其提升到Event 函子内的Future 函子。
查看我们在此处使用的函数的类型,以了解其中的内容:
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 ()