最近、ClojureScriptに手を出してる。coffeeは手に馴染みすぎて飽きたし、TypeScriptはただのJS+ただの静的型付けで驚きがなくて刺激が足りない。
clojure-scriptが凄いのは、clojureプロジェクトとして開発されているということだ。clojureのコードが基本的にはそのまま動く。厳密にはまったく同じではないのだが、clojureサブセットだと思えば問題ない。
JVMが実装してあるわけではないので、clojure-scriptコンパイラはclojure-script -> javascriptの変換をしているわけだ。
僕は仕事で一番最初に使ったサーバー言語は実はclojureで、それなりに思い入れがあるのだが、とはいえちょっとの期間だったし、Lispに造詣が深いとも言えない。なので、勉強しながら使っている。
で、やってみたら色々問題があって、core.asyncに手を出した。その過程のメモ。
clojure-scriptでsetTimeoutループが書けない
JavaScriptでよくやるやつ
var update = function(){
setTimeout(function(){
console.log("updated!")
update()
}, 1000)
};
これをclojure-scriptで書いてようとした
(let [update (fn []
(.setTimeout js/window
(fn [] (println "hoge") (update)))
1000)]
(update))
結果
...
hoge main.js:2016
Uncaught TypeError: Cannot read property 'call' of undefined
このlet束縛が、実行フレームが異なると同一性を保証できない、というようにみえる。ここで無理やりJavaScript的な解決を試みてもいいのだが、それよりも郷に入っては郷に従えというし、clojure-scriptとしてこれはどういう実装を行うか調べたところ core.async を使う実装が多かった。
clojureのcore.asyncはGoのgo channel相当のものだと聞いているので、これを理解すればなんかあやふやだったgochも理解できるのでは?と思って手を出した。
書いてみる
一番簡単な例
(ns main
(:require-macros [cljs.core.async.macros :refer [go]])
(:require
[cljs.core.async :as async :refer [>! <! put! timeout chan]]))
(let [ch (chan)]
(go
(while true
(let [v (<! ch)]
(println "Read: " v))))
(go
(>! ch 1)))
結果
Read: 1
- goは非同期ブロック制御のマクロで、この中で
<!
や>!
が使える -
<!
はキューから値を読み出す。キューが空ならば、次の値が入って来るまでこのブロックでの処理は中断される- JSに慣れていると
(while true
が恐ろしく見えるが、実際は(!< ch)
が評価される度に中断されているので大丈夫 - 実際にスレッド(UIスレッド)をブロックするわけではないので、JS的にはyieldを使うgenerator方のイメージが近い
- JSに慣れていると
-
>!
はキューへ値をいれる。ノンブロッキング処理
つまり、上のgoブロックはひたすら入力を待つエコーサーバーみたいなものだ。
<!
がノンブロッキングであることは次のコードから確認できる
面倒なのでrequireする部分は省略。以降、特に変更はない。
(let [ch (chan)]
(go
(while true
(let [v (<! ch)]
(println "Read: " v))))
(go
(println "send 1")
(>! ch 1) ; [1]
(println "send 2")
(>! ch 2))); [1 2]
send 1 main.js:2016
send 2 main.js:2016
Read: 1 main.js:2016
Read: 2
この例は、厳密に同じではないが、JavaScriptでsetTimeoutが次フレームへのエンキューだと思えば、次のコードと同じ動きをする。
console.log("send1");
setTimeout(function(){console.log("ret 1")});
console.log("send2");
setTimeout(function(){console.log("ret 2")});
もちろん、これを表現したいがために周りくどい処理をしているわけではない。もっと別の役割がある。
(let [ch (chan)]
(go
(while true
(let [v (<! ch)]
(println "Read: " v))))
(go
(loop []
(<! (timeout 1000))
(>! ch 1)
(recur))))
clojureのloop/recurは末尾再帰のループだと認識している。たぶんwhileでもいいんだろうが、今まで見た例だとloopを使う例だった。
recurの意味がわからない人は似非原さんの記事をよむといい : Clojure :: recurが意味分からないという人のための簡単な再帰講座 - Line 1: Error: Invalid Blog('by Esehara' )
(<! (timeout 1000))
に注目してほしい。ここで引数としてchをとっていない!timeoutはコールされてから引数の時間(ミリ秒)後に値が解決されるチャンネルなのだろうと想像できる。
実際に(println (<! (timeout 1000)))
を試してみたらnilが返っていた。
nil
Read: 1
nil
Read: 1
...
この性質を利用してゲームのメインループを書いた記事がこれ。
Clojure - core.asyncでメインループを手に入れる - Qiita
atomで状態を管理する
次にatomを使って変化を起こすカウンタを作ってみる
(def counter (atom 0))
(let [ch (chan)]
(go
(while true
(let [v (<! ch)]
(println "Read: " v))))
(go
(loop []
(<! (timeout 1000))
(swap! counter inc)
(>! ch @counter)
(recur))))
結果
Read: 1
Read: 2
Read: 3
...
atomは本来のclojureではマルチスレッドでのトランザクションを解決するためのSTMとして実装されているのだが、cljsだとシングルスレッドなのであまり関係ない気もするのだが、お作法に習っておけばadd-watch
関数で値の監視のコールバックをとったりできる。
atomの状態を更新するにはswap!とreset!がある。reset! は完全に別の値に入れ替えてしまうのだが、swap! は前の状態へ第3引数の関数を第4引数以降の引数を使って適用する。…というとややこしそうだが、この例だと (inc 1) -> 2 みたいなもんだと思っておけば良い
atomの中の値を確認するのは (deref atom) もしくはリーダーマクロを使って @atom でいける。リーダーマクロというらしい。
マウスクリックをチャンネルに流す
DOMを触るので色々と初期化手続きをする
(.addEventListener js/window "load"
(fn []
(let [ch (chan)]
(go
(while true
(let [v (<! ch)] (println "Read: " v))))
(.addEventListener (.. js/window -document -body) "click" #(put! ch 1)))))
マウスクリックのコールバックの中で、チャンネルに1を流している。!<
は goの中でしか使えないので、代わりに put!
使う。 put!
を使うとgoの外からイベントを流せる。
どうやらコールバックの中は非同期なのでgoの外扱いらしい。goの中に入れても駄目だった。
次にこれをリファクタしてみよう。
チャンネル生成時に初期化してしまい、こいつはこんなイベントを流すやつです、ってのを明示する。
(defn click-chan [el]
(let [out (chan)]
(.addEventListener el "click" #(put! out "clicked!"))
out))
(.addEventListener js/window "load"
#(let [ch (click-chan (.. js/window -document -body))]
(go
(while true
(let [v (<! ch)] (println "Read: " v))))))
これで画面をクリックすれば Read: clicked
が流れてくるはず。
core.asyncのデザインパターンとしては、任意のものをイベントをストリーム化し、受け手がループで待ち構えて処理する。
学びがあった。