Clojure

core.asyncでメインループを手に入れる

More than 5 years have passed since last update.

 core.asyncなるライブラリが約1ヶ月前にリリースされましたが、皆さん使ってみましたか? 僕は昨日、今日とClojureScriptで使ってみました。各所の記事を見ていてもなにが嬉しいのかいまいちわからなかったんですが、さきほどやっとピンときたのでシェアさせてください。

 とても簡単なゲームを、イベント駆動、FRP、core.asyncのgoブロックをそれぞれ使って書いてみます。

ゲームの仕様

 コード量をなるべく減らすためにギリギリまで要素を切り詰めます。昔のBASICポケコンのゲームにも見劣りする程です。(参考: ポケコン哀歌)
 
 1次元空間をプレイヤーが歩き回ることができるローグライクゲームです。1次元空間なので、プレイヤーはx座標だけを持ちます。ローグライクなのでプレイヤーの移動はvimキー、つまりh j k lを使いますが、上下方向はないので左への移動にh(キーコード72)、右への移動にl(キーコード76)を使います。描画は伝統に則りプレイヤーを@、床を.で表現します。

イメージ:

[...@...]    -Lキー押下->    [....@..]
             <-Hキー押下-

 笑うなよ若者達、心の目を開くんだ。

全ゲームに共通の定数、関数を定義しておきます

game-base.clj
(ns line-rogue.base)

(def left-key 72)     ;;Hキーのキーコード
(def right-key 76)    ;;Lキーのキーコード
(def board-width 30)  ;;盤の横幅

(defn render-game
  "省略。プレイヤーの座標をとって盤を描画する"
  [player-x dom-id])

イベント駆動

 普通、ターン制でユーザーの入力を受け取るプログラムは、ゲームでもREPL(シェル)でもそうですが、ループがぐるぐる回っていて、1ターンの処理をしたらブロックしてユーザの入力を待って次のターンに移るという書き方をします。(loop [] (->> read eval print recur))だとreadがブロックするといった感じ。

 でもJSって基本はシングルスレッドで、かつ1つのページで複数の無関係なコードが実行され得るので、あるコードがスレッドをブロックしちゃうと別の無関係なコードまでなにもできなくなっちゃうんですよね。なのでJSにはスレッドをブロックする方法というのが用意されていません(ビジーウェイトでCPU食い尽くす以外)。

 じゃあどうするかっていうと、ブラウザがkeydownイベントを発行したときに実行してもらうコードを登録しておくという方法でユーザの入力を受け取ります。

event-driven-game.cljs
(ns line-rogue.event-driven
  (:use [line-rogue.base :only [left-key right-key render-game]]))

(def player-x (atom 0))

(defn game-turn
  [key]
  (condp = key
    left-key                      ;;hキーなら
    (swap! player-x dec)          ;;プレイヤー座標を1減らす(左に動く)

    right-key                     ;;lキーなら
    (swap! player-x inc)          ;;プレイヤー座標を1増やす(右に動く)

    nil)
    (render-game @player-x)) ;;描画

