リアクティブプログラミング

ご存知のように、プログラミングへの機能的アプローチには固有の特徴があります。データを変更するのではなく、データを変換します。 しかし、これは、たとえばユーザーと積極的に対話するプログラムを作成する場合など、制限を課します。 命令型言語では、あらゆるイベントに「リアルタイム」で応答できるため、この動作を実装するのがはるかに簡単です。一方、純粋な関数型言語では、システムとの通信を最後まで延期する必要があります。 ただし、この問題を解決する新しいプログラミングパラダイムが比較的最近開発され始めています。 そして彼女の名前はFunctional Reactive Programming (FRP)です。 この記事では、Reactive-bananaライブラリーを使用してHaskellでヘビを書くことにより、FRPの基本を説明します。



この記事の残りの部分では、読者がファンクターに精通していることを前提としています。 そうでない場合は、記事全体の理解がこれに依存するため、それらに精通することを強くお勧めします。



主なアイデア



FRPには、 EventBehaviorの 2つの新しいデータタイプが表示されます。 これらのタイプは両方ともファンクターであり、それらに対する多くのアクションはファンクターのコンビネーターによって実行されます。 これらのタイプについて説明します。



イベント


イベントは、正確なタイムスタンプを持つイベントのストリームです。 次のように想像することができます(実際にはすべてがそれほど単純ではないため、想像してください):

type Event a = [(Time, a)]
      
      



たとえば、イベント文字列は、チャットのユーザーに関するイベントのストリームです。

既に述べたように、イベントはファンクターのクラスに属します。つまり、イベントを使用していくつかのアクションを実行できます。

例:

 ("Wellcome, " ++) <$> eusers
      
      



チャットに入ったユーザーからの挨拶のストリームを作成します。



振る舞い


動作とは、時間とともに変化する値を意味します。

 type Behavior a = Time -> a
      
      



このタイプはゲームオブジェクトに適しています。ゲームの蛇は行動になります。

BehaviorとEventをapply関数と組み合わせることができます:

 apply :: Behavior t (a -> b) -> Event ta -> Event tb apply bf ex = [(time, bf time x) | (time, x) <- ex]
      
      



この定義からわかるように、時間を考慮して、applyはBehavior内の関数をEventsに適用します。



私たちはヘビに直接進みます。



ゲームの仕組み



今のところ、リアクティブプログラミングを忘れて、ゲームの仕組みを取り上げてください。 まず、タイプは次のとおりです。

 module Snake where type Segment = (Int, Int) type Pos = (Int, Int) type Snake = [Segment]
      
      



ヘビの1つのセグメントは座標のペアであり、ヘビ自体はこれらのセグメントのチェーンです。 タイプPosは、便宜上のものです。



 startingSnake :: Snake startingSnake = [(10, 0), (11, 0), (12, 0)] wdth = 64 hdth = 48
      
      



ヘビの初期位置と競技場のサイズの定数を作成します。



 moveTo :: Pos -> Snake -> Snake moveTo hs = if h /= head s then h : init s else s keepMoving :: Snake -> Snake keepMoving s = let (x, y) = head s (x', y') = s !! 1 in moveTo (2*x - x', 2*y - y') s ifDied :: Snake -> Bool ifDied s@((x, y):_) = x<0 || x>=wdth || y<0 || y>=hdth || head s `elem` tail s
      
      



moveTo関数はヘビを指定された位置に移動し、keepMovingは移動を続け、ifDiedはヘビが自食または境界との衝突で死んでいるかどうかをチェックします。

これがメカニズムの終わりであり、今最も困難な部分、行動の論理が先にあります。



ロジック



