読者です 読者をやめる 読者になる 読者になる

bitterharvest’s diary

A Bitter Harvestは小説の題名。作者は豪州のPeter Yeldham。苦闘の末に勝ちえた偏見からの解放は命との引換になったという悲しい物語

Netwireでゲームを作成する(7)

10.Netwireの概要

振舞いは次のように定義した。

newtype Behavior t a b = Behavior { stepBehavior :: t -> a -> Maybe (b,Behavior t a b) }

netwireではデータ型Wireによって振舞いを次のように定めている(5.0以前のNetwireでは、これまでの説明と同じようにnewtypeで定義されていた)。

data Wire s e m a b 

Wireの基本的な考え方はこれまでの説明と変わっていないが、前の記事を読まなかった人も理解できるように、詳しく説明する。

a,bともBehaviorで説明したa,bと同じで、aが入力、bが出力である。aにはイベント発生などの情報が含まれるが、これらを受けてbが出力される。Netwireではa,bを特別に作用値(reactive value)と呼んでいる。aからbへ矢印a→bを引くことで、wireの本質が把握しやすくなる(データ型Wireの値をwireと呼ぶ。また、リアクティブ・プログラミングでは作用値を振舞いと呼んでいる)。

本質の部分だけを抜き取って、\(wi re: a \rightarrow b \)と考えると、今まで説明してきたように、wireは一つの関数であると理解できる。即ち、wire(これまでの説明のように振舞いと言い換えてもよいが)は、aの影響を受けてbの状態になることと考えることができる。データ型Wireの値wireが関数であるというのは、圏論を学んでないと、馴染めないかもしれない。しかし、圏論の記事を読んだ人にとっては、データ型が対象であって、この場合の対象は集合ではなく写像だと考えると素直に受入れられると思う。

しかし、この記事で扱う例のほとんどはaの部分を必要としていない。わずかに、積分と間隔での一部の関数での説明にとどまっていることに留意してほしい。

Wireのsは、Behaviorのtに相当する。議論を分かりやすくするために、これまでBehaviorでの時間は現在の時刻として説明してきた。しかし、Wireではより優れた機能を提供できるようにするため、sは前の時間からの差分\(t\)である。これにより、微分積分の概念を導入しやすくなる。

Wireのeは抑制値である。Behaviorの説明では、tの時と同様に分かりやすくするために、抑制はMaybeを用いて説明したが、Wireでは抑制はEitherで実現されている。

現実のwireはdataで定義されているが、もし、newtypeで書き換えると次のようになる。MaybeがEitherに代わり、抑制値eが左側の入力であることが分かる。