(.addEventListener js/document "keydown" #(game-turn (.-keyCode %)))

  ノンブロッキングでよさげな雰囲気ですが、この方法の欠点は、誰かが必ずグローバルな状態を持たなければいけないことです。この例ではplayer-xを束縛しているatomがその役割を担っています。実際には状態が一つだけなら大きな問題にはならないと思うのですが、どちらにしろDOMのイベントリスナやAJAXのコールバックは副作用がなければ意味をなさない、関数型的でない仕組みではあります。

FRP

 HaskellのGUI界隈で人気の方法です。JSでもbacon.jsというのがそこそこ人気があったり、ElmというJavaScriptにコンパイルされるHaskell風構文のFRP専用言語があったりします。jsのRxをClojureScriptから使うという話もあります。

 FRPを数行で説明するとなると難しいですが、(consしか受け付けない (リストの atom))を組み合わせてプログラムを作ることを想像すると良いです。このatomをbehaviourと呼ぶ事にします。例えばinput要素に文字が入力されたらそのイベントオブジェクトがconsされるbehaviourを作っておくと、behaviour用にリフトされたmapを使って、イベントオブジェクトからターゲット要素のvalueを取ってきたbehaviourを作って、同じくbehaviourにリフトされたfilterを使って無効な値を取り除いたbehaviourを作って、なんやかんやあって画面に出力するという形でプログラムを作れます。

今回のゲームに使う必要最低限のbehaviourの実装はこちらを参照してください。もう少しプラクティカルなものはcastorocauda.timelineにほぼ同じ方法の実装があります。

FRPな1次元ローグライクを作るとこんな風になります。FRPの細かいコンセプトは気にせず、雰囲気だけ感じ取ってください。

frp-game.cljs
(ns line-rogue.frp
  (:require [behaviours.core :as b])
  (:use [line-rogue.base :only [left-key right-key render-game]]))

(def player-x-beh (b/behaviour))

(defn dom-events [el ev]
  "`el`で発火した`ev`のbehaviour"
  (let [beh (b/behaviour)]
    (.addEventListener el ev #(b/behaviour-cons! % beh))
    beh))

;;;キーコードの垂れ流し
(def key-code-beh
  (->> (dom-events js/document "keydown")     ;;keydownのイベントのbehaviour
       (b/behaviour-map #(->> % .-keyCode)))) ;;keyCodeのbehaviour

;;;左に移動
(->> key-code-beh
     (b/behaviour-filter #(= left-key %))                  ;;hキーだけ抽出して
     (b/behaviour-map    #(b/behaviour-dec player-x-beh))) ;;プレイヤー座標を更新

;;;右に移動
(->> key-code-beh
     (b/behaviour-filter #(= right-key %))                 ;;lキーだけ抽出して
     (b/behaviour-map    #(b/behaviour-inc player-x-beh))) ;;プレイヤー座標を更新

;;;描画
(b/behaviour-map render-game player-x-beh)

 わりかし恰好良いですよね。想像力豊かな人ならbehaviourは過去、現在、未来、全ての状態を持った変数であるかのように扱えます。それぞれのbehaviourが状態を持つには持つんですが、実行中にダイナミックに購読を開始したり止めたりということを行わなければ状態に悩まされる事はほぼありません。

 僕はFRPはかなり良いと思うんですが、どうやらこことかここ見ると、Rich HickeyやDavid Nolen(core.logicとかclojurescriptの人)はFRPがあまり好きではないようです。"イベントが起きたときにコードが呼ばれる"というIOC(Inversion of Control; 制御の逆転)があるからイベント駆動と結局大して変わらないでしょとのことみたいです。そんな彼らが作ったのが次に紹介するcore.asyncです。

core.async

 いよいよ本題です。ここではcore.asyncの全てのAPIの説明はしません。今回の1次元ローグライクゲームに関係ある事だけ。特にClojureScript版のcore.asyncには通常のスレッドはないのでgoブロックのことだけ扱います。

 さっきのDavid Nolenのツイートとか、Communicating Sequential Processesを見るとcore.asyncでFRPできるみたいな事が書いてありますがこれはミスリーディングです。 core.asyncはFRP用ではありません。core.asyncの仕組みの上でFRPっぽいことをしようとすると多分アンハッピーになります。チャンネルのパブリッシャを作るためにFRPと組み合わせるとかならアリです。

 core.asyncはchannelというキュー状のデータ構造を中心に構成されていますが、core.asyncを理解するポイントはこれをデータ構造として見ないことです。channelはcore.asyncがやりたいことを実現するための手段でしかありません。

 core.asyncの、特にcljs.core.asyncのキモはgoブロックです。goは、 シーケンシャルなコードをイベント駆動のコードに展開するマクロ です。実際にmacroexpandで見てみると面白いです。この際、展開のヒントとして、channelと、channelから値を取り出す(デキューする)<!と、チャンネルに値を入れる(エンキュー)する>!を使うということのようです。詳しくは見ていないですがおそらく(<! some-channel)がsome-channelへのイベントリスナに変換されるのでしょう。これによって、goブロックの中ではあたかもブロッキング読み出しをしているかのようなコードが書ける事になり、しかもそれはノンブロッキングなコードに変換されます。

 ここまではドキュメントとかwalkthroughとか見ればわかるんですが、実際の使い道というか利点が最初はいまいちわかりませんでした。timeoutとかajaxでネストしなくて済むのはたしかにかっこいいですがそれはFRPでもできるし、僕が完全に非同期脳だったので「(while true ...)できるよ!」とか言われてもハァ?でした。

 それでさっきピンと来たのがターンベースのゲームループとかREPLの例でした。お待たせしました。1次元ローグライク、core.async版です。

core-async-game.cljs
(ns line-rogue.core-async
  (:use [cljs.core.async :only [chan <! put!]]
        [line-rogue.base :only [left-key right-key render-game]])
  (:use-macros [cljs.core.async.macros :only [go]]))

;;入力されたキーコードが流れ込んでくるチャンネル
(def input-chan
  (let [c (chan)]
    (.addEventListener js/document "keydown" #(put! c (.-keyCode %)))
    c))


(defn next-x
  "現在のx座標と入力から次のx座標を得る"
  [current-x key-code]
  (condp =  key-code
    left-key  (dec current-x)
    right-key (inc current-x)
    current-x))

;;;;;;;;;;;;;;;;;;;;;
;;;; ここに注目! ;;;;
;;;;;;;;;;;;;;;;;;;;;
(defn main []
  (go
   (loop [player-x 5]
     (let [key-code (<! input-chan)                ;;R: 入力を待つ
           x        (next-x player-x key-code)]    ;;E: 次の位置を計算
       (render-game x "core-async")                ;;P: 描画
       (recur x)))))                               ;;L: 再帰

(main)

 メインループだ!JavaScriptなのに!ブラウザに住み着いてはや2年、骨の髄まで非同期プログラミングが染み付いた身にはこれは衝撃的です。この書き方ができればゲームの現在の状態というのは再帰の中にうまいこと隠せます。これは嬉しい。

 ネストされたコールバックをシーケンシャルに書けるようにしてくれるという点で、addEventListenerに対するgoはHaskellの>>=に対するdoに似ていますね。core.asyncは、channelという実体に注目せずに文法的な支援であると考えればすんなり理解できました。

終わり

 core.asyncの私が理解した部分だけを紹介しました。また、イベント駆動、FRP、そしてcore.asyncと3つの書き方で同じゲームを作ってみる事でそれぞれの特徴を学びました。
 
 イベント駆動には明らかな問題があることがわかりましたが、FRPとcore.asyncではどちらを使うべきなのでしょうか。ClojureScriptに限って言えば、良いFRPライブラリがない(javelinは普通のFRPとなんか違う)状況なので、clojureチームが作っているcore.asyncが手堅い選択かと思います。一般にFRPが良いのかコード変換が良いのかはまだ私の中では結論が出せていません。

 思ったより長くなってしまいました。core.asyncが気になっている方のお役に立てたら嬉しいです。実際にビルドできるコードをbitbucket/ympbyc/line-rogueに置いておきました。

ではまた。