Haskell のアニメーションライブラリ Yampa についてのチュートリアル。またもや FRP (Functional Reactive Programming) の話題です。SOE の FAL は休憩して、その未来の姿である Yampa に寄り道します。Monad を一般化した Arrow なるものを使って、time / space leak なる物を排除し、モバイルロボットのプログラムをきっちり記述します。
プログラムの例として、シムボット(ロボットシミュレータ)を扱います。シムボットは二つの独立して動く車輪を持ち、ぶつかりセンサーや、前後左右に光学センサーを持ちます。また、他のシムボットの位置を知る事も出来ます。
シグナル (p 3)
一番重要な概念はシグナルです。
- Signal a = Time -> a
つまり、シグナルとは時間の関数です。例えばシムボットは車輪が二つあるので、それぞれの車輪の速度(speed)を Speed 型で表すとすれば、全体の速度(velocity)は Signal(Speed, Speed) で表現出来ます(speed と velocity の違いは何なのだろう。。。)。という事である時点のシムボットの x, y 座標と方向 theta は以下のようなプログラムで表現します。
- x = (1/2) * integral ((vr + vl) * cos theta)
- y = (1/2) * integral ((vr + vl) * sin theta)
- theta = (1/l) * integral (vr - vl)
こんなに上手く行くものか。。。それぞれの変数は暗黙の t が省略されています。例えば x は普通の数学の書き方では x(t) の事で、つまり関数です。積分とか言われると僕はもうサッパリですが、後で考えます。
Arrow
以前の FAL では、時間空間漏れの問題にすごい工夫が必要でしたが、Yampa ではすごい解決策を取っています。シグナルには直接触れず、シグナルの関数だけを操作出来る!そうです。そういった操作を SF 型と呼びます。
- SF a b = Signal a -> Signal b
つまり、勝手に時間の関数を書かれると絶対問題が起こるので、原始的なシグナルを最初から用意して、シグナルの関数を組み合わせる事で複雑なシグナルを構成するという方法をとります。こういったシグナルの関数(またはコンビネータ)の表現に Arrow を使います。Arrow が Monad と違う所は、Monad による組み合わせが直線的なのに比べて、Arrow ではもっと複雑な線に出来るそうです。
まず最初に必要な操作は、普通の関数をシグナル関数に "lift" する物です。lift というのは普通の言語で言うとコンストラクタの事です。シグナル a をシグナル b に変換する関数 (a -> b) を、arr によって SF 型に格納します。
arr :: (a -> b) -> SF a b
次に、シグナル関数同志を組み合わせる分けですが、関数合成の書き方、つまり f2(f1(x)) と書く代わりに (f2 . f1) x と書く方法をとります。ただし、関数の順序をひっくり返します。例えば f2(f1(シグナル)) のように合成するコンビネータを次のように書きます。
(>>>) :: SF a b -> SF b c -> SF a c g' :: SF A C g' = arr g = arr f1 >>> arr f2
入力が一つで出力が二つあるような操作を考えます。そういうのは &&& で表現します。
(&&&) :: SF a b -> SF a c -> SF a (b,c) h' :: SF A (B,C) h' = arr h = arr f1 &&& arr f2
何の役に立つのか分かりませんが、引数が二つあって答えそれぞれに関数を適用して二つの答えを返すのを *** で表現するそうです。
(***) :: SF b c -> SF b' c' -> SF (b,b') (c,c') f *** g = (arr fst >>> f) &&& (arr snd >>> g)
形式的な定義 (p 6)
Arrow の定義です。Yampa の SF は Arrow のインスタンスとして定義されています。(Java 的に言うと、SF は Arrow インタフェースを持ちます。) 今まで SF (シグナル関数) を組み合わせる基本操作として arr, >>>, &&& が出てきましたが、元々の基本セットは arr, >>>, first だそうです。&&& はここから導く事が出来ます。
class Arrow a where arr :: (b -> c) -> a b c (>>>) :: a b c -> a c d -> a b d first :: a b c -> a (b,d) (c,d)
そして、実はふつうの関数も Arrow のインスタンスです。これは分かりやすい例なので、分からなくなったら普通の関数の例を考えると良いと思います。最初から言えよ!
instance Arrow (->) where arr f = f f >>> g = g . f first f = \(b,d) -> (f b, d)
長々と意味の分からない事を書いてすみません。実は具体的な本当に面白い話が始まるのはこの後なのですが、眠いので寝ます。
シムボットの例を考える。(p 9)
続きを書くと書いたので続けます。シムボットの x 座標は以下の式で表現出来ると書きました。
- x = (1/2) * integral ((vr + vl) * cos theta)
これを Yampa で表現するには、シグナル(時間に応じて変化する速度や角度) そのままを扱う事が出来ません。変わりにシグナル関数を使います。例えば vr 右車輪速度の場合 vrSF :: SF SimbotImput Speed つまり、SimbotImput (外界情報シグナル) -> Speed (速度シグナル) の関数として扱います。
- SF SimbotImput Distance が x 座標取得装置全体を表現します。
- SimbotInput はシムボットの受け取る状態です。
- vrSF :: SF SimbotInput Speed は右車輪の速度です。
- vlSF :: SF SimbotInput Speed は左車輪の速度です。
- thetaSF :: SF SimbotInput Angle は角度です。
- arr2 は arr の二引数版。二項演算子を lift します。
xSF :: SF SimbotInput Distance xSF = let v = (vrSF &&& vlSF) >>> arr2 (+) t = thetaSF >>> arr cos in (v &&& t) >>> arr2 (*) >>> integral >>> arr (/2)
この中で、integral だけ妙に分かりにくいと思います。integral は普通の言語の ++ や increaseBy: に相当する物です。例えば integral 5 と言うと、最初の値は 0 で、一秒後が 5 で、二秒後が 10 で、、、と順に増えて行きます。普通と違うのは時間の概念を含む事で、例えば 1.5 秒後だとちゃんと 7.5 になります。内部的には (値 += 前回からの経過時間 * 引数) のようになっているので、引数が 5 のような定数でなく、変数になってくると数学的に積分した値と違って来るのが注意点です。この実装の良し悪しはともかく以下の点で非常に重要なポイントです。
Arrow 記法 (p 10)
さすがにこれでは難しいだろうという事で、構文糖を使った表現が提案されています。違う部分は &&& を省略できるぐらいで難しさは似たり寄ったりだと思いますが、参考までに。
xSF' :: SF SimbotInput Distance xSF' = proc inp -> do -- inp は入力 vr <- vrSF -< inp -- 意味は何となく取れるが実は良く分からない。 vl <- vlSF -< inp theta <- thetaSF -< inp i <- integral -< (vr+vl) * cos theta returnA -< (i/2)
不連続イベント (p11)
つづく!