ICFP2015に論文が出るのだそうで、局所的に話題になっているFRPNowというライブラリ。
ちょっと触ってみたので、感触などレポート。
とりあえずFizzBuzz
{-# LANGUAGE MultiWayIf #-}
module Main where
import Control.FRPNow
import Data.Functor ((<$), (<$>))
import Control.Monad (join)
streaming ::
(EvStream a -> Behavior (EvStream b, Event ())) ->
IO a ->
([b] -> IO ()) ->
IO ()
streaming b ioA ioB = runNowMaster $
do
(esA, cbA) <- callbackStream
(esB, evE) <- sampleNow (b esA)
loop cbA esB
return evE
where
loop cbA esB =
do
eys <- sampleNow (nextAll esB)
_ <- async (ioA >>= cbA)
eev <- planNow $ async . ioB <$> eys
planNow $ loop cbA esB <$ join eev
return ()
mainB ::
EvStream () -> Behavior (EvStream String, Event ())
mainB inEs =
do
countUp <- foldEs (+) 0 (1 <$ inEs)
end <- when ((>100) <$> countUp)
return (toFizzBuzz countUp `snapshots` inEs, end)
where
toFizzBuzz :: Behavior Int -> Behavior String
toFizzBuzz bX =
do
x <- bX
let fizz = x `mod` 3 == 0
buzz = x `mod` 5 == 0
return $ if
| fizz && buzz -> "FizzBuzz"
| fizz -> "Fizz"
| buzz -> "Buzz"
| otherwise -> show x
main = streaming mainB (return ()) (mapM_ putStrLn)
streaming関数は汎用的に書いたドライバで、ロジック部分はmainBに集約されている。ロジックの表現としては、それなりに良い感じなのではないか、と思う。
FRPの基礎概念
ここでFRPの基礎をなす概念、EventとBehaviorについて簡単に触れる。
- Behavior
- 時間に伴って変化する値。Behavior aはすなわちTime -> aの意。
- Event
- 時間の中の一点にある値。Event aはだいたい(Time, a)。
なお、このEventの定義はSodiumやYampaといった既存のFRPと少し異なる。SodiumやYampaのEventは時系列上に何回でも発生する事ができる(式で表すとEvent a 〜 [(Time, a)]
みたいな感じ)のに対し、FRPNowのEventは一度きりしか起きない。どちらかというとこれは、他の言語のpromise/futureに近いものであり、実際そういう使い方ができる事を後で示す。
FRPNowでは、普通のFRPのEvent a
に相当するものとして、EvStream a
という型が別途定義されている。
mainBの定義
それを踏まえるとmainBは、時間に伴って流れてくる()
の列を受け取り、Stringの列と一つのイベントのペアを返す関数である事が分かる……と言いたい所だが、戻り値がBehaviorに包まれているのは一体何であろうか。
実を言うと、概念的には戻り値は(EvStream String, Event ())
でも問題ないのだが、foldEsやwhenという関数がBehaviorに包まれた値を返してくるので、mainBもそれに伴ってBehaviorに包んだ値を返さざるを得ないのである。
なぜ余計なBehaviorで包むのかという、この点こそFRPNowの元論文の肝なので、興味のある方は直接読んで頂くのをお薦めする。だが、実プログラミング上でこの点を気にする必要はあまりない。Behaviorはモナドになっているので、returnや>>=を使えば簡単にBehavior型の値が作れるし、実行段階(後述するNowモナドの中)においてはいつでも、sampleNowという関数でBehaviorを剥がす事が可能だからだ。
さて、mainBの内容だが、()の列を受け取ると、まずはfoldEs関数を使ってcountUpというBehaviorを構築している。
foldEs :: (a -> b -> a) -> a -> EvStream b -> Behavior (Behavior a)
これは初期値aに対し、ストリームの値を次々に二項演算で適用していったBehaviorを示す。
開始時点では0、それから()が一つ来る度に0+1、0+1+1、0+1+1+1という具合に数字が増えていくのである。
あとはロジックに従ってcountUpをFizzBuzz文字列に直し、それをsnapshotsでストリームに直したものを第一の戻り値としている。
第二の戻り値は停止条件を表す。
end <- when ((>100) <$> countUp)
という、いかにもそのままの定義だが、「countUpが100を超える瞬間」を第二の戻り値としている。
streamingの定義
streaming関数は、駆動すべきストリームb、入力値を受け取るためのアクションioA、出力値を処理するためのアクションioBを受け取り、bの第二戻り値が示す終端イベントが発生するまでの間、延々と処理を繰り返す関数である。
ioAに、たとえばgetLineでも渡してやる事によって、インタラクティブなCUIアプリケーションも作れるようにしたのだが、FizzBuzzは入力を取らないので、main関数では単に(return ())を渡し、延々と()を流すだけの入力ストリームとしている。出力にはputStrLnで画面に表示する処理を渡す。
そして、streaming関数の処理のキーとなるのはFRPNowで新星の如く現れた新概念、Nowモナドである。
NowモナドはFRPNowにおいて唯一の、外界と繋がる事ができるモナドであるが、Nowモナドのチェーンに含まれるものは全て同時に行われたとみなす点がIOモナドと異なる。このために、時間のかかるIOアクションの実行にはasyncを用いる。
async :: IO a -> Now (Event a)
ここで思い出して頂きたいのは、「Eventはpromise/futureに似ている」という話である。まさしく、asyncの実行結果は他言語でいうところのfutureであるEvent型に包まれて返ってくる。
となるとここに、futureに続く処理を登録するような何かがあるはずであるが、これは以下の関数を使用する。
planNow :: Event (Now a) -> Now (Event a)
Eventに包んだアクションを「予約」して、その戻り値をまたEventとして受け取る訳である。
streamingのloop関数では、まず出力ストリームesBの「次の値」nextAllの取得を予約したあと、ioA>>=cbAをasyncに実行している。cbAは値を入力ストリームに渡すためのコールバック関数であり、これによってシグナルネットワークが稼働して出力ストリームesBから値が出てくる。これらの非同期処理の結果はeysに入ってくるので、さらに非同期アクションioBの実行を予約。それが済んだらloopを再度実行、以下繰り返す。
「次の値」nextAllは、もちろん実行のその時々によって変化するから、その戻り値はBehavior (Event [a])
という風に、時間変化するイベントになっている。Nowモナドの中では、Behaviorの「現時点での値」をsampleNow
で取得する事ができる。loopの呼び出し時刻は、planNowによって少しずつ未来に遅延されていくから、sampleNowで取得できる値も少しずつ未来のものになる。これによって、ストリームの値を時間を時系列順に列挙できる、という仕組みである。
他のFRPとの比較
先ほど、色々なものがわざわざBehavior型に包まれて返ってくるのは気にする必要はない、というくだりがあったが、実はこの点は他のFRPライブラリと比べてちょっとしたアドバンテージになっている。
例えば、SodiumでfoldEsに相当するaccum関数の定義は以下の通り。
accum :: a -> Event (a -> a) -> Reactive (Behavior a)
戻り値はReactive型(一般的な論文ではgeneratorと称されるもの)に包まれているが、これはFRPNowのNow型に相当し、IOアクションの実行が可能である(但し非同期実行の機能はなく、IOアクションは同期的に実行される)。こういう「副作用はないが、直近の入力値以上の何かに依存する」形のシグナル関数を、わざわざIOアクションが可能なモナドに包んで返すというのは、純粋性という観点からちょっと問題に感じる。
そこで、こういったものをgeneratorという概念を使わず、かつ既存のBehavior型で表現できるというのは、なかなか良いアイデアなのではないだろうか。最初に挙げた論文のredditコメントでも、その点について少し議論が行われている(同じ物が違う文脈で出てきて分かりづらいよ、というツッコミもあるが)。
まとめ
- FRPNowは既存のFRPと同様にBehaviorやEventの概念を扱えるが、Behavior/Event層と生IO層の間にpromise/futureのような層があるのが面白い
- いろいろなものがBehaviorに包まれて返ってくるが、慣れればどうという事はないしむしろ便利