newtype Wire s e m a b = Wire { stepWire :: s -> a -> m (Either e (b, Wire s e m a b)}

10.1 簡単な振舞い

それでは、Wireを少し操作する。最初にWireを定数の23とする。

ex1 :: Monad m => Wire s () m a Int
ex1 = pure 23

ここで、pureの型シグネチャは次のようになっている。

*Main> :t pure
pure :: Applicative f => a -> f a

従って、データ型aを取る値をデータ型f aをとる値に変更する。上記のex1は整数23をモナドmに属す値とする。値は23であるが、データ型がmに変更される。

これだけだと分かりにくいので、少し説明を加える。今、二つの圏がったとする。二項演算が可能な副作用を持たない圏\(\mathcal{C}\)、もう一つは、入出力のように副作用を伴う(状態があったり、順番があったりする)圏\(M\)があったとする。圏\(\mathcal{C}\)から圏\(\mathcal{M}\)への関手があったとする(\(F\)は\(\mathcal{C}\)から\(\mathcal{M}\)への写像だが、圏から圏への写像なので、特別に関手という用語を用いる)。圏\(\mathcal{C}\)にも圏\(\mathcal{M}\)にも、整数や、実数、文字、文字列があったとする(例えば、英語でのアラビア数字と日本語での漢数字)。異なる圏の間で同値なものを写像するのがpureである。
さらに、この二つの圏で、同じ演算子が用意されていたとする(例えば、+と足す)。この時、圏\(\mathcal{M}\)で計算が生じたとする。しかし、この圏で計算するのはが大変だったとする(この場合には漢数字が読めない、足すの意味が分からないなどで)。そこで、圏\(\mathcal{C}\)の側で計算して、その結果を圏\(\mathcal{M}\)に移しなおすというのが、前の記事で紹介した(<*>)である。これらの関係を図示したのが以下である(我々は、漢数字の世界に住んでいると考えて欲しい。例えば、江戸時代の大福帳を読み解いている。漢数字の世界では、時々、不便なことが起きるので、都合の良い時だけローマ数字の世界を利用してると考えるとよい)。
f:id:bitterharvest:20150620113548p:plain

ex1がどのような振舞いをするかは、次のプログラムで検証することができる(testWire pure()については後で説明する。ここでは、ex1を繰り返し、実行し、そのたびにex1の出力を表示すると考えて欲しい)。

*Main> :t pure
main1 :: IO ()
main1 = testWire (pure ()) ex1

main1を実行すると、次のように23を出力し続ける。即ち、振舞いは23ということとなる。

*Main> main1

23[K
23[K
23[K
23[K
23[K
23[K
23[K
23[K
23[K
23[K
23[K
23[K

次の例は、Double型の値を外部から指定できるようにした。

*Main> :t pure
ex2 :: Monad m => Double -> Wire s () m a Double
ex2 x = pure x

これの振舞いを検証する結果は次のようになる

*Main> :t pure
main2 :: Double -> IO ()
main2 x = testWire (pure ()) $ ex2 x

main2を実行すると、入力した値がずっと出力される。

*Main> main2 3.14

3.14[K
3.14[K
3.14[K

次の例は、入力を得て、計算し、その結果を振舞いとした。

*Main> :t pure
ex3 :: Monad m => Double -> Wire s () m a Double
ex3 x = pure $ x * x - 2

これの振舞いを検証する結果は次のようになる

*Main> :t pure
main3 :: Double -> IO ()
main3 x = testWire (pure ()) $ ex3 x

次の例は文字列を得て。それを振舞いとする。

*Main> :t pure
ex4 :: Monad m => String -> Wire s () m a String
ex4 x = pure x

これの振舞いを検証する結果は次のようになる

*Main> :t pure
main4 :: String -> IO ()
main4 x = testWire (pure ()) $ ex4 x

main4を実行すると、入力した文字列がずっと出力される。

*Main> main4 "Hello!"

"Hello!"[K
"Hello!"[K
"Hello!"[K
"Hello!"[K
"Hello!"[K
"Hello!"[K
"Hello!"[K

10.2 時間

Netwireの時間は、Behaviorで説明した時間を、一般化したものである。それぞれのwireは、通常は、0で始まる局所的な時間を持っている。しかし、開始時間を0以外の時間に設定したり、時計の進みを遅くしたり速くしたりすることが可能である。
システムには、timeと呼ばれるwireが存在する。このwireは型クラスrealに属す型を取ることができる。型シグネチャは次のようになっている。

time :: (HasTime t s) => Wire s e m a t

まずは時計そのものを観察する。

ex5 :: (Monad m, HasTime t s) => Wire s () m a t
ex5 = time

main5 :: IO ()
main5 = testWire clockSession_ ex5

下記の出力より、次のことが分かる。時計は、1ミリ秒ごとに刻まれている。システムのサンプリングタイムはこれより20倍くらい速く、ミリ秒に達しないときは、前の値をそのまま出力している。

*Main> main5

0s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.0010018s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.002012s[K
0.003002s[K
0.003002s[K

4倍速く進む時計は、次のようになる。

fmap (4*) time

また、120で始まり、4倍の速さでカウントダウンする時計は次のようになる。

ex6 :: (Monad m, HasTime t s) => Wire s () m a t
ex6 = liftA2 (\speed countdown -> countdown - 4*speed) time (pure 120)

main6 :: IO ()
main6 = testWire clockSession_ ex6

実行結果は以下のようになる

*Main> main6

120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
120s[K
119.995964s[K
119.995964s[K
119.995964s[K
119.995964s[K
119.995964s[K
119.995964s[K

ところで、いつも、pureやliftA2を用いて記述するのは煩わしい。そこで、これらを省いて、

liftA2 (+) (pure 25) time

は、糖衣構文として、

25 + time

と書くことにする。

10.3 位置、速度、加速度

時間が出てきたので、次は位置を求めてみる。\(x\)軸の方向に、出発点が\(a_0\)で、速度が\(v\)で歩き始めた人の\(t\)時間後の位置は\(x = a_0 + \int_0^t v dt \)である。
Netwireでは、積分を行うwireがある。これは、定数項を入力a0として次のように定めている。

integral a0

積分記号の中の式は、上記関数への入力として与えられ、これから出力bが定まる。従って、速度vが与えられた時の位置は、

integral a0 . v

となる(ここで、wireのsが積分での\(dt\)に、wireのaが出発点の\(a_0\)に、wireのbが\(a_0 + v \times t \)に対応する。Netwireの動きをもう少し正確に説明すると、セッション間の時間を\(dt\)する。前のセッションでの位置を\(x\)とすると、\(dt\)が微小な値だとすると、次のセッションでの位置\(x'\)は\(x + v \times dt\))となる。実際、netwireでもこのように部分に分けて計算される)。

a0=10, v=8とした時は、次のようになる。

ex7 :: (Monad m, HasTime t s) => Wire s () m a Double
ex7 = integral 10 . 8

main7 :: IO ()
main7 = testWire clockSession_ ex7

実行結果は次のようになる。

*Main> main7

10.0[K
10.0[K
10.0[K
10.0081264[K
10.0081264[K
10.0081264[K
10.0081264[K
10.0081264[K
10.0081264[K
10.0081264[K
10.0081264[K
10.0081264[K
10.0081264[K
10.0081264[K
10.0081264[K


それでは、高さ\(b_0\)から初速度\(v_0\)でまっすぐ上に打ち上げられたボールの\(t\)時間後の位置は\(y = b_0 + \int_0^t (v_0 - g \times t) dt \)である。なお、ここで、\(g\)は重力である。
これはNetwireでは次のようになる(ここで、wireのsが積分での\(dt\)に、wireのaが出発点の\(b_0\)に、wireのbが\(a_0 + v_0 \times t - \frac{1}{2} \times g \times t^2 \)に対応する)。

integral b0 . (v0 - g * time)

型タイプCategoryの結合(.)と型タイプApplicativeでの表現は読みにくいので、これをアロー記法で記述すると次のようになり、Javaなどのプログラムに慣れ親しんだ人には、ずっと見やすくなる(ただ、物理に慣れている人には前者のほうが分かりやすいかもしれない)。

proc _ -> do
  t <- time -< ()
  integral b0 -< v0 - g * t

それでは、b0=100,v0=10,g=9.8とした時のプログラムは次のようになる。

ex8 :: (Monad m, HasTime t s, Fractional t) => Wire s () m a t
ex8 = integral 100 . (10 + (-9.8) *  time)

main8 :: IO ()
main8 = testWire clockSession_ ex8

実行すると次のようになる

*Main> main8

100s[K
100s[K
100s[K
100.009995190197s[K
100.009995190197s[K
100.009995190197s[K
100.009995190197s[K
100.009995190197s[K
100.009995190197s[K

10.4 間隔

ある時間の間だけ信号を生み出して、それ以外では抑制してしまうwireを間隔(Intervals)と呼ぶ。間隔の一つにforがある。例えば、5秒間、"Yes, we can do."と出力するプログラムは次のようになる。

ex9 :: (Monad m, HasTime t s) => Wire s () m a String
ex9 = for 5 . (pure "Yes, we can do.")

main9 :: IO ()
main9 = testWire clockSession_ ex9

5秒経過したあたりの出力は次のようになる。

"Yes, we can do."[K
"Yes, we can do."[K
"Yes, we can do."[K
"Yes, we can do."[K
I: ()[K
I: ()[K
I: ()[K
I: ()[K

また、forの型シグネチャは次の通りである。

for :: (HasTime t s, Monoid e) => t -> Wire s e m a a

forは抑制になると、デフォルトではemptyという値をとる。また、出力はaではなく、eとなる。Netwireは通常この値を解釈することはできないので、通常はe=()とする。

先ほどのプログラムで、文字列ではなく、その時の時間を出力させるようにプログラムを変更すると次のようになる。

ex9' :: (Monad m, HasTime t s) => Wire s () m a t
ex9' = for 5 . time

main9' :: IO ()
main9' = testWire clockSession_ ex9'

5秒経過したあたりの出力は次のようになる。

4.9209474s[K
4.9209474s[K
4.9209474s[K
4.9209474s[K
I: ()[K
I: ()[K
I: ()[K
I: ()[K

forとは逆に、ある時間が経過した後に働く関数にafterがある。
例えば、5秒経過後からはその時の時間を出力するプログラムは次のようになる。

ex9'' :: (Monad m, HasTime t s) => Wire s () m a t
ex9'' = after 5 . time

main9'' :: IO ()
main9'' = testWire clockSession_ ex9''

5秒経過したあたりの出力は次のようになる。

4.9209474s[K
4.9209474s[K
4.9209474s[K
4.9209474s[K
I: ()[K
I: ()[K
I: ()[K
I: ()[K

10.5 イベント

イベントはNetwire5では、newtypeでではなく、dataで定義される。

data Event a

例えば、あらかじめ用意されているneverは決した発生しないイベントであるが、型シグネチャは次のようになっている

never :: Wire s e m a (Event b)

従って、このイベントからの出力はEvent型である。Event型に対する解釈はあらかじめ与えていないので、利用者が定義してインポートする必要がある。これを避けるためには、間隔としてあらかじめ用意されているwireで、これを使用することである。

例えば、イベントが発生するや否やイベントの値(Event a)を出力してくれるものに、asSoonAsがある。これの型シグネチャは次のようになっている。

asSoonAs :: (Monoid e) => Wire s e m (Event a) a

そこで、イベントのwireであるatを用いていくつか例を説明する。atは定められた時間にイベントを発生する。例えば、3秒経過したときにEventの値としてpure "3 seconds."をとるプログラムは、次のようになる。

at 3 . (pure "3 seconds.")

なお、atの型シグネチャは次のようになっている。

at :: HasTime t s => t -> Wire s e m a (Event a)

そこで、3秒たったらすぐにこのイベント値を出力するプログラムは、atをasSoonAsの入力とすることで、次のようになる。

ex11 :: (Monad m, HasTime t s) => Wire s () m a String
ex11 = asSoonAs . at 3 . (pure "3 seconds.")
main11 :: IO ()
main11 = testWire clockSession_ ex11

3秒経過したあたりの出力は次のようになる。

I: ()[K
I: ()[K
I: ()[K
"3 seconds."[K
"3 seconds."[K
"3 seconds."[K
"3 seconds."[K

出力される値をその時の時間に変えると次のようになる。

ex11' :: (Monad m, HasTime t s) => Wire s () m a t
ex11' = asSoonAs . at 3 . time
main11' :: IO ()
main11' = testWire clockSession_ ex11'

3秒経過したあたりの出力は次のようになった。

I: ()[K
I: ()[K
I: ()[K
3.0643861s[K
3.0643861s[K
3.0643861s[K

(<&)を用いることで、別々のイベントを検出できる。例えば、次に2秒経過した後に"Yes, we can do."と出力し、4秒経過した後に"No, you cannot do."と出力するプログラムは次のようになる。

ex12 :: (Monad m, HasTime t s) => Wire s () m a String
ex12 = asSoonAs . (at 2 . (pure "Yes, we can do.") <& at 4 . (pure "No, you cannot do."))
main12 :: IO ()
main12 = testWire clockSession_ ex12

2秒経過したあたりの出力は次のようになる。

I: ()[K
I: ()[K
I: ()[K
I: ()[K
"Yes, we can do."[K
"Yes, we can do."[K
"Yes, we can do."[K

4秒経過したあたりの出力は次のようになる。

"Yes, we can do."[K
"Yes, we can do."[K
"Yes, we can do."[K
"Yes, we can do."[K
"Yes, we can do."[K
"No, you cannot do."[K
"No, you cannot do."[K
"No, you cannot do."[K
"No, you cannot do."[K

時間に変えたプログラムは次のようになる。

ex12' :: (Monad m, HasTime t s) => Wire s () m a t
ex12' = asSoonAs . (at 2  <& at 4) . time
main12' :: IO ()
main12' = testWire clockSession_ ex12'

2秒経過したあたりの出力は次のようになる。

I: ()[K
I: ()[K
I: ()[K
I: ()[K
2.0432059s[K
2.0432059s[K
2.0432059s[K
2.0432059s[K
2.0432059s[K

4秒経過したあたりの出力は次のようになる。

2.0432059s[K
2.0432059s[K
2.0432059s[K
2.0432059s[K
4.1000621s[K
4.1000621s[K
4.1000621s[K
4.1000621s[K
4.1000621s[K

10.6 スイッチ

最後はダイナミックなスイッチ(-->)である。この型シグネチャは次のようになっている。

(-->) :: Monad m => Wire s e m a b -> Wire s e m a b -> Wire s e m a b

例えば、5秒経過するまでは"Yes, we can do."を出力し、その後は"No, you cannot do."を出力するプログラムは次のようになる。

ex13 :: (Monad m, HasTime t s) => Wire s () m a String
ex13 = for 5 . (pure "Yes, we can do.") --> (pure "No, you cannot do.")
main13 :: IO ()
main13 = testWire clockSession_ ex13

5秒経過したあたりの出力は次のようになる。

"Yes, we can do."[K
"Yes, we can do."[K
"Yes, we can do."[K
"Yes, we can do."[K
"No, you cannot do."[K
"No, you cannot do."[K
"No, you cannot do."[K
"No, you cannot do."[K
"No, you cannot do."[K

時間を出力するようにプログラムを変えると次のようになる。

ex13' :: (Monad m, HasTime t s) => Wire s () m a t
ex13' = for 5 . time --> time
main13' :: IO ()
main13' = testWire clockSession_ ex13'

5秒経過したあたりの出力は次のようになる。

4.905949s[K
4.9069499s[K
4.9069499s[K
4.9069499s[K
4.9069499s[K
0.1146717s[K
0.1146717s[K
0.1156645s[K
0.1156645s[K

このプログラムでは、5秒たった時に時計もリセットされ0から再び始まる。

つぎは、Networkのチュートリアルの最後に載っていた再帰的なプログラムを紹介する。

ex14 :: (Monad m, HasTime t s, Fractional t) => Wire s () m a String
ex14 =
    for 2 . (pure "Once upon a time...") -->
    for 3 . (pure "... games were completely imperative...") -->
    for 2 . (pure "... but then...") -->
    for 10 . ((pure "Netwire 5! ") <> anim) -->
    ex14

  where
    anim =
        holdFor 0.5 . periodic 1 . (pure "Hoo...") <|>
        (pure "...ray!")
main14 :: IO ()
main14 = testWire clockSession_ ex14

このプログラムは、最初の2秒間"Once upon a time..."を出力し、次の3秒間"... games were completely imperative..."、さらに2秒後に、"... but then..."と出力する。その後、"Netwire 5! "のあとに、"Hoo..."と"...ray!"を点滅する。これを10秒繰り返した後で、また、最初から繰り返す。