必要なモジュールを接続し、いくつかの定数を説明します。

 {-# LANGUAGE ScopedTypeVariables #-} import Control.Monad (when) import System.IO import System.Random import Graphics.UI.SDL as S hiding (flip) import Graphics.Rendering.OpenGL hiding (Rect, get) import Reactive.Banana as R import Data.Word (Word32) import Snake screenWidth = wdth*10 screenHeight = hdth*10 screenBpp = 32 ticks = 1000 `div` 20
      
      



screenWidth、screenHeight-画面の幅と高さ、それぞれticks-フレームが画面上に残るミリ秒数。



それでは、入力を決めましょう。 外の世界からは、キープレスとクロック信号の2つのイベントのみが私たちに届きます。 したがって、イベントに必要な「スロット」は2つだけであり、それらはnewAddHandler関数によって作成されます。

 main :: IO () main = withInit [InitEverything] $ do initScreen sources <- (,) <$> newAddHandler <*> newAddHandler network <- compile $ setupNetwork sources actuate network eventLoop sources network
      
      



setupNetworkでは、イベントと動作の「ネットワーク」が構築され、コンパイルはNetworkDescriptionをEventNetworkにコンパイルし、actuateはそれを起動します。 受容体から脳への信号のように、イベントはeventLoop関数からネットワークに送信されます。



 eventLoop :: (EventSource SDLKey, EventSource Word32) -> EventNetwork -> IO () eventLoop (essdl, estick) network = loop 0 Nothing where loop lt k = do s <- pollEvent t <- getTicks case s of (KeyDown (Keysym key _ _)) -> loop t (Just key) NoEvent -> do maybe (return ()) (fire essdl) k fire estick t loop t Nothing _ -> when (s /= Quit) (loop tk)
      
      



これがプログラムの「受容体」です。 fire essdl-キーが押された場合、キーの名前を含むessdlイベントを起動します。 estickはユーザーの行動に関係なく起動し、プログラムの開始からの時間を運びます。



ところで、newAddHandlerを返すEventSourceからAddHandlerへの遷移は次のとおりです。

 type EventSource a = (AddHandler a, a -> IO ()) addHandler :: EventSource a -> AddHandler a addHandler = fst fire :: EventSource a -> a -> IO () fire = snd
      
      





それでは、最も重要な部分、つまりイベントのネットワークの説明から始めましょう。



 setupNetwork :: forall t. (EventSource SDLKey, EventSource Word32) -> NetworkDescription t () setupNetwork (essdl, estick) = do -- Keypress and tick events esdl <- fromAddHandler (addHandler essdl) etick <- fromAddHandler (addHandler estick)
      
      



最初に、eventLoopで起動したタイマーイベントとキーボードイベントからイベントを取得します。



 let ekey = filterE (flip elem [SDLK_DOWN, SDLK_UP, SDLK_LEFT, SDLK_RIGHT]) esdl moveSnake :: SDLKey -> Snake -> Snake moveSnake ks = case k of SDLK_UP -> moveTo (x, y-1) s SDLK_DOWN -> moveTo (x, y+1) s SDLK_LEFT -> moveTo (x-1, y) s SDLK_RIGHT -> moveTo (x+1, y) s where (x, y) = head s
      
      



次に、矢印を押すことを意味するイベントを作成しましょう。他のキーは必要ありません。 おそらく既に推測したように、filterEは、述語を満たさないイベントを除外します。 moveSnakeは、押されたキーに基づいて単純にヘビを移動します。



 brandom <- fromPoll randomFruits -- Snake let bsnake :: Behavior t Snake bsnake = accumB startingSnake $ (const startingSnake <$ edie) `union` (moveSnake <$> ekey) `union` (keepMoving <$ etick) `union` ((\s -> s ++ [last s]) <$ egot) edie = filterApply ((\s _ -> ifDied s) <$> bsnake) etick
      
      



fromPollは、実世界と対話するさらに別の方法を実装しますが、以前に使用したものとは異なります。 最初に、イベントではなく動作を取得します。 2番目に、fromPollのアクションにコストがかからないようにします。 たとえば、変数と組み合わせてfromPollを使用すると便利です。

さらに、accumBを使用してスネークを記述します(スネークのタイプは単にBehavior SnakeではなくBehavior t Snakeであることに注意してください。これには深い意味があります。

accumBは、イベントおよび初期値から動作を「収集」します。

 accumB :: a -> Event t (a -> a) -> Behavior ta
      
      



つまり、大まかに言えば、イベントが発生すると、その中の関数が現在の値に適用されます。

例:

 accumB "x" [(time1,(++"y")),(time2,(++"z"))]
      
      



time1では「xy」を保持し、time2では「xyz」を保持するBehaviorを作成します。

私たちが知らないもう1つの機能は、結合です。 イベントを1つに結合します(2つのイベントが同時に発生した場合、結合は最初の引数の結合を優先します)。

これで、bsnakeの仕組みを理解できます。 最初に、ヘビはstartingSnakeと同等であり、次にいくつかの変更が発生します。



edieイベントは、ヘビが死んだときに発生します。これは、filterApplyを使用して実現されます。

 filterApply :: Behavior t (a -> Bool) -> Event ta -> Event ta
      
      



この関数は、動作内の述語を満たさないイベントを破棄します。 名前が示すように、これはフィルター+適用のようなものです。

組み合わせファンクターを使用して、何かを関数に変換する頻度に注目してください。



それでは、果物に移りましょう。

 -- Fruits bfruit :: Behavior t Pos bfruit = stepper (hdth `div` 2, wdth `div` 2) (brandom <@ egot) egot = filterApply ((\fsr _ -> elem fs && notElem rs) <$> bfruit <*> bsnake <*> brandom) etick
      
      



ヘビが現在のものを収集するとすぐに、ブランドの座標を持つ新しい果物が表示されます。 コンビネータは、1つの動作のコンテンツをイベントに「転送」します。つまり、この場合、エゴットイベントのコンテンツは、brandomからのランダムな座標に置き換えられます。 新しく追加されたステッパー関数は、イベントと初期値から動作を作成しますが、accumBとの唯一の違いは、新しい動作イベントが以前のイベントに依存しないことです。

エゴイベントは、ヘビが果実を収集し、新しい果実が体内に入らなくなると、そのタイマー信号でトリガーされます。



 -- Counter ecount = accumE 0 $ ((+1) <$ egot) `union` ((const 0) <$ edie)
      
      



ecountはポイントが増加するイベントです。 ご想像のとおり、accumEは行動ではなくイベントを作成します。 カウンターは、エゴットイベントで1増加し、エディでゼロになります。



 let edraw = apply ((,,) <$> bsnake <*> bfruit) etick
      
      



edrawはすべてのタイマー信号でトリガーされ、ヘビと果物の現在の位置が含まれます。



問題は小さいままです。画面に画像を表示します。

 reactimate $ fmap drawScreen edraw reactimate $ fmap (flip setCaption [] . (++) "Snake. Points: " . show) ecount
      
      



reactimate関数は、イベントからIOアクションを起動します。 drawScreenは画面を描画し、setCaptionはウィンドウの名前を変更します。

これでsetupNetworkが完了し、不足している機能のみを追加できます。

画面の初期化:

 initScreen = do glSetAttribute glDoubleBuffer 1 screen <- setVideoMode screenWidth screenHeight screenBpp [OpenGL] setCaption "Snake. Points: 0" [] clearColor $= Color4 0 0 0 0 matrixMode $= Projection loadIdentity ortho 0 (fromIntegral screenWidth) (fromIntegral screenHeight) 0 (-1) 1 matrixMode $= Modelview 0 loadIdentity
      
      





ランダム位置ジェネレーター:

 randomFruits :: IO Pos randomFruits = (,) <$> (randomRIO (0, wdth-1)) <*> (randomRIO (0, hdth-1))
      
      





さて、最後に、レンダリング機能:

 showSquare :: (GLfloat, GLfloat, GLfloat, GLfloat) -> Pos -> IO () showSquare (r, g, b, a) (x, y) = do -- Move to offset translate $ Vector3 (fromIntegral x*10 :: GLfloat) (fromIntegral y*10) 0 -- Start quad renderPrimitive Quads $ do -- Set color color $ Color4 rgba -- Draw square vertex $ Vertex3 (0 :: GLfloat) 0 0 vertex $ Vertex3 (10 :: GLfloat) 0 0 vertex $ Vertex3 (10 :: GLfloat) 10 0 vertex $ Vertex3 (0 :: GLfloat) 10 0 loadIdentity showFruit :: Pos -> IO () showFruit = showSquare (0, 1, 0, 1) showSnake :: Snake -> IO () showSnake = mapM_ (showSquare (1, 1, 1, 1)) drawScreen (s, f, t) = do clear [ColorBuffer] showSnake s showFruit f glSwapBuffers t' <- getTicks when ((t'-t) < ticks) (delay $ ticks - t' + t)
      
      





以上です。 コンパイルには、reactive-banana、opengl、sdlが必要です。 ここから、プログラムのソースファイルをダウンロードできます: minus.com/mZyZpD4Hx/1f



おわりに



小さなゲームの例を使用して、FRPの基本的な動作原理を示しました。プログラムの仕組みをイベントと動作のネットワークとして表現し、入力データと出力データを分離しました。 このような単純なプログラムでさえ、FRPの利点を見ることができます。たとえば、このパラダイムを使用しなければ、ゲームの状態に型を取得する必要はありませんでした。 この記事がリアクティブプログラミングの研究に役立ち、理解を促進することを願っています。



参照資料



hackage.haskell.org/package/reactive-banana-hackageのリアクティブバナナ

github.com/HeinrichApfelmus/reactive-banana-githubのプロジェクトリポジトリ。 例があります。



All Articles